@leo000001/opencode-quota-sidebar 1.13.10 → 2.0.1
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 +21 -17
- package/dist/cost.d.ts +2 -0
- package/dist/cost.js +11 -1
- package/dist/format.d.ts +1 -1
- package/dist/format.js +78 -21
- package/dist/index.js +24 -2
- package/dist/quota.js +3 -5
- package/dist/quota_service.js +65 -18
- 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 +36 -1
- package/dist/title.js +37 -13
- package/dist/title_apply.d.ts +2 -0
- package/dist/title_apply.js +43 -13
- package/dist/title_refresh.d.ts +2 -0
- package/dist/title_refresh.js +12 -1
- package/dist/tools.d.ts +5 -0
- package/dist/tools.js +8 -4
- package/dist/types.d.ts +37 -0
- package/dist/usage.d.ts +15 -2
- package/dist/usage.js +166 -7
- package/dist/usage_service.js +158 -55
- package/package.json +1 -1
package/dist/usage.d.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { AssistantMessage, Message } from '@opencode-ai/sdk';
|
|
2
|
-
import type { CachedSessionUsage, IncrementalCursor } from './types.js';
|
|
3
|
-
|
|
2
|
+
import type { CacheCoverageMetrics, CacheCoverageMode, CacheUsageBuckets, CachedSessionUsage, IncrementalCursor } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Billing cache version — bump this whenever the persisted `CachedSessionUsage`
|
|
5
|
+
* shape changes in a way that requires recomputation (e.g. new aggregate
|
|
6
|
+
* fields). This is distinct from the plugin *state* version managed by the
|
|
7
|
+
* persistence layer; billing version only governs usage-cache staleness.
|
|
8
|
+
*/
|
|
9
|
+
export declare const USAGE_BILLING_CACHE_VERSION = 4;
|
|
4
10
|
export type ProviderUsage = {
|
|
5
11
|
providerID: string;
|
|
6
12
|
input: number;
|
|
@@ -26,16 +32,23 @@ export type UsageSummary = {
|
|
|
26
32
|
apiCost: number;
|
|
27
33
|
assistantMessages: number;
|
|
28
34
|
sessionCount: number;
|
|
35
|
+
cacheBuckets?: CacheUsageBuckets;
|
|
29
36
|
providers: Record<string, ProviderUsage>;
|
|
30
37
|
};
|
|
31
38
|
export type UsageOptions = {
|
|
32
39
|
/** Equivalent API cost calculator for the message. */
|
|
33
40
|
calcApiCost?: (message: AssistantMessage) => number;
|
|
41
|
+
/** Cache-behavior classifier for the message model/provider. */
|
|
42
|
+
classifyCacheMode?: (message: AssistantMessage) => CacheCoverageMode;
|
|
34
43
|
};
|
|
44
|
+
export declare function getCacheCoverageMetrics(usage: Pick<UsageSummary, 'input' | 'cacheRead' | 'cacheWrite' | 'assistantMessages' | 'cacheBuckets'>): CacheCoverageMetrics;
|
|
35
45
|
export declare function emptyUsageSummary(): UsageSummary;
|
|
36
46
|
export declare function summarizeMessages(entries: Array<{
|
|
37
47
|
info: Message;
|
|
38
48
|
}>, startAt?: number, sessionCount?: number, options?: UsageOptions): UsageSummary;
|
|
49
|
+
export declare function summarizeMessagesInCompletedRange(entries: Array<{
|
|
50
|
+
info: Message;
|
|
51
|
+
}>, startAt: number, endAt: number, sessionCount?: number, options?: UsageOptions): UsageSummary;
|
|
39
52
|
/**
|
|
40
53
|
* P1: Incremental usage aggregation.
|
|
41
54
|
* Only processes messages newer than the cursor. Returns updated cursor.
|
package/dist/usage.js
CHANGED
|
@@ -1,4 +1,125 @@
|
|
|
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;
|
|
8
|
+
function emptyCacheUsageBucket() {
|
|
9
|
+
return {
|
|
10
|
+
input: 0,
|
|
11
|
+
cacheRead: 0,
|
|
12
|
+
cacheWrite: 0,
|
|
13
|
+
assistantMessages: 0,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function emptyCacheUsageBuckets() {
|
|
17
|
+
return {
|
|
18
|
+
readOnly: emptyCacheUsageBucket(),
|
|
19
|
+
readWrite: emptyCacheUsageBucket(),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function cloneCacheUsageBucket(bucket) {
|
|
23
|
+
return {
|
|
24
|
+
input: bucket?.input ?? 0,
|
|
25
|
+
cacheRead: bucket?.cacheRead ?? 0,
|
|
26
|
+
cacheWrite: bucket?.cacheWrite ?? 0,
|
|
27
|
+
assistantMessages: bucket?.assistantMessages ?? 0,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function cloneCacheUsageBuckets(buckets) {
|
|
31
|
+
if (!buckets)
|
|
32
|
+
return undefined;
|
|
33
|
+
return {
|
|
34
|
+
readOnly: cloneCacheUsageBucket(buckets.readOnly),
|
|
35
|
+
readWrite: cloneCacheUsageBucket(buckets.readWrite),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function mergeCacheUsageBucket(target, source) {
|
|
39
|
+
if (!source)
|
|
40
|
+
return target;
|
|
41
|
+
target.input += source.input;
|
|
42
|
+
target.cacheRead += source.cacheRead;
|
|
43
|
+
target.cacheWrite += source.cacheWrite;
|
|
44
|
+
target.assistantMessages += source.assistantMessages;
|
|
45
|
+
return target;
|
|
46
|
+
}
|
|
47
|
+
function addMessageCacheUsage(target, message) {
|
|
48
|
+
target.input += message.tokens.input;
|
|
49
|
+
target.cacheRead += message.tokens.cache.read;
|
|
50
|
+
target.cacheWrite += message.tokens.cache.write;
|
|
51
|
+
target.assistantMessages += 1;
|
|
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
|
+
*/
|
|
61
|
+
function fallbackCacheUsageBuckets(usage) {
|
|
62
|
+
if (usage.cacheWrite > 0) {
|
|
63
|
+
return {
|
|
64
|
+
readOnly: emptyCacheUsageBucket(),
|
|
65
|
+
readWrite: {
|
|
66
|
+
input: usage.input,
|
|
67
|
+
cacheRead: usage.cacheRead,
|
|
68
|
+
cacheWrite: usage.cacheWrite,
|
|
69
|
+
assistantMessages: usage.assistantMessages,
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (usage.cacheRead > 0) {
|
|
74
|
+
return {
|
|
75
|
+
readOnly: {
|
|
76
|
+
input: usage.input,
|
|
77
|
+
cacheRead: usage.cacheRead,
|
|
78
|
+
cacheWrite: 0,
|
|
79
|
+
assistantMessages: usage.assistantMessages,
|
|
80
|
+
},
|
|
81
|
+
readWrite: emptyCacheUsageBucket(),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
function resolvedCacheUsageBuckets(usage) {
|
|
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;
|
|
106
|
+
}
|
|
107
|
+
export function getCacheCoverageMetrics(usage) {
|
|
108
|
+
const buckets = resolvedCacheUsageBuckets(usage);
|
|
109
|
+
const readWritePromptSurface = buckets.readWrite.input +
|
|
110
|
+
buckets.readWrite.cacheRead +
|
|
111
|
+
buckets.readWrite.cacheWrite;
|
|
112
|
+
const readOnlyPromptSurface = buckets.readOnly.input + buckets.readOnly.cacheRead;
|
|
113
|
+
return {
|
|
114
|
+
cacheCoverage: readWritePromptSurface > 0
|
|
115
|
+
? (buckets.readWrite.cacheRead + buckets.readWrite.cacheWrite) /
|
|
116
|
+
readWritePromptSurface
|
|
117
|
+
: undefined,
|
|
118
|
+
cacheReadCoverage: readOnlyPromptSurface > 0
|
|
119
|
+
? buckets.readOnly.cacheRead / readOnlyPromptSurface
|
|
120
|
+
: undefined,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
2
123
|
export function emptyUsageSummary() {
|
|
3
124
|
return {
|
|
4
125
|
input: 0,
|
|
@@ -69,18 +190,47 @@ function addMessageUsage(target, message, options) {
|
|
|
69
190
|
provider.apiCost += apiCost;
|
|
70
191
|
provider.assistantMessages += 1;
|
|
71
192
|
target.providers[message.providerID] = provider;
|
|
193
|
+
const cacheMode = options?.classifyCacheMode?.(message) || 'none';
|
|
194
|
+
if (cacheMode === 'read-only') {
|
|
195
|
+
const buckets = (target.cacheBuckets ||= emptyCacheUsageBuckets());
|
|
196
|
+
addMessageCacheUsage(buckets.readOnly, message);
|
|
197
|
+
}
|
|
198
|
+
else if (cacheMode === 'read-write') {
|
|
199
|
+
const buckets = (target.cacheBuckets ||= emptyCacheUsageBuckets());
|
|
200
|
+
addMessageCacheUsage(buckets.readWrite, message);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function completedTimeOf(message) {
|
|
204
|
+
const completed = message.time.completed;
|
|
205
|
+
if (typeof completed !== 'number')
|
|
206
|
+
return undefined;
|
|
207
|
+
if (!Number.isFinite(completed))
|
|
208
|
+
return undefined;
|
|
209
|
+
return completed;
|
|
210
|
+
}
|
|
211
|
+
function isCompletedAssistantInRange(message, startAt = 0, endAt = Number.POSITIVE_INFINITY) {
|
|
212
|
+
if (!isAssistant(message))
|
|
213
|
+
return false;
|
|
214
|
+
const completed = completedTimeOf(message);
|
|
215
|
+
if (completed === undefined)
|
|
216
|
+
return false;
|
|
217
|
+
return completed >= startAt && completed <= endAt;
|
|
72
218
|
}
|
|
73
219
|
export function summarizeMessages(entries, startAt = 0, sessionCount = 1, options) {
|
|
74
220
|
const summary = emptyUsageSummary();
|
|
75
221
|
summary.sessionCount = sessionCount;
|
|
76
222
|
for (const entry of entries) {
|
|
77
|
-
if (!
|
|
78
|
-
continue;
|
|
79
|
-
if (typeof entry.info.time.completed !== 'number')
|
|
223
|
+
if (!isCompletedAssistantInRange(entry.info, startAt))
|
|
80
224
|
continue;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
225
|
+
addMessageUsage(summary, entry.info, options);
|
|
226
|
+
}
|
|
227
|
+
return summary;
|
|
228
|
+
}
|
|
229
|
+
export function summarizeMessagesInCompletedRange(entries, startAt, endAt, sessionCount = 1, options) {
|
|
230
|
+
const summary = emptyUsageSummary();
|
|
231
|
+
summary.sessionCount = sessionCount;
|
|
232
|
+
for (const entry of entries) {
|
|
233
|
+
if (!isCompletedAssistantInRange(entry.info, startAt, endAt))
|
|
84
234
|
continue;
|
|
85
235
|
addMessageUsage(summary, entry.info, options);
|
|
86
236
|
}
|
|
@@ -276,6 +426,12 @@ export function mergeUsage(target, source, options) {
|
|
|
276
426
|
target.apiCost += source.apiCost;
|
|
277
427
|
target.assistantMessages += source.assistantMessages;
|
|
278
428
|
target.sessionCount += source.sessionCount;
|
|
429
|
+
const sourceBuckets = source.cacheBuckets;
|
|
430
|
+
if (sourceBuckets) {
|
|
431
|
+
const targetBuckets = (target.cacheBuckets ||= emptyCacheUsageBuckets());
|
|
432
|
+
mergeCacheUsageBucket(targetBuckets.readOnly, sourceBuckets.readOnly);
|
|
433
|
+
mergeCacheUsageBucket(targetBuckets.readWrite, sourceBuckets.readWrite);
|
|
434
|
+
}
|
|
279
435
|
for (const provider of Object.values(source.providers)) {
|
|
280
436
|
const existing = target.providers[provider.providerID] ||
|
|
281
437
|
emptyProviderUsage(provider.providerID);
|
|
@@ -321,12 +477,14 @@ export function toCachedSessionUsage(summary) {
|
|
|
321
477
|
cost: summary.cost,
|
|
322
478
|
apiCost: summary.apiCost,
|
|
323
479
|
assistantMessages: summary.assistantMessages,
|
|
480
|
+
cacheBuckets: cloneCacheUsageBuckets(summary.cacheBuckets),
|
|
324
481
|
providers,
|
|
325
482
|
};
|
|
326
483
|
}
|
|
327
484
|
export function fromCachedSessionUsage(cached, sessionCount = 1) {
|
|
328
485
|
// Merge cached reasoning into output for a single output metric.
|
|
329
486
|
const mergedOutputValue = cached.output + cached.reasoning;
|
|
487
|
+
const cacheBuckets = cloneCacheUsageBuckets(cached.cacheBuckets);
|
|
330
488
|
return {
|
|
331
489
|
input: cached.input,
|
|
332
490
|
output: mergedOutputValue,
|
|
@@ -338,6 +496,7 @@ export function fromCachedSessionUsage(cached, sessionCount = 1) {
|
|
|
338
496
|
apiCost: cached.apiCost || 0,
|
|
339
497
|
assistantMessages: cached.assistantMessages,
|
|
340
498
|
sessionCount,
|
|
499
|
+
cacheBuckets,
|
|
341
500
|
providers: Object.entries(cached.providers).reduce((acc, [providerID, provider]) => {
|
|
342
501
|
acc[providerID] = {
|
|
343
502
|
providerID,
|
package/dist/usage_service.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { TtlValueCache } from './cache.js';
|
|
2
|
-
import { calcEquivalentApiCostForMessage, canonicalApiCostProviderID, modelCostKey, parseModelCostRates, SUBSCRIPTION_API_COST_PROVIDERS, } from './cost.js';
|
|
3
|
-
import { dateKeyFromTimestamp,
|
|
2
|
+
import { cacheCoverageModeFromRates, calcEquivalentApiCostForMessage, canonicalApiCostProviderID, modelCostKey, parseModelCostRates, SUBSCRIPTION_API_COST_PROVIDERS, } from './cost.js';
|
|
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
|
};
|
|
@@ -48,13 +56,10 @@ export function createUsageService(deps) {
|
|
|
48
56
|
const map = all.reduce((acc, provider) => {
|
|
49
57
|
if (!isRecord(provider))
|
|
50
58
|
return acc;
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
: undefined;
|
|
54
|
-
if (!providerID)
|
|
55
|
-
return acc;
|
|
56
|
-
if (!SUBSCRIPTION_API_COST_PROVIDERS.has(providerID))
|
|
59
|
+
const rawProviderID = typeof provider.id === 'string' ? provider.id : undefined;
|
|
60
|
+
if (!rawProviderID)
|
|
57
61
|
return acc;
|
|
62
|
+
const canonicalProviderID = canonicalApiCostProviderID(rawProviderID);
|
|
58
63
|
const models = provider.models;
|
|
59
64
|
if (!isRecord(models))
|
|
60
65
|
return acc;
|
|
@@ -65,9 +70,15 @@ export function createUsageService(deps) {
|
|
|
65
70
|
if (!rates)
|
|
66
71
|
continue;
|
|
67
72
|
const modelID = typeof modelValue.id === 'string' ? modelValue.id : modelKey;
|
|
68
|
-
acc[modelCostKey(
|
|
73
|
+
acc[modelCostKey(rawProviderID, modelID)] = rates;
|
|
69
74
|
if (modelKey !== modelID) {
|
|
70
|
-
acc[modelCostKey(
|
|
75
|
+
acc[modelCostKey(rawProviderID, modelKey)] = rates;
|
|
76
|
+
}
|
|
77
|
+
if (canonicalProviderID !== rawProviderID) {
|
|
78
|
+
acc[modelCostKey(canonicalProviderID, modelID)] = rates;
|
|
79
|
+
if (modelKey !== modelID) {
|
|
80
|
+
acc[modelCostKey(canonicalProviderID, modelKey)] = rates;
|
|
81
|
+
}
|
|
71
82
|
}
|
|
72
83
|
}
|
|
73
84
|
return acc;
|
|
@@ -78,7 +89,8 @@ export function createUsageService(deps) {
|
|
|
78
89
|
const providerID = canonicalApiCostProviderID(message.providerID);
|
|
79
90
|
if (!SUBSCRIPTION_API_COST_PROVIDERS.has(providerID))
|
|
80
91
|
return 0;
|
|
81
|
-
const rates = modelCostMap[modelCostKey(providerID, message.modelID)]
|
|
92
|
+
const rates = modelCostMap[modelCostKey(message.providerID, message.modelID)] ||
|
|
93
|
+
modelCostMap[modelCostKey(providerID, message.modelID)];
|
|
82
94
|
if (!rates) {
|
|
83
95
|
const key = modelCostKey(providerID, message.modelID);
|
|
84
96
|
if (!missingApiCostRateKeys.has(key)) {
|
|
@@ -89,6 +101,36 @@ export function createUsageService(deps) {
|
|
|
89
101
|
}
|
|
90
102
|
return calcEquivalentApiCostForMessage(message, rates);
|
|
91
103
|
};
|
|
104
|
+
const classifyCacheMode = (message, modelCostMap) => {
|
|
105
|
+
const canonicalProviderID = canonicalApiCostProviderID(message.providerID);
|
|
106
|
+
const baseRates = modelCostMap[modelCostKey(message.providerID, message.modelID)] ||
|
|
107
|
+
modelCostMap[modelCostKey(canonicalProviderID, message.modelID)];
|
|
108
|
+
const effectiveRates = baseRates &&
|
|
109
|
+
message.tokens.input + message.tokens.cache.read > 200_000 &&
|
|
110
|
+
baseRates.contextOver200k
|
|
111
|
+
? baseRates.contextOver200k
|
|
112
|
+
: baseRates;
|
|
113
|
+
const fromRates = cacheCoverageModeFromRates(effectiveRates);
|
|
114
|
+
if (fromRates !== 'none')
|
|
115
|
+
return fromRates;
|
|
116
|
+
if (message.tokens.cache.write > 0)
|
|
117
|
+
return 'read-write';
|
|
118
|
+
if (message.tokens.cache.read <= 0)
|
|
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.
|
|
126
|
+
if (canonicalProviderID === 'anthropic' ||
|
|
127
|
+
message.modelID.toLowerCase().includes('claude')) {
|
|
128
|
+
return 'read-write';
|
|
129
|
+
}
|
|
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';
|
|
133
|
+
};
|
|
92
134
|
const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
|
|
93
135
|
const decodeTokens = (value) => {
|
|
94
136
|
if (!isRecord(value))
|
|
@@ -117,10 +159,6 @@ export function createUsageService(deps) {
|
|
|
117
159
|
return undefined;
|
|
118
160
|
if (typeof value.role !== 'string')
|
|
119
161
|
return undefined;
|
|
120
|
-
if (typeof value.providerID !== 'string')
|
|
121
|
-
return undefined;
|
|
122
|
-
if (typeof value.modelID !== 'string')
|
|
123
|
-
return undefined;
|
|
124
162
|
if (!isRecord(value.time))
|
|
125
163
|
return undefined;
|
|
126
164
|
if (!isFiniteNumber(value.time.created))
|
|
@@ -129,6 +167,19 @@ export function createUsageService(deps) {
|
|
|
129
167
|
!isFiniteNumber(value.time.completed)) {
|
|
130
168
|
return undefined;
|
|
131
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;
|
|
132
183
|
const tokens = decodeTokens(value.tokens);
|
|
133
184
|
if (!tokens)
|
|
134
185
|
return undefined;
|
|
@@ -194,6 +245,21 @@ export function createUsageService(deps) {
|
|
|
194
245
|
return false;
|
|
195
246
|
return cached.billingVersion === USAGE_BILLING_CACHE_VERSION;
|
|
196
247
|
};
|
|
248
|
+
const hasStaleZeroApiCost = (cached, modelCostMap) => {
|
|
249
|
+
if (!cached)
|
|
250
|
+
return false;
|
|
251
|
+
if (cached.apiCost > 0)
|
|
252
|
+
return false;
|
|
253
|
+
return Object.entries(cached.providers).some(([providerID, provider]) => {
|
|
254
|
+
if (provider.apiCost > 0)
|
|
255
|
+
return false;
|
|
256
|
+
const canonicalProviderID = canonicalApiCostProviderID(providerID);
|
|
257
|
+
if (!SUBSCRIPTION_API_COST_PROVIDERS.has(canonicalProviderID))
|
|
258
|
+
return false;
|
|
259
|
+
return Object.keys(modelCostMap).some((key) => key.startsWith(`${providerID}:`) ||
|
|
260
|
+
key.startsWith(`${canonicalProviderID}:`));
|
|
261
|
+
});
|
|
262
|
+
};
|
|
197
263
|
const summarizeSessionUsage = async (sessionID, generationAtStart) => {
|
|
198
264
|
const entries = await loadSessionEntries(sessionID);
|
|
199
265
|
const sessionState = deps.state.sessions[sessionID];
|
|
@@ -212,7 +278,8 @@ export function createUsageService(deps) {
|
|
|
212
278
|
}
|
|
213
279
|
const modelCostMap = await getModelCostMap();
|
|
214
280
|
const staleBillingCache = Boolean(sessionState?.usage) &&
|
|
215
|
-
!isUsageBillingCurrent(sessionState?.usage)
|
|
281
|
+
(!isUsageBillingCurrent(sessionState?.usage) ||
|
|
282
|
+
hasStaleZeroApiCost(sessionState?.usage, modelCostMap));
|
|
216
283
|
const forceRescan = forceRescanSessions.has(sessionID) || staleBillingCache;
|
|
217
284
|
if (forceRescan)
|
|
218
285
|
forceRescanSessions.delete(sessionID);
|
|
@@ -221,11 +288,13 @@ export function createUsageService(deps) {
|
|
|
221
288
|
}
|
|
222
289
|
const { usage, cursor } = summarizeMessagesIncremental(entries, sessionState?.usage, sessionState?.cursor, forceRescan, {
|
|
223
290
|
calcApiCost: (message) => calcEquivalentApiCost(message, modelCostMap),
|
|
291
|
+
classifyCacheMode: (message) => classifyCacheMode(message, modelCostMap),
|
|
224
292
|
});
|
|
225
293
|
usage.sessionCount = 1;
|
|
226
294
|
// Update cursor in state
|
|
227
295
|
if (sessionState) {
|
|
228
296
|
sessionState.cursor = cursor;
|
|
297
|
+
sessionState.dirty = false;
|
|
229
298
|
}
|
|
230
299
|
if ((dirtyGeneration.get(sessionID) || 0) === generationAtStart) {
|
|
231
300
|
cleanGeneration.set(sessionID, generationAtStart);
|
|
@@ -261,11 +330,16 @@ export function createUsageService(deps) {
|
|
|
261
330
|
const summarizeSessionUsageForDisplay = async (sessionID, includeChildren) => {
|
|
262
331
|
const root = await summarizeSessionUsageLocked(sessionID);
|
|
263
332
|
const usage = root.usage;
|
|
333
|
+
let dirty = false;
|
|
264
334
|
if (root.persist) {
|
|
265
335
|
persistSessionUsage(sessionID, toCachedSessionUsage(usage));
|
|
336
|
+
dirty = true;
|
|
266
337
|
}
|
|
267
|
-
if (!includeChildren)
|
|
338
|
+
if (!includeChildren) {
|
|
339
|
+
if (dirty)
|
|
340
|
+
deps.persistence.scheduleSave();
|
|
268
341
|
return usage;
|
|
342
|
+
}
|
|
269
343
|
const descendantIDs = await deps.descendantsResolver.listDescendantSessionIDs(sessionID, {
|
|
270
344
|
maxDepth: deps.config.sidebar.childrenMaxDepth,
|
|
271
345
|
maxSessions: deps.config.sidebar.childrenMaxSessions,
|
|
@@ -294,6 +368,7 @@ export function createUsageService(deps) {
|
|
|
294
368
|
const child = await summarizeSessionUsageLocked(childID);
|
|
295
369
|
if (child.persist) {
|
|
296
370
|
persistSessionUsage(childID, toCachedSessionUsage(child.usage));
|
|
371
|
+
dirty = true;
|
|
297
372
|
}
|
|
298
373
|
return child.usage;
|
|
299
374
|
});
|
|
@@ -301,15 +376,17 @@ export function createUsageService(deps) {
|
|
|
301
376
|
mergeUsage(merged, childUsage, { includeCost: false });
|
|
302
377
|
}
|
|
303
378
|
}
|
|
379
|
+
if (dirty)
|
|
380
|
+
deps.persistence.scheduleSave();
|
|
304
381
|
return merged;
|
|
305
382
|
};
|
|
306
383
|
const RANGE_USAGE_CONCURRENCY = 5;
|
|
307
384
|
const summarizeRangeUsage = async (period) => {
|
|
308
385
|
const startAt = periodStart(period);
|
|
386
|
+
const endAt = Date.now();
|
|
309
387
|
await deps.persistence.flushSave();
|
|
310
|
-
const sessions = await
|
|
388
|
+
const sessions = await scanAllSessions(deps.statePath, deps.state);
|
|
311
389
|
const usage = emptyUsageSummary();
|
|
312
|
-
usage.sessionCount = sessions.length;
|
|
313
390
|
const modelCostMap = await getModelCostMap();
|
|
314
391
|
const hasPricing = Object.keys(modelCostMap).length > 0;
|
|
315
392
|
const hasAnySubscriptionProvider = (cached) => {
|
|
@@ -338,61 +415,78 @@ export function createUsageService(deps) {
|
|
|
338
415
|
return false;
|
|
339
416
|
return true;
|
|
340
417
|
};
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
if (session.state.usage) {
|
|
344
|
-
if (shouldRecomputeUsageCache(session.state.usage)) {
|
|
345
|
-
needsFetch.push(session);
|
|
346
|
-
}
|
|
347
|
-
else {
|
|
348
|
-
mergeUsage(usage, fromCachedSessionUsage(session.state.usage, 0));
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
else {
|
|
352
|
-
needsFetch.push(session);
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
if (needsFetch.length > 0) {
|
|
356
|
-
const fetched = await mapConcurrent(needsFetch, RANGE_USAGE_CONCURRENCY, async (session) => {
|
|
418
|
+
if (sessions.length > 0) {
|
|
419
|
+
const fetched = await mapConcurrent(sessions, RANGE_USAGE_CONCURRENCY, async (session) => {
|
|
357
420
|
const entries = await loadSessionEntries(session.sessionID);
|
|
358
421
|
if (!entries) {
|
|
359
|
-
if (session.state.usage) {
|
|
360
|
-
return {
|
|
361
|
-
sessionID: session.sessionID,
|
|
362
|
-
dateKey: session.dateKey,
|
|
363
|
-
computed: fromCachedSessionUsage(session.state.usage, 1),
|
|
364
|
-
persist: false,
|
|
365
|
-
cursor: session.state.cursor,
|
|
366
|
-
};
|
|
367
|
-
}
|
|
368
|
-
const empty = emptyUsageSummary();
|
|
369
|
-
empty.sessionCount = 1;
|
|
370
422
|
return {
|
|
371
423
|
sessionID: session.sessionID,
|
|
372
424
|
dateKey: session.dateKey,
|
|
373
|
-
|
|
425
|
+
createdAt: session.state.createdAt,
|
|
426
|
+
lastMessageTime: session.state.cursor?.lastMessageTime,
|
|
427
|
+
computed: emptyUsageSummary(),
|
|
428
|
+
fullUsage: undefined,
|
|
429
|
+
loadFailed: true,
|
|
374
430
|
persist: false,
|
|
375
431
|
cursor: undefined,
|
|
376
432
|
};
|
|
377
433
|
}
|
|
378
|
-
const
|
|
434
|
+
const computed = summarizeMessagesInCompletedRange(entries, startAt, endAt, 0, {
|
|
435
|
+
calcApiCost: (message) => calcEquivalentApiCost(message, modelCostMap),
|
|
436
|
+
classifyCacheMode: (message) => classifyCacheMode(message, modelCostMap),
|
|
437
|
+
});
|
|
438
|
+
const shouldPersistFullUsage = !session.state.usage || shouldRecomputeUsageCache(session.state.usage);
|
|
439
|
+
if (!shouldPersistFullUsage) {
|
|
440
|
+
return {
|
|
441
|
+
sessionID: session.sessionID,
|
|
442
|
+
dateKey: session.dateKey,
|
|
443
|
+
createdAt: session.state.createdAt,
|
|
444
|
+
lastMessageTime: session.state.cursor?.lastMessageTime,
|
|
445
|
+
computed,
|
|
446
|
+
fullUsage: undefined,
|
|
447
|
+
loadFailed: false,
|
|
448
|
+
persist: false,
|
|
449
|
+
cursor: session.state.cursor,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
const { usage: fullUsage, cursor } = summarizeMessagesIncremental(entries, undefined, undefined, true, {
|
|
379
453
|
calcApiCost: (message) => calcEquivalentApiCost(message, modelCostMap),
|
|
454
|
+
classifyCacheMode: (message) => classifyCacheMode(message, modelCostMap),
|
|
380
455
|
});
|
|
381
456
|
return {
|
|
382
457
|
sessionID: session.sessionID,
|
|
383
458
|
dateKey: session.dateKey,
|
|
459
|
+
createdAt: session.state.createdAt,
|
|
460
|
+
lastMessageTime: cursor.lastMessageTime,
|
|
384
461
|
computed,
|
|
462
|
+
fullUsage,
|
|
463
|
+
loadFailed: false,
|
|
385
464
|
persist: true,
|
|
386
465
|
cursor,
|
|
387
466
|
};
|
|
388
467
|
});
|
|
468
|
+
const failedLoads = fetched.filter((item) => {
|
|
469
|
+
if (!item.loadFailed)
|
|
470
|
+
return false;
|
|
471
|
+
const lastMessageTime = item.lastMessageTime;
|
|
472
|
+
if (typeof lastMessageTime === 'number' && lastMessageTime < startAt) {
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
return true;
|
|
476
|
+
});
|
|
477
|
+
if (failedLoads.length > 0) {
|
|
478
|
+
throw new Error(`range usage unavailable: failed to load ${failedLoads.length} session(s)`);
|
|
479
|
+
}
|
|
389
480
|
let dirty = false;
|
|
390
481
|
const diskOnlyUpdates = [];
|
|
391
|
-
for (const { sessionID, dateKey, computed, persist, cursor } of fetched) {
|
|
392
|
-
|
|
482
|
+
for (const { sessionID, dateKey, computed, fullUsage, persist, cursor } of fetched) {
|
|
483
|
+
if (computed.assistantMessages > 0) {
|
|
484
|
+
computed.sessionCount = 1;
|
|
485
|
+
mergeUsage(usage, computed);
|
|
486
|
+
}
|
|
393
487
|
const memoryState = deps.state.sessions[sessionID];
|
|
394
|
-
if (persist && memoryState) {
|
|
395
|
-
memoryState.usage = toCachedSessionUsage(
|
|
488
|
+
if (persist && fullUsage && memoryState) {
|
|
489
|
+
memoryState.usage = toCachedSessionUsage(fullUsage);
|
|
396
490
|
memoryState.cursor = cursor;
|
|
397
491
|
const resolvedDateKey = deps.state.sessionDateMap[sessionID] ||
|
|
398
492
|
dateKeyFromTimestamp(memoryState.createdAt);
|
|
@@ -400,11 +494,11 @@ export function createUsageService(deps) {
|
|
|
400
494
|
deps.persistence.markDirty(resolvedDateKey);
|
|
401
495
|
dirty = true;
|
|
402
496
|
}
|
|
403
|
-
else if (persist) {
|
|
497
|
+
else if (persist && fullUsage) {
|
|
404
498
|
diskOnlyUpdates.push({
|
|
405
499
|
sessionID,
|
|
406
500
|
dateKey,
|
|
407
|
-
usage: toCachedSessionUsage(
|
|
501
|
+
usage: toCachedSessionUsage(fullUsage),
|
|
408
502
|
cursor,
|
|
409
503
|
});
|
|
410
504
|
}
|
|
@@ -425,6 +519,15 @@ export function createUsageService(deps) {
|
|
|
425
519
|
};
|
|
426
520
|
const markSessionDirty = (sessionID) => {
|
|
427
521
|
bumpDirty(sessionID);
|
|
522
|
+
const sessionState = deps.state.sessions[sessionID];
|
|
523
|
+
if (sessionState && !sessionState.dirty) {
|
|
524
|
+
sessionState.dirty = true;
|
|
525
|
+
const dateKey = deps.state.sessionDateMap[sessionID] ||
|
|
526
|
+
dateKeyFromTimestamp(sessionState.createdAt);
|
|
527
|
+
deps.state.sessionDateMap[sessionID] = dateKey;
|
|
528
|
+
deps.persistence.markDirty(dateKey);
|
|
529
|
+
deps.persistence.scheduleSave();
|
|
530
|
+
}
|
|
428
531
|
};
|
|
429
532
|
const markForceRescan = (sessionID) => {
|
|
430
533
|
forceRescanSessions.add(sessionID);
|