claude-usage-dashboard 1.5.0 → 1.5.2
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 +122 -122
- package/bin/cli.cjs +20 -20
- package/bin/cli.js +16 -16
- package/bin/cli.sh +11 -11
- package/package.json +43 -43
- package/public/css/style.css +287 -287
- package/public/index.html +123 -123
- package/public/js/api.js +17 -17
- package/public/js/app.js +326 -326
- package/public/js/charts/cache-efficiency.js +29 -29
- package/public/js/charts/cost-comparison.js +39 -39
- package/public/js/charts/model-distribution.js +56 -56
- package/public/js/charts/project-distribution.js +103 -103
- package/public/js/charts/quota-cycles.js +209 -209
- package/public/js/charts/session-stats.js +117 -117
- package/public/js/charts/token-trend.js +357 -357
- package/public/js/components/date-picker.js +35 -35
- package/public/js/components/plan-selector.js +57 -57
- package/server/aggregator.js +151 -151
- package/server/credentials.js +112 -112
- package/server/index.js +53 -45
- package/server/parser.js +129 -129
- package/server/pricing.js +52 -52
- package/server/quota-cycles.js +274 -274
- package/server/routes/api.js +175 -175
- package/server/sync.js +69 -69
package/server/pricing.js
CHANGED
|
@@ -1,52 +1,52 @@
|
|
|
1
|
-
export const MODEL_PRICING = {
|
|
2
|
-
'claude-opus-4-6': {
|
|
3
|
-
input_price_per_mtok: 5,
|
|
4
|
-
output_price_per_mtok: 25,
|
|
5
|
-
cache_read_price_per_mtok: 0.50,
|
|
6
|
-
cache_creation_price_per_mtok: 6.25,
|
|
7
|
-
},
|
|
8
|
-
'claude-sonnet-4-6': {
|
|
9
|
-
input_price_per_mtok: 3,
|
|
10
|
-
output_price_per_mtok: 15,
|
|
11
|
-
cache_read_price_per_mtok: 0.30,
|
|
12
|
-
cache_creation_price_per_mtok: 3.75,
|
|
13
|
-
},
|
|
14
|
-
'claude-haiku-4-5': {
|
|
15
|
-
input_price_per_mtok: 1,
|
|
16
|
-
output_price_per_mtok: 5,
|
|
17
|
-
cache_read_price_per_mtok: 0.10,
|
|
18
|
-
cache_creation_price_per_mtok: 1.25,
|
|
19
|
-
},
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
export const PLAN_DEFAULTS = {
|
|
23
|
-
pro: 20,
|
|
24
|
-
max5x: 100,
|
|
25
|
-
max20x: 200,
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
export function getModelPricing(modelId) {
|
|
29
|
-
return MODEL_PRICING[modelId] || null;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Calculate the API cost for a single usage record.
|
|
34
|
-
* Returns 0 for unknown models.
|
|
35
|
-
*
|
|
36
|
-
* In Claude Code logs, input_tokens is the non-cached input.
|
|
37
|
-
* cache_read_tokens and cache_creation_tokens are separate, additive fields.
|
|
38
|
-
* cost = input * input_rate + cache_read * read_rate + cache_creation * write_rate + output * output_rate
|
|
39
|
-
*/
|
|
40
|
-
export function calculateRecordCost(record) {
|
|
41
|
-
const pricing = MODEL_PRICING[record.model];
|
|
42
|
-
if (!pricing) return 0;
|
|
43
|
-
|
|
44
|
-
const M = 1_000_000;
|
|
45
|
-
|
|
46
|
-
return (
|
|
47
|
-
(record.input_tokens / M) * pricing.input_price_per_mtok +
|
|
48
|
-
(record.cache_read_tokens / M) * pricing.cache_read_price_per_mtok +
|
|
49
|
-
(record.cache_creation_tokens / M) * pricing.cache_creation_price_per_mtok +
|
|
50
|
-
(record.output_tokens / M) * pricing.output_price_per_mtok
|
|
51
|
-
);
|
|
52
|
-
}
|
|
1
|
+
export const MODEL_PRICING = {
|
|
2
|
+
'claude-opus-4-6': {
|
|
3
|
+
input_price_per_mtok: 5,
|
|
4
|
+
output_price_per_mtok: 25,
|
|
5
|
+
cache_read_price_per_mtok: 0.50,
|
|
6
|
+
cache_creation_price_per_mtok: 6.25,
|
|
7
|
+
},
|
|
8
|
+
'claude-sonnet-4-6': {
|
|
9
|
+
input_price_per_mtok: 3,
|
|
10
|
+
output_price_per_mtok: 15,
|
|
11
|
+
cache_read_price_per_mtok: 0.30,
|
|
12
|
+
cache_creation_price_per_mtok: 3.75,
|
|
13
|
+
},
|
|
14
|
+
'claude-haiku-4-5': {
|
|
15
|
+
input_price_per_mtok: 1,
|
|
16
|
+
output_price_per_mtok: 5,
|
|
17
|
+
cache_read_price_per_mtok: 0.10,
|
|
18
|
+
cache_creation_price_per_mtok: 1.25,
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const PLAN_DEFAULTS = {
|
|
23
|
+
pro: 20,
|
|
24
|
+
max5x: 100,
|
|
25
|
+
max20x: 200,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function getModelPricing(modelId) {
|
|
29
|
+
return MODEL_PRICING[modelId] || null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Calculate the API cost for a single usage record.
|
|
34
|
+
* Returns 0 for unknown models.
|
|
35
|
+
*
|
|
36
|
+
* In Claude Code logs, input_tokens is the non-cached input.
|
|
37
|
+
* cache_read_tokens and cache_creation_tokens are separate, additive fields.
|
|
38
|
+
* cost = input * input_rate + cache_read * read_rate + cache_creation * write_rate + output * output_rate
|
|
39
|
+
*/
|
|
40
|
+
export function calculateRecordCost(record) {
|
|
41
|
+
const pricing = MODEL_PRICING[record.model];
|
|
42
|
+
if (!pricing) return 0;
|
|
43
|
+
|
|
44
|
+
const M = 1_000_000;
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
(record.input_tokens / M) * pricing.input_price_per_mtok +
|
|
48
|
+
(record.cache_read_tokens / M) * pricing.cache_read_price_per_mtok +
|
|
49
|
+
(record.cache_creation_tokens / M) * pricing.cache_creation_price_per_mtok +
|
|
50
|
+
(record.output_tokens / M) * pricing.output_price_per_mtok
|
|
51
|
+
);
|
|
52
|
+
}
|
package/server/quota-cycles.js
CHANGED
|
@@ -1,274 +1,274 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
import os from 'os';
|
|
4
|
-
import { parseLogDirectory } from './parser.js';
|
|
5
|
-
import { filterByDateRange } from './aggregator.js';
|
|
6
|
-
import { calculateRecordCost } from './pricing.js';
|
|
7
|
-
|
|
8
|
-
const MAX_HISTORY = 52;
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Pure computation: given records (already filtered to a cycle) and quota data,
|
|
12
|
-
* compute actual tokens/cost and project at 100% utilization.
|
|
13
|
-
*/
|
|
14
|
-
export function computeCycleData(records, quotaData) {
|
|
15
|
-
const overallUtil = quotaData.seven_day?.utilization || 0;
|
|
16
|
-
const opusUtil = quotaData.seven_day_opus?.utilization || 0;
|
|
17
|
-
const sonnetUtil = quotaData.seven_day_sonnet?.utilization || 0;
|
|
18
|
-
|
|
19
|
-
// Per-type accumulators
|
|
20
|
-
let inTok = 0, outTok = 0, crTok = 0, cwTok = 0, totalCost = 0;
|
|
21
|
-
let opusIn = 0, opusOut = 0, opusCR = 0, opusCW = 0, opusCost = 0;
|
|
22
|
-
let sonIn = 0, sonOut = 0, sonCR = 0, sonCW = 0, sonnetCost = 0;
|
|
23
|
-
|
|
24
|
-
for (const r of records) {
|
|
25
|
-
const cost = calculateRecordCost(r);
|
|
26
|
-
inTok += r.input_tokens; outTok += r.output_tokens;
|
|
27
|
-
crTok += r.cache_read_tokens; cwTok += r.cache_creation_tokens;
|
|
28
|
-
totalCost += cost;
|
|
29
|
-
|
|
30
|
-
if (r.model?.includes('opus')) {
|
|
31
|
-
opusIn += r.input_tokens; opusOut += r.output_tokens;
|
|
32
|
-
opusCR += r.cache_read_tokens; opusCW += r.cache_creation_tokens;
|
|
33
|
-
opusCost += cost;
|
|
34
|
-
} else if (r.model?.includes('sonnet')) {
|
|
35
|
-
sonIn += r.input_tokens; sonOut += r.output_tokens;
|
|
36
|
-
sonCR += r.cache_read_tokens; sonCW += r.cache_creation_tokens;
|
|
37
|
-
sonnetCost += cost;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
totalCost = Math.round(totalCost * 100) / 100;
|
|
42
|
-
opusCost = Math.round(opusCost * 100) / 100;
|
|
43
|
-
sonnetCost = Math.round(sonnetCost * 100) / 100;
|
|
44
|
-
|
|
45
|
-
function buildTokens(inp, out, cr, cw) {
|
|
46
|
-
return { input: inp, output: out, cacheRead: cr, cacheCreation: cw };
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function project(actual, utilization) {
|
|
50
|
-
if (utilization <= 0) return null;
|
|
51
|
-
return Math.round(actual / (utilization / 100));
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function projectCost(actual, utilization) {
|
|
55
|
-
if (utilization <= 0) return null;
|
|
56
|
-
return Math.round((actual / (utilization / 100)) * 100) / 100;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// actualTokens = total excluding cache reads (in + out + cw) — used for projections
|
|
60
|
-
const totalExclCR = inTok + outTok + cwTok;
|
|
61
|
-
const opusExclCR = opusIn + opusOut + opusCW;
|
|
62
|
-
const sonExclCR = sonIn + sonOut + sonCW;
|
|
63
|
-
|
|
64
|
-
return {
|
|
65
|
-
overall: {
|
|
66
|
-
utilization: overallUtil,
|
|
67
|
-
tokens: buildTokens(inTok, outTok, crTok, cwTok),
|
|
68
|
-
actualTokens: totalExclCR,
|
|
69
|
-
projectedTokensAt100: project(totalExclCR, overallUtil),
|
|
70
|
-
actualCost: totalCost,
|
|
71
|
-
projectedCostAt100: projectCost(totalCost, overallUtil),
|
|
72
|
-
},
|
|
73
|
-
models: {
|
|
74
|
-
opus: {
|
|
75
|
-
utilization: opusUtil,
|
|
76
|
-
tokens: buildTokens(opusIn, opusOut, opusCR, opusCW),
|
|
77
|
-
actualTokens: opusExclCR,
|
|
78
|
-
projectedTokensAt100: project(opusExclCR, opusUtil),
|
|
79
|
-
actualCost: opusCost,
|
|
80
|
-
projectedCostAt100: projectCost(opusCost, opusUtil),
|
|
81
|
-
},
|
|
82
|
-
sonnet: {
|
|
83
|
-
utilization: sonnetUtil,
|
|
84
|
-
tokens: buildTokens(sonIn, sonOut, sonCR, sonCW),
|
|
85
|
-
actualTokens: sonExclCR,
|
|
86
|
-
projectedTokensAt100: project(sonExclCR, sonnetUtil),
|
|
87
|
-
actualCost: sonnetCost,
|
|
88
|
-
projectedCostAt100: projectCost(sonnetCost, sonnetUtil),
|
|
89
|
-
},
|
|
90
|
-
},
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Update the quota cycle snapshot file for this machine.
|
|
96
|
-
* Called after each successful quota API fetch.
|
|
97
|
-
*
|
|
98
|
-
* @param {object} quotaData - Quota API response (must have available === true)
|
|
99
|
-
* @param {string} logBaseDir - This machine's log directory (~/.claude/projects/)
|
|
100
|
-
* @param {string} machineName - Identifier for this machine
|
|
101
|
-
* @param {string} [snapshotDir] - Directory for snapshot files (defaults to syncDir or ~/.claude/)
|
|
102
|
-
* @param {string} [syncDir] - Shared sync directory; used as fallback when snapshotDir is not set
|
|
103
|
-
*/
|
|
104
|
-
export function updateQuotaCycleSnapshot(quotaData, logBaseDir, machineName, snapshotDir, syncDir) {
|
|
105
|
-
if (!quotaData?.available || !quotaData.seven_day?.resets_at) return;
|
|
106
|
-
|
|
107
|
-
const dir = snapshotDir || syncDir || path.join(os.homedir(), '.claude');
|
|
108
|
-
const filePath = path.join(dir, `quota-cycles-${machineName}.json`);
|
|
109
|
-
|
|
110
|
-
// Normalize resets_at to second precision — the API returns varying microseconds
|
|
111
|
-
// on each call (e.g. .905316 vs .581788) which would cause false cycle switches
|
|
112
|
-
const rawResetsAt = new Date(quotaData.seven_day.resets_at);
|
|
113
|
-
rawResetsAt.setMilliseconds(0);
|
|
114
|
-
const resetsAt = rawResetsAt.toISOString();
|
|
115
|
-
const start = new Date(rawResetsAt.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
116
|
-
|
|
117
|
-
let snapshot;
|
|
118
|
-
try {
|
|
119
|
-
snapshot = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
120
|
-
} catch {
|
|
121
|
-
snapshot = { schemaVersion: 1, machineName, currentCycle: null, history: [] };
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// Compare normalized timestamps to detect actual cycle boundary changes
|
|
125
|
-
const storedResetNorm = snapshot.currentCycle
|
|
126
|
-
? new Date(snapshot.currentCycle.resets_at).setMilliseconds(0)
|
|
127
|
-
: null;
|
|
128
|
-
if (snapshot.currentCycle && storedResetNorm !== rawResetsAt.getTime()) {
|
|
129
|
-
snapshot.history.unshift(snapshot.currentCycle);
|
|
130
|
-
if (snapshot.history.length > MAX_HISTORY) {
|
|
131
|
-
snapshot.history = snapshot.history.slice(0, MAX_HISTORY);
|
|
132
|
-
}
|
|
133
|
-
snapshot.currentCycle = null;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
const allRecords = parseLogDirectory(logBaseDir);
|
|
137
|
-
const cycleRecords = filterByDateRange(allRecords, start, resetsAt);
|
|
138
|
-
const cycleData = computeCycleData(cycleRecords, quotaData);
|
|
139
|
-
|
|
140
|
-
snapshot.currentCycle = {
|
|
141
|
-
resets_at: resetsAt,
|
|
142
|
-
start,
|
|
143
|
-
lastUpdated: new Date().toISOString(),
|
|
144
|
-
...cycleData,
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
fs.writeFileSync(filePath, JSON.stringify(snapshot, null, 2));
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Load and merge quota cycle data from all machines.
|
|
152
|
-
*
|
|
153
|
-
* @param {string} machineName - This machine's name
|
|
154
|
-
* @param {string|null} syncDir - Shared sync directory (null if single-machine)
|
|
155
|
-
* @param {string} [snapshotDir] - Directory for snapshot files (defaults to ~/.claude/)
|
|
156
|
-
* @returns {{ currentCycle: object|null, history: object[], machines: string[] }}
|
|
157
|
-
*/
|
|
158
|
-
export function loadQuotaCycles(machineName, syncDir, snapshotDir) {
|
|
159
|
-
const dir = snapshotDir || syncDir || path.join(os.homedir(), '.claude');
|
|
160
|
-
const empty = { currentCycle: null, history: [], machines: [] };
|
|
161
|
-
|
|
162
|
-
let files;
|
|
163
|
-
try {
|
|
164
|
-
files = fs.readdirSync(dir).filter(f => f.startsWith('quota-cycles-') && f.endsWith('.json'));
|
|
165
|
-
} catch {
|
|
166
|
-
return empty;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
if (files.length === 0) return empty;
|
|
170
|
-
|
|
171
|
-
const snapshots = [];
|
|
172
|
-
for (const file of files) {
|
|
173
|
-
try {
|
|
174
|
-
const data = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf-8'));
|
|
175
|
-
if (data.schemaVersion === 1) snapshots.push(data);
|
|
176
|
-
} catch { /* skip corrupt files */ }
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
if (snapshots.length === 0) return empty;
|
|
180
|
-
|
|
181
|
-
const machines = snapshots.map(s => s.machineName);
|
|
182
|
-
|
|
183
|
-
if (snapshots.length === 1) {
|
|
184
|
-
const s = snapshots[0];
|
|
185
|
-
return { currentCycle: s.currentCycle, history: s.history, machines };
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
return {
|
|
189
|
-
currentCycle: mergeCycles(snapshots.map(s => s.currentCycle).filter(Boolean)),
|
|
190
|
-
history: mergeHistories(snapshots.map(s => s.history)),
|
|
191
|
-
machines,
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
function mergeCycles(cycles) {
|
|
196
|
-
if (cycles.length === 0) return null;
|
|
197
|
-
if (cycles.length === 1) return cycles[0];
|
|
198
|
-
|
|
199
|
-
const byReset = new Map();
|
|
200
|
-
for (const c of cycles) {
|
|
201
|
-
const key = c.resets_at;
|
|
202
|
-
if (!byReset.has(key)) byReset.set(key, []);
|
|
203
|
-
byReset.get(key).push(c);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
let bestKey = null, bestCount = 0;
|
|
207
|
-
for (const [key, arr] of byReset) {
|
|
208
|
-
if (arr.length > bestCount || (arr.length === bestCount && key > bestKey)) { bestKey = key; bestCount = arr.length; }
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
const sameCycle = byReset.get(bestKey);
|
|
212
|
-
return mergeSamePeriodCycles(sameCycle);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function mergeSamePeriodCycles(cycles) {
|
|
216
|
-
const mostRecent = cycles.reduce((a, b) =>
|
|
217
|
-
new Date(a.lastUpdated) > new Date(b.lastUpdated) ? a : b
|
|
218
|
-
);
|
|
219
|
-
|
|
220
|
-
return {
|
|
221
|
-
resets_at: mostRecent.resets_at,
|
|
222
|
-
start: mostRecent.start,
|
|
223
|
-
lastUpdated: mostRecent.lastUpdated,
|
|
224
|
-
overall: mergeMetrics(cycles.map(c => c.overall), mostRecent.overall.utilization),
|
|
225
|
-
models: {
|
|
226
|
-
opus: mergeMetrics(cycles.map(c => c.models.opus), mostRecent.models?.opus?.utilization || 0),
|
|
227
|
-
sonnet: mergeMetrics(cycles.map(c => c.models.sonnet), mostRecent.models?.sonnet?.utilization || 0),
|
|
228
|
-
},
|
|
229
|
-
};
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
function mergeMetrics(metricsArray, utilization) {
|
|
233
|
-
const totalTokens = metricsArray.reduce((sum, m) => sum + (m?.actualTokens || 0), 0);
|
|
234
|
-
const totalCost = Math.round(metricsArray.reduce((sum, m) => sum + (m?.actualCost || 0), 0) * 100) / 100;
|
|
235
|
-
|
|
236
|
-
// Merge per-type token breakdown
|
|
237
|
-
const tokens = { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 };
|
|
238
|
-
for (const m of metricsArray) {
|
|
239
|
-
if (m?.tokens) {
|
|
240
|
-
tokens.input += m.tokens.input || 0;
|
|
241
|
-
tokens.output += m.tokens.output || 0;
|
|
242
|
-
tokens.cacheRead += m.tokens.cacheRead || 0;
|
|
243
|
-
tokens.cacheCreation += m.tokens.cacheCreation || 0;
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
return {
|
|
248
|
-
utilization,
|
|
249
|
-
tokens,
|
|
250
|
-
actualTokens: totalTokens,
|
|
251
|
-
projectedTokensAt100: utilization > 0 ? Math.round(totalTokens / (utilization / 100)) : null,
|
|
252
|
-
actualCost: totalCost,
|
|
253
|
-
projectedCostAt100: utilization > 0 ? Math.round((totalCost / (utilization / 100)) * 100) / 100 : null,
|
|
254
|
-
};
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
function mergeHistories(historyArrays) {
|
|
258
|
-
const byReset = new Map();
|
|
259
|
-
for (const history of historyArrays) {
|
|
260
|
-
for (const entry of history) {
|
|
261
|
-
const key = entry.resets_at;
|
|
262
|
-
if (!byReset.has(key)) byReset.set(key, []);
|
|
263
|
-
byReset.get(key).push(entry);
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
const merged = [];
|
|
268
|
-
for (const [, entries] of byReset) {
|
|
269
|
-
merged.push(mergeSamePeriodCycles(entries));
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
merged.sort((a, b) => new Date(b.resets_at) - new Date(a.resets_at));
|
|
273
|
-
return merged.slice(0, MAX_HISTORY);
|
|
274
|
-
}
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { parseLogDirectory } from './parser.js';
|
|
5
|
+
import { filterByDateRange } from './aggregator.js';
|
|
6
|
+
import { calculateRecordCost } from './pricing.js';
|
|
7
|
+
|
|
8
|
+
const MAX_HISTORY = 52;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Pure computation: given records (already filtered to a cycle) and quota data,
|
|
12
|
+
* compute actual tokens/cost and project at 100% utilization.
|
|
13
|
+
*/
|
|
14
|
+
export function computeCycleData(records, quotaData) {
|
|
15
|
+
const overallUtil = quotaData.seven_day?.utilization || 0;
|
|
16
|
+
const opusUtil = quotaData.seven_day_opus?.utilization || 0;
|
|
17
|
+
const sonnetUtil = quotaData.seven_day_sonnet?.utilization || 0;
|
|
18
|
+
|
|
19
|
+
// Per-type accumulators
|
|
20
|
+
let inTok = 0, outTok = 0, crTok = 0, cwTok = 0, totalCost = 0;
|
|
21
|
+
let opusIn = 0, opusOut = 0, opusCR = 0, opusCW = 0, opusCost = 0;
|
|
22
|
+
let sonIn = 0, sonOut = 0, sonCR = 0, sonCW = 0, sonnetCost = 0;
|
|
23
|
+
|
|
24
|
+
for (const r of records) {
|
|
25
|
+
const cost = calculateRecordCost(r);
|
|
26
|
+
inTok += r.input_tokens; outTok += r.output_tokens;
|
|
27
|
+
crTok += r.cache_read_tokens; cwTok += r.cache_creation_tokens;
|
|
28
|
+
totalCost += cost;
|
|
29
|
+
|
|
30
|
+
if (r.model?.includes('opus')) {
|
|
31
|
+
opusIn += r.input_tokens; opusOut += r.output_tokens;
|
|
32
|
+
opusCR += r.cache_read_tokens; opusCW += r.cache_creation_tokens;
|
|
33
|
+
opusCost += cost;
|
|
34
|
+
} else if (r.model?.includes('sonnet')) {
|
|
35
|
+
sonIn += r.input_tokens; sonOut += r.output_tokens;
|
|
36
|
+
sonCR += r.cache_read_tokens; sonCW += r.cache_creation_tokens;
|
|
37
|
+
sonnetCost += cost;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
totalCost = Math.round(totalCost * 100) / 100;
|
|
42
|
+
opusCost = Math.round(opusCost * 100) / 100;
|
|
43
|
+
sonnetCost = Math.round(sonnetCost * 100) / 100;
|
|
44
|
+
|
|
45
|
+
function buildTokens(inp, out, cr, cw) {
|
|
46
|
+
return { input: inp, output: out, cacheRead: cr, cacheCreation: cw };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function project(actual, utilization) {
|
|
50
|
+
if (utilization <= 0) return null;
|
|
51
|
+
return Math.round(actual / (utilization / 100));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function projectCost(actual, utilization) {
|
|
55
|
+
if (utilization <= 0) return null;
|
|
56
|
+
return Math.round((actual / (utilization / 100)) * 100) / 100;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// actualTokens = total excluding cache reads (in + out + cw) — used for projections
|
|
60
|
+
const totalExclCR = inTok + outTok + cwTok;
|
|
61
|
+
const opusExclCR = opusIn + opusOut + opusCW;
|
|
62
|
+
const sonExclCR = sonIn + sonOut + sonCW;
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
overall: {
|
|
66
|
+
utilization: overallUtil,
|
|
67
|
+
tokens: buildTokens(inTok, outTok, crTok, cwTok),
|
|
68
|
+
actualTokens: totalExclCR,
|
|
69
|
+
projectedTokensAt100: project(totalExclCR, overallUtil),
|
|
70
|
+
actualCost: totalCost,
|
|
71
|
+
projectedCostAt100: projectCost(totalCost, overallUtil),
|
|
72
|
+
},
|
|
73
|
+
models: {
|
|
74
|
+
opus: {
|
|
75
|
+
utilization: opusUtil,
|
|
76
|
+
tokens: buildTokens(opusIn, opusOut, opusCR, opusCW),
|
|
77
|
+
actualTokens: opusExclCR,
|
|
78
|
+
projectedTokensAt100: project(opusExclCR, opusUtil),
|
|
79
|
+
actualCost: opusCost,
|
|
80
|
+
projectedCostAt100: projectCost(opusCost, opusUtil),
|
|
81
|
+
},
|
|
82
|
+
sonnet: {
|
|
83
|
+
utilization: sonnetUtil,
|
|
84
|
+
tokens: buildTokens(sonIn, sonOut, sonCR, sonCW),
|
|
85
|
+
actualTokens: sonExclCR,
|
|
86
|
+
projectedTokensAt100: project(sonExclCR, sonnetUtil),
|
|
87
|
+
actualCost: sonnetCost,
|
|
88
|
+
projectedCostAt100: projectCost(sonnetCost, sonnetUtil),
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Update the quota cycle snapshot file for this machine.
|
|
96
|
+
* Called after each successful quota API fetch.
|
|
97
|
+
*
|
|
98
|
+
* @param {object} quotaData - Quota API response (must have available === true)
|
|
99
|
+
* @param {string} logBaseDir - This machine's log directory (~/.claude/projects/)
|
|
100
|
+
* @param {string} machineName - Identifier for this machine
|
|
101
|
+
* @param {string} [snapshotDir] - Directory for snapshot files (defaults to syncDir or ~/.claude/)
|
|
102
|
+
* @param {string} [syncDir] - Shared sync directory; used as fallback when snapshotDir is not set
|
|
103
|
+
*/
|
|
104
|
+
export function updateQuotaCycleSnapshot(quotaData, logBaseDir, machineName, snapshotDir, syncDir) {
|
|
105
|
+
if (!quotaData?.available || !quotaData.seven_day?.resets_at) return;
|
|
106
|
+
|
|
107
|
+
const dir = snapshotDir || syncDir || path.join(os.homedir(), '.claude');
|
|
108
|
+
const filePath = path.join(dir, `quota-cycles-${machineName}.json`);
|
|
109
|
+
|
|
110
|
+
// Normalize resets_at to second precision — the API returns varying microseconds
|
|
111
|
+
// on each call (e.g. .905316 vs .581788) which would cause false cycle switches
|
|
112
|
+
const rawResetsAt = new Date(quotaData.seven_day.resets_at);
|
|
113
|
+
rawResetsAt.setMilliseconds(0);
|
|
114
|
+
const resetsAt = rawResetsAt.toISOString();
|
|
115
|
+
const start = new Date(rawResetsAt.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
116
|
+
|
|
117
|
+
let snapshot;
|
|
118
|
+
try {
|
|
119
|
+
snapshot = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
120
|
+
} catch {
|
|
121
|
+
snapshot = { schemaVersion: 1, machineName, currentCycle: null, history: [] };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Compare normalized timestamps to detect actual cycle boundary changes
|
|
125
|
+
const storedResetNorm = snapshot.currentCycle
|
|
126
|
+
? new Date(snapshot.currentCycle.resets_at).setMilliseconds(0)
|
|
127
|
+
: null;
|
|
128
|
+
if (snapshot.currentCycle && storedResetNorm !== rawResetsAt.getTime()) {
|
|
129
|
+
snapshot.history.unshift(snapshot.currentCycle);
|
|
130
|
+
if (snapshot.history.length > MAX_HISTORY) {
|
|
131
|
+
snapshot.history = snapshot.history.slice(0, MAX_HISTORY);
|
|
132
|
+
}
|
|
133
|
+
snapshot.currentCycle = null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const allRecords = parseLogDirectory(logBaseDir);
|
|
137
|
+
const cycleRecords = filterByDateRange(allRecords, start, resetsAt);
|
|
138
|
+
const cycleData = computeCycleData(cycleRecords, quotaData);
|
|
139
|
+
|
|
140
|
+
snapshot.currentCycle = {
|
|
141
|
+
resets_at: resetsAt,
|
|
142
|
+
start,
|
|
143
|
+
lastUpdated: new Date().toISOString(),
|
|
144
|
+
...cycleData,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
fs.writeFileSync(filePath, JSON.stringify(snapshot, null, 2));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Load and merge quota cycle data from all machines.
|
|
152
|
+
*
|
|
153
|
+
* @param {string} machineName - This machine's name
|
|
154
|
+
* @param {string|null} syncDir - Shared sync directory (null if single-machine)
|
|
155
|
+
* @param {string} [snapshotDir] - Directory for snapshot files (defaults to ~/.claude/)
|
|
156
|
+
* @returns {{ currentCycle: object|null, history: object[], machines: string[] }}
|
|
157
|
+
*/
|
|
158
|
+
export function loadQuotaCycles(machineName, syncDir, snapshotDir) {
|
|
159
|
+
const dir = snapshotDir || syncDir || path.join(os.homedir(), '.claude');
|
|
160
|
+
const empty = { currentCycle: null, history: [], machines: [] };
|
|
161
|
+
|
|
162
|
+
let files;
|
|
163
|
+
try {
|
|
164
|
+
files = fs.readdirSync(dir).filter(f => f.startsWith('quota-cycles-') && f.endsWith('.json'));
|
|
165
|
+
} catch {
|
|
166
|
+
return empty;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (files.length === 0) return empty;
|
|
170
|
+
|
|
171
|
+
const snapshots = [];
|
|
172
|
+
for (const file of files) {
|
|
173
|
+
try {
|
|
174
|
+
const data = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf-8'));
|
|
175
|
+
if (data.schemaVersion === 1) snapshots.push(data);
|
|
176
|
+
} catch { /* skip corrupt files */ }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (snapshots.length === 0) return empty;
|
|
180
|
+
|
|
181
|
+
const machines = snapshots.map(s => s.machineName);
|
|
182
|
+
|
|
183
|
+
if (snapshots.length === 1) {
|
|
184
|
+
const s = snapshots[0];
|
|
185
|
+
return { currentCycle: s.currentCycle, history: s.history, machines };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
currentCycle: mergeCycles(snapshots.map(s => s.currentCycle).filter(Boolean)),
|
|
190
|
+
history: mergeHistories(snapshots.map(s => s.history)),
|
|
191
|
+
machines,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function mergeCycles(cycles) {
|
|
196
|
+
if (cycles.length === 0) return null;
|
|
197
|
+
if (cycles.length === 1) return cycles[0];
|
|
198
|
+
|
|
199
|
+
const byReset = new Map();
|
|
200
|
+
for (const c of cycles) {
|
|
201
|
+
const key = c.resets_at;
|
|
202
|
+
if (!byReset.has(key)) byReset.set(key, []);
|
|
203
|
+
byReset.get(key).push(c);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let bestKey = null, bestCount = 0;
|
|
207
|
+
for (const [key, arr] of byReset) {
|
|
208
|
+
if (arr.length > bestCount || (arr.length === bestCount && key > bestKey)) { bestKey = key; bestCount = arr.length; }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const sameCycle = byReset.get(bestKey);
|
|
212
|
+
return mergeSamePeriodCycles(sameCycle);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function mergeSamePeriodCycles(cycles) {
|
|
216
|
+
const mostRecent = cycles.reduce((a, b) =>
|
|
217
|
+
new Date(a.lastUpdated) > new Date(b.lastUpdated) ? a : b
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
resets_at: mostRecent.resets_at,
|
|
222
|
+
start: mostRecent.start,
|
|
223
|
+
lastUpdated: mostRecent.lastUpdated,
|
|
224
|
+
overall: mergeMetrics(cycles.map(c => c.overall), mostRecent.overall.utilization),
|
|
225
|
+
models: {
|
|
226
|
+
opus: mergeMetrics(cycles.map(c => c.models.opus), mostRecent.models?.opus?.utilization || 0),
|
|
227
|
+
sonnet: mergeMetrics(cycles.map(c => c.models.sonnet), mostRecent.models?.sonnet?.utilization || 0),
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function mergeMetrics(metricsArray, utilization) {
|
|
233
|
+
const totalTokens = metricsArray.reduce((sum, m) => sum + (m?.actualTokens || 0), 0);
|
|
234
|
+
const totalCost = Math.round(metricsArray.reduce((sum, m) => sum + (m?.actualCost || 0), 0) * 100) / 100;
|
|
235
|
+
|
|
236
|
+
// Merge per-type token breakdown
|
|
237
|
+
const tokens = { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 };
|
|
238
|
+
for (const m of metricsArray) {
|
|
239
|
+
if (m?.tokens) {
|
|
240
|
+
tokens.input += m.tokens.input || 0;
|
|
241
|
+
tokens.output += m.tokens.output || 0;
|
|
242
|
+
tokens.cacheRead += m.tokens.cacheRead || 0;
|
|
243
|
+
tokens.cacheCreation += m.tokens.cacheCreation || 0;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
utilization,
|
|
249
|
+
tokens,
|
|
250
|
+
actualTokens: totalTokens,
|
|
251
|
+
projectedTokensAt100: utilization > 0 ? Math.round(totalTokens / (utilization / 100)) : null,
|
|
252
|
+
actualCost: totalCost,
|
|
253
|
+
projectedCostAt100: utilization > 0 ? Math.round((totalCost / (utilization / 100)) * 100) / 100 : null,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function mergeHistories(historyArrays) {
|
|
258
|
+
const byReset = new Map();
|
|
259
|
+
for (const history of historyArrays) {
|
|
260
|
+
for (const entry of history) {
|
|
261
|
+
const key = entry.resets_at;
|
|
262
|
+
if (!byReset.has(key)) byReset.set(key, []);
|
|
263
|
+
byReset.get(key).push(entry);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const merged = [];
|
|
268
|
+
for (const [, entries] of byReset) {
|
|
269
|
+
merged.push(mergeSamePeriodCycles(entries));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
merged.sort((a, b) => new Date(b.resets_at) - new Date(a.resets_at));
|
|
273
|
+
return merged.slice(0, MAX_HISTORY);
|
|
274
|
+
}
|