claude-usage-dashboard 1.5.2 → 1.5.4
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 +333 -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 -53
- package/server/parser.js +151 -129
- package/server/pricing.js +52 -52
- package/server/quota-cycles.js +325 -274
- package/server/routes/api.js +175 -175
- package/server/sync.js +104 -69
package/server/quota-cycles.js
CHANGED
|
@@ -1,274 +1,325 @@
|
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
return
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
+
* Normalize a cycle's resets_at to hour precision for use as a dedup key.
|
|
12
|
+
* The API returns varying sub-second precision across calls and machines,
|
|
13
|
+
* so raw strings cannot be used for grouping.
|
|
14
|
+
*/
|
|
15
|
+
function cyclePeriodKey(cycle) {
|
|
16
|
+
const d = new Date(cycle.resets_at);
|
|
17
|
+
d.setMinutes(0, 0, 0);
|
|
18
|
+
return d.toISOString();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Deduplicate history entries that represent the same quota cycle period.
|
|
23
|
+
* Keeps the entry with the latest lastUpdated for each period.
|
|
24
|
+
*/
|
|
25
|
+
function deduplicateHistory(history) {
|
|
26
|
+
const byKey = new Map();
|
|
27
|
+
for (const entry of history) {
|
|
28
|
+
const key = cyclePeriodKey(entry);
|
|
29
|
+
const existing = byKey.get(key);
|
|
30
|
+
if (!existing || new Date(entry.lastUpdated) > new Date(existing.lastUpdated)) {
|
|
31
|
+
byKey.set(key, entry);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const result = Array.from(byKey.values());
|
|
35
|
+
result.sort((a, b) => new Date(b.resets_at) - new Date(a.resets_at));
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Pure computation: given records (already filtered to a cycle) and quota data,
|
|
41
|
+
* compute actual tokens/cost and project at 100% utilization.
|
|
42
|
+
*/
|
|
43
|
+
export function computeCycleData(records, quotaData) {
|
|
44
|
+
const overallUtil = quotaData.seven_day?.utilization || 0;
|
|
45
|
+
const opusUtil = quotaData.seven_day_opus?.utilization || 0;
|
|
46
|
+
const sonnetUtil = quotaData.seven_day_sonnet?.utilization || 0;
|
|
47
|
+
|
|
48
|
+
// Per-type accumulators
|
|
49
|
+
let inTok = 0, outTok = 0, crTok = 0, cwTok = 0, totalCost = 0;
|
|
50
|
+
let opusIn = 0, opusOut = 0, opusCR = 0, opusCW = 0, opusCost = 0;
|
|
51
|
+
let sonIn = 0, sonOut = 0, sonCR = 0, sonCW = 0, sonnetCost = 0;
|
|
52
|
+
|
|
53
|
+
for (const r of records) {
|
|
54
|
+
const cost = calculateRecordCost(r);
|
|
55
|
+
inTok += r.input_tokens; outTok += r.output_tokens;
|
|
56
|
+
crTok += r.cache_read_tokens; cwTok += r.cache_creation_tokens;
|
|
57
|
+
totalCost += cost;
|
|
58
|
+
|
|
59
|
+
if (r.model?.includes('opus')) {
|
|
60
|
+
opusIn += r.input_tokens; opusOut += r.output_tokens;
|
|
61
|
+
opusCR += r.cache_read_tokens; opusCW += r.cache_creation_tokens;
|
|
62
|
+
opusCost += cost;
|
|
63
|
+
} else if (r.model?.includes('sonnet')) {
|
|
64
|
+
sonIn += r.input_tokens; sonOut += r.output_tokens;
|
|
65
|
+
sonCR += r.cache_read_tokens; sonCW += r.cache_creation_tokens;
|
|
66
|
+
sonnetCost += cost;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
totalCost = Math.round(totalCost * 100) / 100;
|
|
71
|
+
opusCost = Math.round(opusCost * 100) / 100;
|
|
72
|
+
sonnetCost = Math.round(sonnetCost * 100) / 100;
|
|
73
|
+
|
|
74
|
+
function buildTokens(inp, out, cr, cw) {
|
|
75
|
+
return { input: inp, output: out, cacheRead: cr, cacheCreation: cw };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function project(actual, utilization) {
|
|
79
|
+
if (utilization <= 0) return null;
|
|
80
|
+
return Math.round(actual / (utilization / 100));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function projectCost(actual, utilization) {
|
|
84
|
+
if (utilization <= 0) return null;
|
|
85
|
+
return Math.round((actual / (utilization / 100)) * 100) / 100;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// actualTokens = total excluding cache reads (in + out + cw) — used for projections
|
|
89
|
+
const totalExclCR = inTok + outTok + cwTok;
|
|
90
|
+
const opusExclCR = opusIn + opusOut + opusCW;
|
|
91
|
+
const sonExclCR = sonIn + sonOut + sonCW;
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
overall: {
|
|
95
|
+
utilization: overallUtil,
|
|
96
|
+
tokens: buildTokens(inTok, outTok, crTok, cwTok),
|
|
97
|
+
actualTokens: totalExclCR,
|
|
98
|
+
projectedTokensAt100: project(totalExclCR, overallUtil),
|
|
99
|
+
actualCost: totalCost,
|
|
100
|
+
projectedCostAt100: projectCost(totalCost, overallUtil),
|
|
101
|
+
},
|
|
102
|
+
models: {
|
|
103
|
+
opus: {
|
|
104
|
+
utilization: opusUtil,
|
|
105
|
+
tokens: buildTokens(opusIn, opusOut, opusCR, opusCW),
|
|
106
|
+
actualTokens: opusExclCR,
|
|
107
|
+
projectedTokensAt100: project(opusExclCR, opusUtil),
|
|
108
|
+
actualCost: opusCost,
|
|
109
|
+
projectedCostAt100: projectCost(opusCost, opusUtil),
|
|
110
|
+
},
|
|
111
|
+
sonnet: {
|
|
112
|
+
utilization: sonnetUtil,
|
|
113
|
+
tokens: buildTokens(sonIn, sonOut, sonCR, sonCW),
|
|
114
|
+
actualTokens: sonExclCR,
|
|
115
|
+
projectedTokensAt100: project(sonExclCR, sonnetUtil),
|
|
116
|
+
actualCost: sonnetCost,
|
|
117
|
+
projectedCostAt100: projectCost(sonnetCost, sonnetUtil),
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Update the quota cycle snapshot file for this machine.
|
|
125
|
+
* Called after each successful quota API fetch.
|
|
126
|
+
*
|
|
127
|
+
* @param {object} quotaData - Quota API response (must have available === true)
|
|
128
|
+
* @param {string} logBaseDir - This machine's log directory (~/.claude/projects/)
|
|
129
|
+
* @param {string} machineName - Identifier for this machine
|
|
130
|
+
* @param {string} [snapshotDir] - Directory for snapshot files (defaults to syncDir or ~/.claude/)
|
|
131
|
+
* @param {string} [syncDir] - Shared sync directory; used as fallback when snapshotDir is not set
|
|
132
|
+
*/
|
|
133
|
+
export function updateQuotaCycleSnapshot(quotaData, logBaseDir, machineName, snapshotDir, syncDir) {
|
|
134
|
+
if (!quotaData?.available || !quotaData.seven_day?.resets_at) return;
|
|
135
|
+
|
|
136
|
+
const dir = snapshotDir || syncDir || path.join(os.homedir(), '.claude');
|
|
137
|
+
const filePath = path.join(dir, `quota-cycles-${machineName}.json`);
|
|
138
|
+
|
|
139
|
+
// Normalize resets_at to second precision — the API returns varying microseconds
|
|
140
|
+
// on each call (e.g. .905316 vs .581788) which would cause false cycle switches
|
|
141
|
+
const rawResetsAt = new Date(quotaData.seven_day.resets_at);
|
|
142
|
+
rawResetsAt.setMilliseconds(0);
|
|
143
|
+
const resetsAt = rawResetsAt.toISOString();
|
|
144
|
+
const start = new Date(rawResetsAt.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
145
|
+
|
|
146
|
+
let snapshot;
|
|
147
|
+
try {
|
|
148
|
+
snapshot = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
149
|
+
} catch {
|
|
150
|
+
snapshot = { schemaVersion: 1, machineName, currentCycle: null, history: [] };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Compare normalized period keys to detect actual cycle boundary changes.
|
|
154
|
+
// Uses hour-precision keys to tolerate varying sub-second timestamps from the API.
|
|
155
|
+
const storedKey = snapshot.currentCycle ? cyclePeriodKey(snapshot.currentCycle) : null;
|
|
156
|
+
const newKey = cyclePeriodKey({ resets_at: resetsAt });
|
|
157
|
+
if (snapshot.currentCycle && storedKey !== newKey) {
|
|
158
|
+
snapshot.history.unshift(snapshot.currentCycle);
|
|
159
|
+
snapshot.history = deduplicateHistory(snapshot.history);
|
|
160
|
+
if (snapshot.history.length > MAX_HISTORY) {
|
|
161
|
+
snapshot.history = snapshot.history.slice(0, MAX_HISTORY);
|
|
162
|
+
}
|
|
163
|
+
snapshot.currentCycle = null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const allRecords = parseLogDirectory(logBaseDir);
|
|
167
|
+
const cycleRecords = filterByDateRange(allRecords, start, resetsAt);
|
|
168
|
+
const cycleData = computeCycleData(cycleRecords, quotaData);
|
|
169
|
+
|
|
170
|
+
snapshot.currentCycle = {
|
|
171
|
+
resets_at: resetsAt,
|
|
172
|
+
start,
|
|
173
|
+
lastUpdated: new Date().toISOString(),
|
|
174
|
+
...cycleData,
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
fs.writeFileSync(filePath, JSON.stringify(snapshot, null, 2));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Load and merge quota cycle data from all machines.
|
|
182
|
+
*
|
|
183
|
+
* @param {string} machineName - This machine's name
|
|
184
|
+
* @param {string|null} syncDir - Shared sync directory (null if single-machine)
|
|
185
|
+
* @param {string} [snapshotDir] - Directory for snapshot files (defaults to ~/.claude/)
|
|
186
|
+
* @returns {{ currentCycle: object|null, history: object[], machines: string[] }}
|
|
187
|
+
*/
|
|
188
|
+
export function loadQuotaCycles(machineName, syncDir, snapshotDir) {
|
|
189
|
+
const dir = snapshotDir || syncDir || path.join(os.homedir(), '.claude');
|
|
190
|
+
const empty = { currentCycle: null, history: [], machines: [] };
|
|
191
|
+
|
|
192
|
+
let files;
|
|
193
|
+
try {
|
|
194
|
+
files = fs.readdirSync(dir).filter(f => f.startsWith('quota-cycles-') && f.endsWith('.json'));
|
|
195
|
+
} catch {
|
|
196
|
+
return empty;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (files.length === 0) return empty;
|
|
200
|
+
|
|
201
|
+
const snapshots = [];
|
|
202
|
+
for (const file of files) {
|
|
203
|
+
try {
|
|
204
|
+
const data = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf-8'));
|
|
205
|
+
if (data.schemaVersion === 1) snapshots.push(data);
|
|
206
|
+
} catch { /* skip corrupt files */ }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (snapshots.length === 0) return empty;
|
|
210
|
+
|
|
211
|
+
const machines = snapshots.map(s => s.machineName);
|
|
212
|
+
|
|
213
|
+
if (snapshots.length === 1) {
|
|
214
|
+
// Single machine: duplicates are time-series snapshots of the same data,
|
|
215
|
+
// so dedup (keep most recent) and filter out current-cycle overlap.
|
|
216
|
+
const s = snapshots[0];
|
|
217
|
+
let history = deduplicateHistory(s.history);
|
|
218
|
+
if (s.currentCycle) {
|
|
219
|
+
const currentKey = cyclePeriodKey(s.currentCycle);
|
|
220
|
+
history = history.filter(h => cyclePeriodKey(h) !== currentKey);
|
|
221
|
+
}
|
|
222
|
+
return { currentCycle: s.currentCycle, history, machines };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Multi-machine: dedup within each machine first (removes false-switch
|
|
226
|
+
// duplicates), then merge across machines (sums different machines' data).
|
|
227
|
+
const dedupedHistories = snapshots.map(s => deduplicateHistory(s.history));
|
|
228
|
+
let currentCycle = mergeCycles(snapshots.map(s => s.currentCycle).filter(Boolean));
|
|
229
|
+
let history = mergeHistories(dedupedHistories);
|
|
230
|
+
|
|
231
|
+
// If history contains entries for the current cycle's period (e.g. from an
|
|
232
|
+
// offline machine whose cycle was archived), merge them INTO the current
|
|
233
|
+
// cycle instead of dropping them.
|
|
234
|
+
if (currentCycle) {
|
|
235
|
+
const currentKey = cyclePeriodKey(currentCycle);
|
|
236
|
+
const overlapping = history.filter(h => cyclePeriodKey(h) === currentKey);
|
|
237
|
+
history = history.filter(h => cyclePeriodKey(h) !== currentKey);
|
|
238
|
+
if (overlapping.length > 0) {
|
|
239
|
+
currentCycle = mergeSamePeriodCycles([currentCycle, ...overlapping]);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { currentCycle, history, machines };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function mergeCycles(cycles) {
|
|
247
|
+
if (cycles.length === 0) return null;
|
|
248
|
+
if (cycles.length === 1) return cycles[0];
|
|
249
|
+
|
|
250
|
+
const byPeriod = new Map();
|
|
251
|
+
for (const c of cycles) {
|
|
252
|
+
const key = cyclePeriodKey(c);
|
|
253
|
+
if (!byPeriod.has(key)) byPeriod.set(key, []);
|
|
254
|
+
byPeriod.get(key).push(c);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
let bestKey = null, bestCount = 0;
|
|
258
|
+
for (const [key, arr] of byPeriod) {
|
|
259
|
+
if (arr.length > bestCount || (arr.length === bestCount && key > bestKey)) { bestKey = key; bestCount = arr.length; }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const sameCycle = byPeriod.get(bestKey);
|
|
263
|
+
return mergeSamePeriodCycles(sameCycle);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function mergeSamePeriodCycles(cycles) {
|
|
267
|
+
const mostRecent = cycles.reduce((a, b) =>
|
|
268
|
+
new Date(a.lastUpdated) > new Date(b.lastUpdated) ? a : b
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
resets_at: mostRecent.resets_at,
|
|
273
|
+
start: mostRecent.start,
|
|
274
|
+
lastUpdated: mostRecent.lastUpdated,
|
|
275
|
+
overall: mergeMetrics(cycles.map(c => c.overall), mostRecent.overall.utilization),
|
|
276
|
+
models: {
|
|
277
|
+
opus: mergeMetrics(cycles.map(c => c.models.opus), mostRecent.models?.opus?.utilization || 0),
|
|
278
|
+
sonnet: mergeMetrics(cycles.map(c => c.models.sonnet), mostRecent.models?.sonnet?.utilization || 0),
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function mergeMetrics(metricsArray, utilization) {
|
|
284
|
+
const totalTokens = metricsArray.reduce((sum, m) => sum + (m?.actualTokens || 0), 0);
|
|
285
|
+
const totalCost = Math.round(metricsArray.reduce((sum, m) => sum + (m?.actualCost || 0), 0) * 100) / 100;
|
|
286
|
+
|
|
287
|
+
// Merge per-type token breakdown
|
|
288
|
+
const tokens = { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 };
|
|
289
|
+
for (const m of metricsArray) {
|
|
290
|
+
if (m?.tokens) {
|
|
291
|
+
tokens.input += m.tokens.input || 0;
|
|
292
|
+
tokens.output += m.tokens.output || 0;
|
|
293
|
+
tokens.cacheRead += m.tokens.cacheRead || 0;
|
|
294
|
+
tokens.cacheCreation += m.tokens.cacheCreation || 0;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
utilization,
|
|
300
|
+
tokens,
|
|
301
|
+
actualTokens: totalTokens,
|
|
302
|
+
projectedTokensAt100: utilization > 0 ? Math.round(totalTokens / (utilization / 100)) : null,
|
|
303
|
+
actualCost: totalCost,
|
|
304
|
+
projectedCostAt100: utilization > 0 ? Math.round((totalCost / (utilization / 100)) * 100) / 100 : null,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function mergeHistories(historyArrays) {
|
|
309
|
+
const byPeriod = new Map();
|
|
310
|
+
for (const history of historyArrays) {
|
|
311
|
+
for (const entry of history) {
|
|
312
|
+
const key = cyclePeriodKey(entry);
|
|
313
|
+
if (!byPeriod.has(key)) byPeriod.set(key, []);
|
|
314
|
+
byPeriod.get(key).push(entry);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const merged = [];
|
|
319
|
+
for (const [, entries] of byPeriod) {
|
|
320
|
+
merged.push(mergeSamePeriodCycles(entries));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
merged.sort((a, b) => new Date(b.resets_at) - new Date(a.resets_at));
|
|
324
|
+
return merged.slice(0, MAX_HISTORY);
|
|
325
|
+
}
|