@leo000001/opencode-quota-sidebar 2.0.0 → 2.0.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 +26 -19
- package/dist/cost.js +2 -1
- package/dist/format.js +155 -52
- package/dist/index.js +77 -4
- package/dist/persistence.js +15 -1
- package/dist/quota.js +3 -5
- package/dist/quota_service.js +194 -29
- package/dist/storage.d.ts +5 -0
- package/dist/storage.js +74 -9
- package/dist/storage_chunks.js +20 -9
- package/dist/storage_parse.js +4 -1
- package/dist/title.js +37 -15
- package/dist/title_apply.d.ts +21 -3
- package/dist/title_apply.js +109 -23
- package/dist/title_refresh.d.ts +4 -0
- package/dist/title_refresh.js +35 -1
- package/dist/tools.d.ts +22 -1
- package/dist/tools.js +60 -14
- package/dist/types.d.ts +27 -1
- package/dist/usage.d.ts +14 -6
- package/dist/usage.js +78 -13
- package/dist/usage_service.js +159 -55
- package/package.json +1 -1
package/dist/usage.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Billing cache version — bump this whenever the persisted `CachedSessionUsage`
|
|
3
|
+
* shape changes in a way that requires recomputation (e.g. new aggregate
|
|
4
|
+
* fields). This is distinct from the plugin *state* version managed by the
|
|
5
|
+
* persistence layer; billing version only governs usage-cache staleness.
|
|
6
|
+
*/
|
|
7
|
+
export const USAGE_BILLING_CACHE_VERSION = 4;
|
|
2
8
|
function emptyCacheUsageBucket() {
|
|
3
9
|
return {
|
|
4
10
|
input: 0,
|
|
@@ -25,8 +31,8 @@ function cloneCacheUsageBuckets(buckets) {
|
|
|
25
31
|
if (!buckets)
|
|
26
32
|
return undefined;
|
|
27
33
|
return {
|
|
28
|
-
readOnly: cloneCacheUsageBucket(buckets
|
|
29
|
-
readWrite: cloneCacheUsageBucket(buckets
|
|
34
|
+
readOnly: cloneCacheUsageBucket(buckets.readOnly),
|
|
35
|
+
readWrite: cloneCacheUsageBucket(buckets.readWrite),
|
|
30
36
|
};
|
|
31
37
|
}
|
|
32
38
|
function mergeCacheUsageBucket(target, source) {
|
|
@@ -44,6 +50,14 @@ function addMessageCacheUsage(target, message) {
|
|
|
44
50
|
target.cacheWrite += message.tokens.cache.write;
|
|
45
51
|
target.assistantMessages += 1;
|
|
46
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Best-effort fallback for legacy cached data that lacks per-message cache
|
|
55
|
+
* buckets. When `cacheWrite > 0` we assume all tokens came from a read-write
|
|
56
|
+
* model (Anthropic-like); when only `cacheRead > 0` we assume read-only
|
|
57
|
+
* (OpenAI-like). Mixed-provider sessions that were cached before v3 will be
|
|
58
|
+
* attributed to a single bucket — this is a known limitation; new sessions
|
|
59
|
+
* classify per-message and are not affected.
|
|
60
|
+
*/
|
|
47
61
|
function fallbackCacheUsageBuckets(usage) {
|
|
48
62
|
if (usage.cacheWrite > 0) {
|
|
49
63
|
return {
|
|
@@ -70,8 +84,25 @@ function fallbackCacheUsageBuckets(usage) {
|
|
|
70
84
|
return undefined;
|
|
71
85
|
}
|
|
72
86
|
function resolvedCacheUsageBuckets(usage) {
|
|
73
|
-
|
|
74
|
-
|
|
87
|
+
const explicit = cloneCacheUsageBuckets(usage.cacheBuckets);
|
|
88
|
+
if (!explicit) {
|
|
89
|
+
return cloneCacheUsageBuckets(fallbackCacheUsageBuckets(usage)) || emptyCacheUsageBuckets();
|
|
90
|
+
}
|
|
91
|
+
const accountedInput = explicit.readOnly.input + explicit.readWrite.input;
|
|
92
|
+
const accountedCacheRead = explicit.readOnly.cacheRead + explicit.readWrite.cacheRead;
|
|
93
|
+
const accountedCacheWrite = explicit.readOnly.cacheWrite + explicit.readWrite.cacheWrite;
|
|
94
|
+
const accountedAssistantMessages = explicit.readOnly.assistantMessages + explicit.readWrite.assistantMessages;
|
|
95
|
+
const residual = fallbackCacheUsageBuckets({
|
|
96
|
+
input: Math.max(0, usage.input - accountedInput),
|
|
97
|
+
cacheRead: Math.max(0, usage.cacheRead - accountedCacheRead),
|
|
98
|
+
cacheWrite: Math.max(0, usage.cacheWrite - accountedCacheWrite),
|
|
99
|
+
assistantMessages: Math.max(0, usage.assistantMessages - accountedAssistantMessages),
|
|
100
|
+
});
|
|
101
|
+
if (residual) {
|
|
102
|
+
mergeCacheUsageBucket(explicit.readOnly, residual.readOnly);
|
|
103
|
+
mergeCacheUsageBucket(explicit.readWrite, residual.readWrite);
|
|
104
|
+
}
|
|
105
|
+
return explicit;
|
|
75
106
|
}
|
|
76
107
|
export function getCacheCoverageMetrics(usage) {
|
|
77
108
|
const buckets = resolvedCacheUsageBuckets(usage);
|
|
@@ -89,6 +120,9 @@ export function getCacheCoverageMetrics(usage) {
|
|
|
89
120
|
: undefined,
|
|
90
121
|
};
|
|
91
122
|
}
|
|
123
|
+
export function getProviderCacheCoverageMetrics(usage) {
|
|
124
|
+
return getCacheCoverageMetrics(usage);
|
|
125
|
+
}
|
|
92
126
|
export function emptyUsageSummary() {
|
|
93
127
|
return {
|
|
94
128
|
input: 0,
|
|
@@ -101,7 +135,6 @@ export function emptyUsageSummary() {
|
|
|
101
135
|
apiCost: 0,
|
|
102
136
|
assistantMessages: 0,
|
|
103
137
|
sessionCount: 0,
|
|
104
|
-
cacheBuckets: emptyCacheUsageBuckets(),
|
|
105
138
|
providers: {},
|
|
106
139
|
};
|
|
107
140
|
}
|
|
@@ -117,6 +150,7 @@ function emptyProviderUsage(providerID) {
|
|
|
117
150
|
cost: 0,
|
|
118
151
|
apiCost: 0,
|
|
119
152
|
assistantMessages: 0,
|
|
153
|
+
cacheBuckets: undefined,
|
|
120
154
|
};
|
|
121
155
|
}
|
|
122
156
|
function isAssistant(message) {
|
|
@@ -164,23 +198,47 @@ function addMessageUsage(target, message, options) {
|
|
|
164
198
|
if (cacheMode === 'read-only') {
|
|
165
199
|
const buckets = (target.cacheBuckets ||= emptyCacheUsageBuckets());
|
|
166
200
|
addMessageCacheUsage(buckets.readOnly, message);
|
|
201
|
+
const providerBuckets = (provider.cacheBuckets ||= emptyCacheUsageBuckets());
|
|
202
|
+
addMessageCacheUsage(providerBuckets.readOnly, message);
|
|
167
203
|
}
|
|
168
204
|
else if (cacheMode === 'read-write') {
|
|
169
205
|
const buckets = (target.cacheBuckets ||= emptyCacheUsageBuckets());
|
|
170
206
|
addMessageCacheUsage(buckets.readWrite, message);
|
|
207
|
+
const providerBuckets = (provider.cacheBuckets ||= emptyCacheUsageBuckets());
|
|
208
|
+
addMessageCacheUsage(providerBuckets.readWrite, message);
|
|
171
209
|
}
|
|
172
210
|
}
|
|
211
|
+
function completedTimeOf(message) {
|
|
212
|
+
const completed = message.time.completed;
|
|
213
|
+
if (typeof completed !== 'number')
|
|
214
|
+
return undefined;
|
|
215
|
+
if (!Number.isFinite(completed))
|
|
216
|
+
return undefined;
|
|
217
|
+
return completed;
|
|
218
|
+
}
|
|
219
|
+
function isCompletedAssistantInRange(message, startAt = 0, endAt = Number.POSITIVE_INFINITY) {
|
|
220
|
+
if (!isAssistant(message))
|
|
221
|
+
return false;
|
|
222
|
+
const completed = completedTimeOf(message);
|
|
223
|
+
if (completed === undefined)
|
|
224
|
+
return false;
|
|
225
|
+
return completed >= startAt && completed <= endAt;
|
|
226
|
+
}
|
|
173
227
|
export function summarizeMessages(entries, startAt = 0, sessionCount = 1, options) {
|
|
174
228
|
const summary = emptyUsageSummary();
|
|
175
229
|
summary.sessionCount = sessionCount;
|
|
176
230
|
for (const entry of entries) {
|
|
177
|
-
if (!
|
|
231
|
+
if (!isCompletedAssistantInRange(entry.info, startAt))
|
|
178
232
|
continue;
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
233
|
+
addMessageUsage(summary, entry.info, options);
|
|
234
|
+
}
|
|
235
|
+
return summary;
|
|
236
|
+
}
|
|
237
|
+
export function summarizeMessagesInCompletedRange(entries, startAt, endAt, sessionCount = 1, options) {
|
|
238
|
+
const summary = emptyUsageSummary();
|
|
239
|
+
summary.sessionCount = sessionCount;
|
|
240
|
+
for (const entry of entries) {
|
|
241
|
+
if (!isCompletedAssistantInRange(entry.info, startAt, endAt))
|
|
184
242
|
continue;
|
|
185
243
|
addMessageUsage(summary, entry.info, options);
|
|
186
244
|
}
|
|
@@ -376,9 +434,9 @@ export function mergeUsage(target, source, options) {
|
|
|
376
434
|
target.apiCost += source.apiCost;
|
|
377
435
|
target.assistantMessages += source.assistantMessages;
|
|
378
436
|
target.sessionCount += source.sessionCount;
|
|
379
|
-
const targetBuckets = (target.cacheBuckets ||= emptyCacheUsageBuckets());
|
|
380
437
|
const sourceBuckets = source.cacheBuckets;
|
|
381
438
|
if (sourceBuckets) {
|
|
439
|
+
const targetBuckets = (target.cacheBuckets ||= emptyCacheUsageBuckets());
|
|
382
440
|
mergeCacheUsageBucket(targetBuckets.readOnly, sourceBuckets.readOnly);
|
|
383
441
|
mergeCacheUsageBucket(targetBuckets.readWrite, sourceBuckets.readWrite);
|
|
384
442
|
}
|
|
@@ -395,6 +453,11 @@ export function mergeUsage(target, source, options) {
|
|
|
395
453
|
}
|
|
396
454
|
existing.apiCost += provider.apiCost;
|
|
397
455
|
existing.assistantMessages += provider.assistantMessages;
|
|
456
|
+
if (provider.cacheBuckets) {
|
|
457
|
+
const providerBuckets = (existing.cacheBuckets ||= emptyCacheUsageBuckets());
|
|
458
|
+
mergeCacheUsageBucket(providerBuckets.readOnly, provider.cacheBuckets.readOnly);
|
|
459
|
+
mergeCacheUsageBucket(providerBuckets.readWrite, provider.cacheBuckets.readWrite);
|
|
460
|
+
}
|
|
398
461
|
target.providers[provider.providerID] = existing;
|
|
399
462
|
}
|
|
400
463
|
return target;
|
|
@@ -412,6 +475,7 @@ export function toCachedSessionUsage(summary) {
|
|
|
412
475
|
cost: provider.cost,
|
|
413
476
|
apiCost: provider.apiCost,
|
|
414
477
|
assistantMessages: provider.assistantMessages,
|
|
478
|
+
cacheBuckets: cloneCacheUsageBuckets(provider.cacheBuckets),
|
|
415
479
|
};
|
|
416
480
|
return acc;
|
|
417
481
|
}, {});
|
|
@@ -459,6 +523,7 @@ export function fromCachedSessionUsage(cached, sessionCount = 1) {
|
|
|
459
523
|
cost: provider.cost,
|
|
460
524
|
apiCost: provider.apiCost || 0,
|
|
461
525
|
assistantMessages: provider.assistantMessages,
|
|
526
|
+
cacheBuckets: cloneCacheUsageBuckets(provider.cacheBuckets),
|
|
462
527
|
};
|
|
463
528
|
return acc;
|
|
464
529
|
}, {}),
|
package/dist/usage_service.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { TtlValueCache } from './cache.js';
|
|
2
2
|
import { cacheCoverageModeFromRates, calcEquivalentApiCostForMessage, canonicalApiCostProviderID, modelCostKey, parseModelCostRates, SUBSCRIPTION_API_COST_PROVIDERS, } from './cost.js';
|
|
3
|
-
import { dateKeyFromTimestamp,
|
|
3
|
+
import { dateKeyFromTimestamp, scanAllSessions, updateSessionsInDayChunks, } from './storage.js';
|
|
4
4
|
import { periodStart } from './period.js';
|
|
5
5
|
import { debug, isRecord, mapConcurrent, swallow } from './helpers.js';
|
|
6
|
-
import { emptyUsageSummary, fromCachedSessionUsage, mergeUsage, summarizeMessagesIncremental, toCachedSessionUsage, USAGE_BILLING_CACHE_VERSION, } from './usage.js';
|
|
6
|
+
import { emptyUsageSummary, fromCachedSessionUsage, mergeUsage, summarizeMessagesInCompletedRange, summarizeMessagesIncremental, toCachedSessionUsage, USAGE_BILLING_CACHE_VERSION, } from './usage.js';
|
|
7
|
+
const READ_ONLY_CACHE_PROVIDERS = new Set([
|
|
8
|
+
'openai',
|
|
9
|
+
'github-copilot',
|
|
10
|
+
'venice',
|
|
11
|
+
'openrouter',
|
|
12
|
+
]);
|
|
7
13
|
export function createUsageService(deps) {
|
|
8
14
|
const forceRescanSessions = new Set();
|
|
9
15
|
const dirtyGeneration = new Map();
|
|
@@ -12,6 +18,8 @@ export function createUsageService(deps) {
|
|
|
12
18
|
dirtyGeneration.set(sessionID, (dirtyGeneration.get(sessionID) || 0) + 1);
|
|
13
19
|
};
|
|
14
20
|
const isDirty = (sessionID) => {
|
|
21
|
+
if (deps.state.sessions[sessionID]?.dirty)
|
|
22
|
+
return true;
|
|
15
23
|
return ((dirtyGeneration.get(sessionID) || 0) !==
|
|
16
24
|
(cleanGeneration.get(sessionID) || 0));
|
|
17
25
|
};
|
|
@@ -109,13 +117,19 @@ export function createUsageService(deps) {
|
|
|
109
117
|
return 'read-write';
|
|
110
118
|
if (message.tokens.cache.read <= 0)
|
|
111
119
|
return 'none';
|
|
120
|
+
const rawProviderID = message.providerID.toLowerCase();
|
|
121
|
+
if (READ_ONLY_CACHE_PROVIDERS.has(canonicalProviderID) ||
|
|
122
|
+
READ_ONLY_CACHE_PROVIDERS.has(rawProviderID)) {
|
|
123
|
+
return 'read-only';
|
|
124
|
+
}
|
|
125
|
+
// Heuristic fallback: classify by provider identity when pricing is missing.
|
|
112
126
|
if (canonicalProviderID === 'anthropic' ||
|
|
113
127
|
message.modelID.toLowerCase().includes('claude')) {
|
|
114
128
|
return 'read-write';
|
|
115
129
|
}
|
|
116
|
-
if
|
|
117
|
-
|
|
118
|
-
return '
|
|
130
|
+
// Last resort: if the message has cache.read tokens from an unknown provider,
|
|
131
|
+
// treat it as read-only (the safer default — avoids inflating Cache Coverage).
|
|
132
|
+
return 'read-only';
|
|
119
133
|
};
|
|
120
134
|
const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
|
|
121
135
|
const decodeTokens = (value) => {
|
|
@@ -145,10 +159,6 @@ export function createUsageService(deps) {
|
|
|
145
159
|
return undefined;
|
|
146
160
|
if (typeof value.role !== 'string')
|
|
147
161
|
return undefined;
|
|
148
|
-
if (typeof value.providerID !== 'string')
|
|
149
|
-
return undefined;
|
|
150
|
-
if (typeof value.modelID !== 'string')
|
|
151
|
-
return undefined;
|
|
152
162
|
if (!isRecord(value.time))
|
|
153
163
|
return undefined;
|
|
154
164
|
if (!isFiniteNumber(value.time.created))
|
|
@@ -157,6 +167,19 @@ export function createUsageService(deps) {
|
|
|
157
167
|
!isFiniteNumber(value.time.completed)) {
|
|
158
168
|
return undefined;
|
|
159
169
|
}
|
|
170
|
+
if (value.role !== 'assistant') {
|
|
171
|
+
return {
|
|
172
|
+
...value,
|
|
173
|
+
time: {
|
|
174
|
+
created: value.time.created,
|
|
175
|
+
completed: value.time.completed,
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
if (typeof value.providerID !== 'string')
|
|
180
|
+
return undefined;
|
|
181
|
+
if (typeof value.modelID !== 'string')
|
|
182
|
+
return undefined;
|
|
160
183
|
const tokens = decodeTokens(value.tokens);
|
|
161
184
|
if (!tokens)
|
|
162
185
|
return undefined;
|
|
@@ -187,6 +210,7 @@ export function createUsageService(deps) {
|
|
|
187
210
|
.filter((item) => Boolean(item));
|
|
188
211
|
if (decoded.length > 0 && decoded.length < value.length) {
|
|
189
212
|
debug(`message entries partially decoded: kept ${decoded.length}/${value.length}`);
|
|
213
|
+
return undefined;
|
|
190
214
|
}
|
|
191
215
|
// If the API returned entries but none match the expected shape,
|
|
192
216
|
// treat it as a load failure so we don't silently undercount.
|
|
@@ -222,7 +246,22 @@ export function createUsageService(deps) {
|
|
|
222
246
|
return false;
|
|
223
247
|
return cached.billingVersion === USAGE_BILLING_CACHE_VERSION;
|
|
224
248
|
};
|
|
225
|
-
const
|
|
249
|
+
const hasStaleZeroApiCost = (cached, modelCostMap) => {
|
|
250
|
+
if (!cached)
|
|
251
|
+
return false;
|
|
252
|
+
if (cached.apiCost > 0)
|
|
253
|
+
return false;
|
|
254
|
+
return Object.entries(cached.providers).some(([providerID, provider]) => {
|
|
255
|
+
if (provider.apiCost > 0)
|
|
256
|
+
return false;
|
|
257
|
+
const canonicalProviderID = canonicalApiCostProviderID(providerID);
|
|
258
|
+
if (!SUBSCRIPTION_API_COST_PROVIDERS.has(canonicalProviderID))
|
|
259
|
+
return false;
|
|
260
|
+
return Object.keys(modelCostMap).some((key) => key.startsWith(`${providerID}:`) ||
|
|
261
|
+
key.startsWith(`${canonicalProviderID}:`));
|
|
262
|
+
});
|
|
263
|
+
};
|
|
264
|
+
const summarizeSessionUsage = async (sessionID, generationAtStart, options) => {
|
|
226
265
|
const entries = await loadSessionEntries(sessionID);
|
|
227
266
|
const sessionState = deps.state.sessions[sessionID];
|
|
228
267
|
// If we can't load messages (transient API failure), fall back to cached
|
|
@@ -234,13 +273,17 @@ export function createUsageService(deps) {
|
|
|
234
273
|
persist: false,
|
|
235
274
|
};
|
|
236
275
|
}
|
|
276
|
+
if (options?.requireEntries) {
|
|
277
|
+
throw new Error(`session usage unavailable: failed to load messages for ${sessionID}`);
|
|
278
|
+
}
|
|
237
279
|
const empty = emptyUsageSummary();
|
|
238
280
|
empty.sessionCount = 1;
|
|
239
281
|
return { usage: empty, persist: false };
|
|
240
282
|
}
|
|
241
283
|
const modelCostMap = await getModelCostMap();
|
|
242
284
|
const staleBillingCache = Boolean(sessionState?.usage) &&
|
|
243
|
-
!isUsageBillingCurrent(sessionState?.usage)
|
|
285
|
+
(!isUsageBillingCurrent(sessionState?.usage) ||
|
|
286
|
+
hasStaleZeroApiCost(sessionState?.usage, modelCostMap));
|
|
244
287
|
const forceRescan = forceRescanSessions.has(sessionID) || staleBillingCache;
|
|
245
288
|
if (forceRescan)
|
|
246
289
|
forceRescanSessions.delete(sessionID);
|
|
@@ -255,13 +298,14 @@ export function createUsageService(deps) {
|
|
|
255
298
|
// Update cursor in state
|
|
256
299
|
if (sessionState) {
|
|
257
300
|
sessionState.cursor = cursor;
|
|
301
|
+
sessionState.dirty = false;
|
|
258
302
|
}
|
|
259
303
|
if ((dirtyGeneration.get(sessionID) || 0) === generationAtStart) {
|
|
260
304
|
cleanGeneration.set(sessionID, generationAtStart);
|
|
261
305
|
}
|
|
262
306
|
return { usage, persist: true };
|
|
263
307
|
};
|
|
264
|
-
const summarizeSessionUsageLocked = async (sessionID) => {
|
|
308
|
+
const summarizeSessionUsageLocked = async (sessionID, options) => {
|
|
265
309
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
266
310
|
const generationAtStart = dirtyGeneration.get(sessionID) || 0;
|
|
267
311
|
const existing = usageInFlight.get(sessionID);
|
|
@@ -271,13 +315,15 @@ export function createUsageService(deps) {
|
|
|
271
315
|
continue;
|
|
272
316
|
return result;
|
|
273
317
|
}
|
|
274
|
-
const promise = summarizeSessionUsage(sessionID, generationAtStart);
|
|
318
|
+
const promise = summarizeSessionUsage(sessionID, generationAtStart, options);
|
|
275
319
|
const entry = { generation: generationAtStart, promise };
|
|
276
|
-
promise
|
|
320
|
+
void promise
|
|
321
|
+
.finally(() => {
|
|
277
322
|
const current = usageInFlight.get(sessionID);
|
|
278
323
|
if (current?.promise === promise)
|
|
279
324
|
usageInFlight.delete(sessionID);
|
|
280
|
-
})
|
|
325
|
+
})
|
|
326
|
+
.catch(() => undefined);
|
|
281
327
|
usageInFlight.set(sessionID, entry);
|
|
282
328
|
const result = await promise;
|
|
283
329
|
if ((dirtyGeneration.get(sessionID) || 0) !== generationAtStart)
|
|
@@ -290,18 +336,26 @@ export function createUsageService(deps) {
|
|
|
290
336
|
const summarizeSessionUsageForDisplay = async (sessionID, includeChildren) => {
|
|
291
337
|
const root = await summarizeSessionUsageLocked(sessionID);
|
|
292
338
|
const usage = root.usage;
|
|
339
|
+
let dirty = false;
|
|
293
340
|
if (root.persist) {
|
|
294
341
|
persistSessionUsage(sessionID, toCachedSessionUsage(usage));
|
|
342
|
+
dirty = true;
|
|
295
343
|
}
|
|
296
|
-
if (!includeChildren)
|
|
344
|
+
if (!includeChildren) {
|
|
345
|
+
if (dirty)
|
|
346
|
+
deps.persistence.scheduleSave();
|
|
297
347
|
return usage;
|
|
348
|
+
}
|
|
298
349
|
const descendantIDs = await deps.descendantsResolver.listDescendantSessionIDs(sessionID, {
|
|
299
350
|
maxDepth: deps.config.sidebar.childrenMaxDepth,
|
|
300
351
|
maxSessions: deps.config.sidebar.childrenMaxSessions,
|
|
301
352
|
concurrency: deps.config.sidebar.childrenConcurrency,
|
|
302
353
|
});
|
|
303
|
-
if (descendantIDs.length === 0)
|
|
354
|
+
if (descendantIDs.length === 0) {
|
|
355
|
+
if (dirty)
|
|
356
|
+
deps.persistence.scheduleSave();
|
|
304
357
|
return usage;
|
|
358
|
+
}
|
|
305
359
|
const merged = emptyUsageSummary();
|
|
306
360
|
mergeUsage(merged, usage);
|
|
307
361
|
const needsFetch = [];
|
|
@@ -323,6 +377,7 @@ export function createUsageService(deps) {
|
|
|
323
377
|
const child = await summarizeSessionUsageLocked(childID);
|
|
324
378
|
if (child.persist) {
|
|
325
379
|
persistSessionUsage(childID, toCachedSessionUsage(child.usage));
|
|
380
|
+
dirty = true;
|
|
326
381
|
}
|
|
327
382
|
return child.usage;
|
|
328
383
|
});
|
|
@@ -330,15 +385,17 @@ export function createUsageService(deps) {
|
|
|
330
385
|
mergeUsage(merged, childUsage, { includeCost: false });
|
|
331
386
|
}
|
|
332
387
|
}
|
|
388
|
+
if (dirty)
|
|
389
|
+
deps.persistence.scheduleSave();
|
|
333
390
|
return merged;
|
|
334
391
|
};
|
|
335
392
|
const RANGE_USAGE_CONCURRENCY = 5;
|
|
336
393
|
const summarizeRangeUsage = async (period) => {
|
|
337
394
|
const startAt = periodStart(period);
|
|
395
|
+
const endAt = Date.now();
|
|
338
396
|
await deps.persistence.flushSave();
|
|
339
|
-
const sessions = await
|
|
397
|
+
const sessions = await scanAllSessions(deps.statePath, deps.state);
|
|
340
398
|
const usage = emptyUsageSummary();
|
|
341
|
-
usage.sessionCount = sessions.length;
|
|
342
399
|
const modelCostMap = await getModelCostMap();
|
|
343
400
|
const hasPricing = Object.keys(modelCostMap).length > 0;
|
|
344
401
|
const hasAnySubscriptionProvider = (cached) => {
|
|
@@ -367,80 +424,108 @@ export function createUsageService(deps) {
|
|
|
367
424
|
return false;
|
|
368
425
|
return true;
|
|
369
426
|
};
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
if (session.state.usage) {
|
|
373
|
-
if (shouldRecomputeUsageCache(session.state.usage)) {
|
|
374
|
-
needsFetch.push(session);
|
|
375
|
-
}
|
|
376
|
-
else {
|
|
377
|
-
mergeUsage(usage, fromCachedSessionUsage(session.state.usage, 0));
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
else {
|
|
381
|
-
needsFetch.push(session);
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
if (needsFetch.length > 0) {
|
|
385
|
-
const fetched = await mapConcurrent(needsFetch, RANGE_USAGE_CONCURRENCY, async (session) => {
|
|
427
|
+
if (sessions.length > 0) {
|
|
428
|
+
const fetched = await mapConcurrent(sessions, RANGE_USAGE_CONCURRENCY, async (session) => {
|
|
386
429
|
const entries = await loadSessionEntries(session.sessionID);
|
|
387
430
|
if (!entries) {
|
|
388
|
-
if (session.state.usage) {
|
|
389
|
-
return {
|
|
390
|
-
sessionID: session.sessionID,
|
|
391
|
-
dateKey: session.dateKey,
|
|
392
|
-
computed: fromCachedSessionUsage(session.state.usage, 1),
|
|
393
|
-
persist: false,
|
|
394
|
-
cursor: session.state.cursor,
|
|
395
|
-
};
|
|
396
|
-
}
|
|
397
|
-
const empty = emptyUsageSummary();
|
|
398
|
-
empty.sessionCount = 1;
|
|
399
431
|
return {
|
|
400
432
|
sessionID: session.sessionID,
|
|
401
433
|
dateKey: session.dateKey,
|
|
402
|
-
|
|
434
|
+
createdAt: session.state.createdAt,
|
|
435
|
+
lastMessageTime: session.state.cursor?.lastMessageTime,
|
|
436
|
+
dirty: session.state.dirty === true,
|
|
437
|
+
computed: emptyUsageSummary(),
|
|
438
|
+
fullUsage: undefined,
|
|
439
|
+
loadFailed: true,
|
|
403
440
|
persist: false,
|
|
404
441
|
cursor: undefined,
|
|
405
442
|
};
|
|
406
443
|
}
|
|
407
|
-
const
|
|
444
|
+
const computed = summarizeMessagesInCompletedRange(entries, startAt, endAt, 0, {
|
|
445
|
+
calcApiCost: (message) => calcEquivalentApiCost(message, modelCostMap),
|
|
446
|
+
classifyCacheMode: (message) => classifyCacheMode(message, modelCostMap),
|
|
447
|
+
});
|
|
448
|
+
const shouldPersistFullUsage = !session.state.usage || shouldRecomputeUsageCache(session.state.usage);
|
|
449
|
+
if (!shouldPersistFullUsage) {
|
|
450
|
+
return {
|
|
451
|
+
sessionID: session.sessionID,
|
|
452
|
+
dateKey: session.dateKey,
|
|
453
|
+
createdAt: session.state.createdAt,
|
|
454
|
+
lastMessageTime: session.state.cursor?.lastMessageTime,
|
|
455
|
+
dirty: session.state.dirty === true,
|
|
456
|
+
computed,
|
|
457
|
+
fullUsage: undefined,
|
|
458
|
+
loadFailed: false,
|
|
459
|
+
persist: false,
|
|
460
|
+
cursor: session.state.cursor,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
const { usage: fullUsage, cursor } = summarizeMessagesIncremental(entries, undefined, undefined, true, {
|
|
408
464
|
calcApiCost: (message) => calcEquivalentApiCost(message, modelCostMap),
|
|
409
465
|
classifyCacheMode: (message) => classifyCacheMode(message, modelCostMap),
|
|
410
466
|
});
|
|
411
467
|
return {
|
|
412
468
|
sessionID: session.sessionID,
|
|
413
469
|
dateKey: session.dateKey,
|
|
470
|
+
createdAt: session.state.createdAt,
|
|
471
|
+
lastMessageTime: cursor.lastMessageTime,
|
|
472
|
+
dirty: false,
|
|
414
473
|
computed,
|
|
474
|
+
fullUsage,
|
|
475
|
+
loadFailed: false,
|
|
415
476
|
persist: true,
|
|
416
477
|
cursor,
|
|
417
478
|
};
|
|
418
479
|
});
|
|
480
|
+
const failedLoads = fetched.filter((item) => {
|
|
481
|
+
if (!item.loadFailed)
|
|
482
|
+
return false;
|
|
483
|
+
if (item.dirty)
|
|
484
|
+
return true;
|
|
485
|
+
const lastMessageTime = item.lastMessageTime;
|
|
486
|
+
if (typeof lastMessageTime === 'number' && lastMessageTime < startAt) {
|
|
487
|
+
return false;
|
|
488
|
+
}
|
|
489
|
+
return true;
|
|
490
|
+
});
|
|
491
|
+
if (failedLoads.length > 0) {
|
|
492
|
+
throw new Error(`range usage unavailable: failed to load ${failedLoads.length} session(s)`);
|
|
493
|
+
}
|
|
419
494
|
let dirty = false;
|
|
420
495
|
const diskOnlyUpdates = [];
|
|
421
|
-
for (const { sessionID, dateKey, computed, persist, cursor } of fetched) {
|
|
422
|
-
|
|
496
|
+
for (const { sessionID, dateKey, computed, fullUsage, persist, cursor } of fetched) {
|
|
497
|
+
if (computed.assistantMessages > 0) {
|
|
498
|
+
computed.sessionCount = 1;
|
|
499
|
+
mergeUsage(usage, computed);
|
|
500
|
+
}
|
|
423
501
|
const memoryState = deps.state.sessions[sessionID];
|
|
424
|
-
if (persist && memoryState) {
|
|
425
|
-
memoryState.usage = toCachedSessionUsage(
|
|
502
|
+
if (persist && fullUsage && memoryState) {
|
|
503
|
+
memoryState.usage = toCachedSessionUsage(fullUsage);
|
|
426
504
|
memoryState.cursor = cursor;
|
|
427
505
|
const resolvedDateKey = deps.state.sessionDateMap[sessionID] ||
|
|
428
506
|
dateKeyFromTimestamp(memoryState.createdAt);
|
|
429
507
|
deps.state.sessionDateMap[sessionID] = resolvedDateKey;
|
|
430
508
|
deps.persistence.markDirty(resolvedDateKey);
|
|
509
|
+
memoryState.dirty = false;
|
|
431
510
|
dirty = true;
|
|
432
511
|
}
|
|
433
|
-
else if (persist) {
|
|
512
|
+
else if (persist && fullUsage) {
|
|
434
513
|
diskOnlyUpdates.push({
|
|
435
514
|
sessionID,
|
|
436
515
|
dateKey,
|
|
437
|
-
usage: toCachedSessionUsage(
|
|
516
|
+
usage: toCachedSessionUsage(fullUsage),
|
|
438
517
|
cursor,
|
|
439
518
|
});
|
|
440
519
|
}
|
|
441
520
|
}
|
|
442
521
|
if (diskOnlyUpdates.length > 0) {
|
|
443
|
-
await updateSessionsInDayChunks(deps.statePath, diskOnlyUpdates).catch(
|
|
522
|
+
const persisted = await updateSessionsInDayChunks(deps.statePath, diskOnlyUpdates).catch((error) => {
|
|
523
|
+
swallow('updateSessionsInDayChunks')(error);
|
|
524
|
+
return false;
|
|
525
|
+
});
|
|
526
|
+
if (!persisted) {
|
|
527
|
+
throw new Error(`range usage unavailable: failed to persist ${diskOnlyUpdates.length} disk-only session(s)`);
|
|
528
|
+
}
|
|
444
529
|
}
|
|
445
530
|
if (dirty)
|
|
446
531
|
deps.persistence.scheduleSave();
|
|
@@ -449,12 +534,31 @@ export function createUsageService(deps) {
|
|
|
449
534
|
};
|
|
450
535
|
const summarizeForTool = async (period, sessionID, includeChildren) => {
|
|
451
536
|
if (period === 'session') {
|
|
537
|
+
if (!includeChildren) {
|
|
538
|
+
const session = await summarizeSessionUsageLocked(sessionID, {
|
|
539
|
+
requireEntries: true,
|
|
540
|
+
});
|
|
541
|
+
if (session.persist) {
|
|
542
|
+
persistSessionUsage(sessionID, toCachedSessionUsage(session.usage));
|
|
543
|
+
deps.persistence.scheduleSave();
|
|
544
|
+
}
|
|
545
|
+
return session.usage;
|
|
546
|
+
}
|
|
452
547
|
return summarizeSessionUsageForDisplay(sessionID, includeChildren);
|
|
453
548
|
}
|
|
454
549
|
return summarizeRangeUsage(period);
|
|
455
550
|
};
|
|
456
551
|
const markSessionDirty = (sessionID) => {
|
|
457
552
|
bumpDirty(sessionID);
|
|
553
|
+
const sessionState = deps.state.sessions[sessionID];
|
|
554
|
+
if (sessionState && !sessionState.dirty) {
|
|
555
|
+
sessionState.dirty = true;
|
|
556
|
+
const dateKey = deps.state.sessionDateMap[sessionID] ||
|
|
557
|
+
dateKeyFromTimestamp(sessionState.createdAt);
|
|
558
|
+
deps.state.sessionDateMap[sessionID] = dateKey;
|
|
559
|
+
deps.persistence.markDirty(dateKey);
|
|
560
|
+
deps.persistence.scheduleSave();
|
|
561
|
+
}
|
|
458
562
|
};
|
|
459
563
|
const markForceRescan = (sessionID) => {
|
|
460
564
|
forceRescanSessions.add(sessionID);
|