@leo000001/opencode-quota-sidebar 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/format.js +68 -26
- package/dist/storage.d.ts +8 -1
- package/dist/storage.js +65 -9
- package/dist/storage_parse.js +1 -0
- package/dist/types.d.ts +4 -0
- package/dist/usage.d.ts +1 -0
- package/dist/usage.js +9 -1
- package/dist/usage_service.js +47 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -180,8 +180,8 @@ Notes:
|
|
|
180
180
|
- `sidebar.childrenMaxDepth` limits how many levels of nested subagents are traversed (default: `6`, clamped 1–32).
|
|
181
181
|
- `sidebar.childrenMaxSessions` caps the total number of descendant sessions aggregated (default: `128`, clamped 0–2000).
|
|
182
182
|
- `sidebar.childrenConcurrency` controls parallel fetches for descendant session messages (default: `5`, clamped 1–10).
|
|
183
|
-
- `output`
|
|
184
|
-
- API cost
|
|
183
|
+
- `output` includes reasoning tokens (`output = tokens.output + tokens.reasoning`). Reasoning is not rendered as a separate line.
|
|
184
|
+
- API cost bills reasoning tokens at the output rate (same as completion tokens).
|
|
185
185
|
- `quota.providers` is the extensible per-adapter switch map.
|
|
186
186
|
- If API Cost is `$0.00`, it usually means the model/provider has no pricing mapping in OpenCode at the moment, so equivalent API cost cannot be estimated.
|
|
187
187
|
|
package/dist/format.js
CHANGED
|
@@ -187,7 +187,10 @@ export function renderSidebarTitle(baseTitle, usage, quotas, config) {
|
|
|
187
187
|
return Math.max(max, stringCellWidth(label));
|
|
188
188
|
}, 0);
|
|
189
189
|
const quotaItems = visibleQuotas
|
|
190
|
-
.flatMap((item) => compactQuotaWide(item, labelWidth
|
|
190
|
+
.flatMap((item) => compactQuotaWide(item, labelWidth, {
|
|
191
|
+
width,
|
|
192
|
+
wrapLines: config.sidebar.wrapQuotaLines,
|
|
193
|
+
}))
|
|
191
194
|
.filter((s) => Boolean(s));
|
|
192
195
|
if (quotaItems.length > 0) {
|
|
193
196
|
lines.push('');
|
|
@@ -200,14 +203,31 @@ export function renderSidebarTitle(baseTitle, usage, quotas, config) {
|
|
|
200
203
|
}
|
|
201
204
|
/**
|
|
202
205
|
* Multi-window quota format for sidebar.
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
+
*
|
|
207
|
+
* When wrapLines=false (or content fits):
|
|
208
|
+
* "OpenAI 5h 80% Rst 16:20"
|
|
209
|
+
* " Weekly 70% Rst 03-01"
|
|
210
|
+
*
|
|
211
|
+
* When wrapLines=true and label+content overflows width:
|
|
212
|
+
* "RC-openai"
|
|
213
|
+
* " Daily $349.66/$180 Exp+ 02-27"
|
|
214
|
+
* " Balance $108.88"
|
|
206
215
|
*/
|
|
207
|
-
function compactQuotaWide(quota, labelWidth = 0) {
|
|
216
|
+
function compactQuotaWide(quota, labelWidth = 0, options) {
|
|
208
217
|
const label = sanitizeLine(quotaDisplayLabel(quota));
|
|
209
218
|
const labelPadded = padEndCells(label, labelWidth);
|
|
219
|
+
const indent = ' '.repeat(labelWidth + 1);
|
|
220
|
+
const detailIndent = ' ';
|
|
210
221
|
const withLabel = (content) => `${labelPadded} ${content}`;
|
|
222
|
+
const wrap = options?.wrapLines === true && (options?.width || 0) > 0;
|
|
223
|
+
const width = options?.width || 0;
|
|
224
|
+
/** If inline version overflows, break into label-line + indented detail lines. */
|
|
225
|
+
const maybeBreak = (inlineText, detailLines) => {
|
|
226
|
+
const inline = withLabel(inlineText);
|
|
227
|
+
if (!wrap || stringCellWidth(inline) <= width)
|
|
228
|
+
return [inline];
|
|
229
|
+
return [label, ...detailLines.map((d) => `${detailIndent}${d}`)];
|
|
230
|
+
};
|
|
211
231
|
if (quota.status === 'error')
|
|
212
232
|
return [withLabel('Remaining ?')];
|
|
213
233
|
if (quota.status === 'unsupported')
|
|
@@ -229,7 +249,7 @@ function compactQuotaWide(quota, labelWidth = 0) {
|
|
|
229
249
|
? [sanitizeLine(win.label), pct]
|
|
230
250
|
: [sanitizeLine(win.label)]
|
|
231
251
|
: [pct];
|
|
232
|
-
const reset = compactReset(win.resetAt);
|
|
252
|
+
const reset = compactReset(win.resetAt, win.resetLabel);
|
|
233
253
|
if (reset) {
|
|
234
254
|
parts.push(`${sanitizeLine(win.resetLabel || 'Rst')} ${reset}`);
|
|
235
255
|
}
|
|
@@ -238,42 +258,64 @@ function compactQuotaWide(quota, labelWidth = 0) {
|
|
|
238
258
|
// Multi-window rendering
|
|
239
259
|
if (quota.windows && quota.windows.length > 0) {
|
|
240
260
|
const parts = quota.windows.map(renderWindow);
|
|
261
|
+
// Build the detail lines (window texts + optional balance)
|
|
262
|
+
const details = [...parts];
|
|
263
|
+
if (balanceText && !parts.some((p) => p.includes('Balance '))) {
|
|
264
|
+
details.push(balanceText);
|
|
265
|
+
}
|
|
266
|
+
// Try inline first (single window, fits in one line)
|
|
241
267
|
if (parts.length === 1) {
|
|
242
|
-
const
|
|
243
|
-
if (
|
|
244
|
-
|
|
245
|
-
|
|
268
|
+
const firstInline = withLabel(parts[0]);
|
|
269
|
+
if (!wrap || stringCellWidth(firstInline) <= width) {
|
|
270
|
+
// Inline fits — use classic layout
|
|
271
|
+
const lines = [firstInline];
|
|
272
|
+
if (balanceText && !parts[0].includes('Balance ')) {
|
|
273
|
+
lines.push(`${indent}${balanceText}`);
|
|
274
|
+
}
|
|
275
|
+
return lines;
|
|
246
276
|
}
|
|
247
|
-
|
|
277
|
+
// Overflow — break: label on its own line, details indented
|
|
278
|
+
return [label, ...details.map((d) => `${detailIndent}${d}`)];
|
|
248
279
|
}
|
|
249
|
-
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
280
|
+
// Multiple windows: try classic inline layout first
|
|
281
|
+
const firstInline = withLabel(parts[0]);
|
|
282
|
+
if (!wrap || stringCellWidth(firstInline) <= width) {
|
|
283
|
+
const lines = [
|
|
284
|
+
firstInline,
|
|
285
|
+
...parts.slice(1).map((part) => `${indent}${part}`),
|
|
286
|
+
];
|
|
287
|
+
if (balanceText && !parts.some((p) => p.includes('Balance '))) {
|
|
288
|
+
lines.push(`${indent}${balanceText}`);
|
|
289
|
+
}
|
|
290
|
+
return lines;
|
|
257
291
|
}
|
|
258
|
-
|
|
292
|
+
// Overflow — break all
|
|
293
|
+
return [label, ...details.map((d) => `${detailIndent}${d}`)];
|
|
259
294
|
}
|
|
260
295
|
if (balanceText) {
|
|
261
|
-
return
|
|
296
|
+
return maybeBreak(balanceText, [balanceText]);
|
|
262
297
|
}
|
|
263
298
|
// Fallback: single value from top-level remainingPercent
|
|
264
299
|
const percent = quota.remainingPercent === undefined
|
|
265
300
|
? '?'
|
|
266
301
|
: `${Math.round(quota.remainingPercent)}%`;
|
|
267
|
-
const reset = compactReset(quota.resetAt);
|
|
268
|
-
|
|
302
|
+
const reset = compactReset(quota.resetAt, 'Rst');
|
|
303
|
+
const fallbackText = `Remaining ${percent}${reset ? ` Rst ${reset}` : ''}`;
|
|
304
|
+
return maybeBreak(fallbackText, [fallbackText]);
|
|
269
305
|
}
|
|
270
|
-
function compactReset(iso) {
|
|
306
|
+
function compactReset(iso, resetLabel) {
|
|
271
307
|
if (!iso)
|
|
272
308
|
return undefined;
|
|
273
309
|
const timestamp = Date.parse(iso);
|
|
274
310
|
if (Number.isNaN(timestamp))
|
|
275
311
|
return undefined;
|
|
276
312
|
const value = new Date(timestamp);
|
|
313
|
+
// RightCode subscriptions are displayed as an expiry date (MM-DD), not a time.
|
|
314
|
+
// Using UTC here makes the output stable across time zones for ISO `...Z` input.
|
|
315
|
+
if (typeof resetLabel === 'string' && resetLabel.startsWith('Exp')) {
|
|
316
|
+
const two = (num) => `${num}`.padStart(2, '0');
|
|
317
|
+
return `${two(value.getUTCMonth() + 1)}-${two(value.getUTCDate())}`;
|
|
318
|
+
}
|
|
277
319
|
const now = new Date();
|
|
278
320
|
const sameDay = value.getFullYear() === now.getFullYear() &&
|
|
279
321
|
value.getMonth() === now.getMonth() &&
|
|
@@ -475,7 +517,7 @@ export function renderToastMessage(period, usage, quotas, options) {
|
|
|
475
517
|
const pct = win.remainingPercent === undefined
|
|
476
518
|
? '-'
|
|
477
519
|
: `${win.remainingPercent.toFixed(1)}%`;
|
|
478
|
-
const reset = compactReset(win.resetAt);
|
|
520
|
+
const reset = compactReset(win.resetAt, win.resetLabel);
|
|
479
521
|
const parts = [win.label];
|
|
480
522
|
if (showPercent)
|
|
481
523
|
parts.push(pct);
|
|
@@ -505,7 +547,7 @@ export function renderToastMessage(period, usage, quotas, options) {
|
|
|
505
547
|
const percent = item.remainingPercent === undefined
|
|
506
548
|
? '-'
|
|
507
549
|
: `${item.remainingPercent.toFixed(1)}%`;
|
|
508
|
-
const reset = compactReset(item.resetAt);
|
|
550
|
+
const reset = compactReset(item.resetAt, 'Rst');
|
|
509
551
|
return [
|
|
510
552
|
{
|
|
511
553
|
label: quotaDisplayLabel(item),
|
package/dist/storage.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { dateKeyFromTimestamp, normalizeTimestampMs } from './storage_dates.js';
|
|
2
2
|
import { authFilePath, resolveOpencodeDataDir, stateFilePath } from './storage_paths.js';
|
|
3
|
-
import type { QuotaSidebarConfig, QuotaSidebarState, SessionState } from './types.js';
|
|
3
|
+
import type { CachedSessionUsage, IncrementalCursor, QuotaSidebarConfig, QuotaSidebarState, SessionState } from './types.js';
|
|
4
4
|
export { authFilePath, dateKeyFromTimestamp, normalizeTimestampMs, resolveOpencodeDataDir, stateFilePath, };
|
|
5
5
|
export declare const defaultConfig: QuotaSidebarConfig;
|
|
6
6
|
export declare function defaultState(): QuotaSidebarState;
|
|
@@ -32,3 +32,10 @@ export declare function scanSessionsByCreatedRange(statePath: string, startAt: n
|
|
|
32
32
|
}[]>;
|
|
33
33
|
/** Best-effort: remove a session entry from its day chunk (if present). */
|
|
34
34
|
export declare function deleteSessionFromDayChunk(statePath: string, sessionID: string, dateKey: string): Promise<boolean>;
|
|
35
|
+
/** Best-effort: persist recomputed usage/cursor for sessions loaded from disk-only chunks. */
|
|
36
|
+
export declare function updateSessionsInDayChunks(statePath: string, updates: Array<{
|
|
37
|
+
sessionID: string;
|
|
38
|
+
dateKey: string;
|
|
39
|
+
usage: CachedSessionUsage;
|
|
40
|
+
cursor: IncrementalCursor | undefined;
|
|
41
|
+
}>): Promise<number>;
|
package/dist/storage.js
CHANGED
|
@@ -13,6 +13,7 @@ export const defaultConfig = {
|
|
|
13
13
|
width: 36,
|
|
14
14
|
showCost: true,
|
|
15
15
|
showQuota: true,
|
|
16
|
+
wrapQuotaLines: true,
|
|
16
17
|
includeChildren: true,
|
|
17
18
|
childrenMaxDepth: 6,
|
|
18
19
|
childrenMaxSessions: 128,
|
|
@@ -68,6 +69,7 @@ export async function loadConfig(paths) {
|
|
|
68
69
|
width: Math.max(20, Math.min(60, asNumber(sidebar.width, defaultConfig.sidebar.width))),
|
|
69
70
|
showCost: asBoolean(sidebar.showCost, defaultConfig.sidebar.showCost),
|
|
70
71
|
showQuota: asBoolean(sidebar.showQuota, defaultConfig.sidebar.showQuota),
|
|
72
|
+
wrapQuotaLines: asBoolean(sidebar.wrapQuotaLines, defaultConfig.sidebar.wrapQuotaLines),
|
|
71
73
|
includeChildren: asBoolean(sidebar.includeChildren, defaultConfig.sidebar.includeChildren),
|
|
72
74
|
childrenMaxDepth: Math.max(1, Math.min(32, Math.floor(asNumber(sidebar.childrenMaxDepth, defaultConfig.sidebar.childrenMaxDepth)))),
|
|
73
75
|
childrenMaxSessions: Math.max(0, Math.min(2000, Math.floor(asNumber(sidebar.childrenMaxSessions, defaultConfig.sidebar.childrenMaxSessions)))),
|
|
@@ -161,6 +163,21 @@ export async function loadState(statePath) {
|
|
|
161
163
|
}
|
|
162
164
|
// ─── State saving ────────────────────────────────────────────────────────────
|
|
163
165
|
const MAX_QUOTA_CACHE_AGE_MS = 24 * 60 * 60 * 1000;
|
|
166
|
+
const dayChunkWriteLocks = new Map();
|
|
167
|
+
async function withDayChunkWriteLock(dateKey, fn) {
|
|
168
|
+
const previous = dayChunkWriteLocks.get(dateKey) || Promise.resolve();
|
|
169
|
+
const run = previous.then(() => fn());
|
|
170
|
+
const release = run.then(() => undefined, () => undefined);
|
|
171
|
+
dayChunkWriteLocks.set(dateKey, release);
|
|
172
|
+
try {
|
|
173
|
+
return await run;
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
if (dayChunkWriteLocks.get(dateKey) === release) {
|
|
177
|
+
dayChunkWriteLocks.delete(dateKey);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
164
181
|
function pruneState(state) {
|
|
165
182
|
const now = Date.now();
|
|
166
183
|
for (const [key, value] of Object.entries(state.quotaCache)) {
|
|
@@ -214,7 +231,7 @@ export async function saveState(statePath, state, options) {
|
|
|
214
231
|
if (!Object.prototype.hasOwnProperty.call(sessionsByDate, dateKey)) {
|
|
215
232
|
return undefined;
|
|
216
233
|
}
|
|
217
|
-
return (async () => {
|
|
234
|
+
return withDayChunkWriteLock(dateKey, async () => {
|
|
218
235
|
const memorySessions = sessionsByDate[dateKey] || {};
|
|
219
236
|
const next = writeAll
|
|
220
237
|
? memorySessions
|
|
@@ -223,7 +240,7 @@ export async function saveState(statePath, state, options) {
|
|
|
223
240
|
...memorySessions,
|
|
224
241
|
};
|
|
225
242
|
await writeDayChunk(rootPath, dateKey, next);
|
|
226
|
-
})
|
|
243
|
+
});
|
|
227
244
|
})
|
|
228
245
|
.filter((promise) => Boolean(promise)));
|
|
229
246
|
}
|
|
@@ -316,11 +333,50 @@ export async function scanSessionsByCreatedRange(statePath, startAt, endAt = Dat
|
|
|
316
333
|
/** Best-effort: remove a session entry from its day chunk (if present). */
|
|
317
334
|
export async function deleteSessionFromDayChunk(statePath, sessionID, dateKey) {
|
|
318
335
|
const rootPath = chunkRootPathFromStateFile(statePath);
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
336
|
+
return withDayChunkWriteLock(dateKey, async () => {
|
|
337
|
+
const sessions = await readDayChunk(rootPath, dateKey);
|
|
338
|
+
if (!Object.prototype.hasOwnProperty.call(sessions, sessionID))
|
|
339
|
+
return false;
|
|
340
|
+
const next = { ...sessions };
|
|
341
|
+
delete next[sessionID];
|
|
342
|
+
await writeDayChunk(rootPath, dateKey, next);
|
|
343
|
+
return true;
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
/** Best-effort: persist recomputed usage/cursor for sessions loaded from disk-only chunks. */
|
|
347
|
+
export async function updateSessionsInDayChunks(statePath, updates) {
|
|
348
|
+
if (updates.length === 0)
|
|
349
|
+
return 0;
|
|
350
|
+
const rootPath = chunkRootPathFromStateFile(statePath);
|
|
351
|
+
const byDate = updates.reduce((acc, item) => {
|
|
352
|
+
const bucket = acc[item.dateKey] || [];
|
|
353
|
+
bucket.push(item);
|
|
354
|
+
acc[item.dateKey] = bucket;
|
|
355
|
+
return acc;
|
|
356
|
+
}, {});
|
|
357
|
+
let written = 0;
|
|
358
|
+
const WRITE_CONCURRENCY = 5;
|
|
359
|
+
await mapConcurrent(Object.entries(byDate), WRITE_CONCURRENCY, async ([dateKey, dateUpdates]) => {
|
|
360
|
+
await withDayChunkWriteLock(dateKey, async () => {
|
|
361
|
+
const sessions = await readDayChunk(rootPath, dateKey);
|
|
362
|
+
const next = { ...sessions };
|
|
363
|
+
let changed = false;
|
|
364
|
+
for (const item of dateUpdates) {
|
|
365
|
+
const existing = next[item.sessionID];
|
|
366
|
+
if (!existing)
|
|
367
|
+
continue;
|
|
368
|
+
next[item.sessionID] = {
|
|
369
|
+
...existing,
|
|
370
|
+
usage: item.usage,
|
|
371
|
+
cursor: item.cursor,
|
|
372
|
+
};
|
|
373
|
+
changed = true;
|
|
374
|
+
}
|
|
375
|
+
if (!changed)
|
|
376
|
+
return;
|
|
377
|
+
await writeDayChunk(rootPath, dateKey, next);
|
|
378
|
+
written++;
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
return written;
|
|
326
382
|
}
|
package/dist/storage_parse.js
CHANGED
package/dist/types.d.ts
CHANGED
|
@@ -49,6 +49,8 @@ export type CachedProviderUsage = {
|
|
|
49
49
|
assistantMessages: number;
|
|
50
50
|
};
|
|
51
51
|
export type CachedSessionUsage = {
|
|
52
|
+
/** Billing aggregation cache version for cost/apiCost refresh migrations. */
|
|
53
|
+
billingVersion?: number;
|
|
52
54
|
input: number;
|
|
53
55
|
output: number;
|
|
54
56
|
reasoning: number;
|
|
@@ -97,6 +99,8 @@ export type QuotaSidebarConfig = {
|
|
|
97
99
|
width: number;
|
|
98
100
|
showCost: boolean;
|
|
99
101
|
showQuota: boolean;
|
|
102
|
+
/** When true, wrap long quota lines and indent continuations. */
|
|
103
|
+
wrapQuotaLines: boolean;
|
|
100
104
|
/** Include descendant subagent sessions in session-scoped usage/quota. */
|
|
101
105
|
includeChildren: boolean;
|
|
102
106
|
/** Max descendant traversal depth when includeChildren is enabled. */
|
package/dist/usage.d.ts
CHANGED
package/dist/usage.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export const USAGE_BILLING_CACHE_VERSION = 1;
|
|
1
2
|
export function emptyUsageSummary() {
|
|
2
3
|
return {
|
|
3
4
|
input: 0,
|
|
@@ -189,10 +190,16 @@ export function summarizeMessagesIncremental(entries, existingUsage, cursor, for
|
|
|
189
190
|
time: nextCursor.lastMessageTime ?? cursorTime,
|
|
190
191
|
};
|
|
191
192
|
if (newerThan(candidate, current)) {
|
|
193
|
+
const idsAtCursorTime = new Set(nextCursor.lastMessageIdsAtTime ||
|
|
194
|
+
cursor.lastMessageIdsAtTime ||
|
|
195
|
+
(current.id ? [current.id] : []));
|
|
196
|
+
idsAtCursorTime.add(msg.id);
|
|
192
197
|
nextCursor = {
|
|
193
198
|
lastMessageId: msg.id,
|
|
194
199
|
lastMessageTime: msg.time.completed,
|
|
195
|
-
lastMessageIdsAtTime:
|
|
200
|
+
lastMessageIdsAtTime: candidate.time > current.time
|
|
201
|
+
? [msg.id]
|
|
202
|
+
: Array.from(idsAtCursorTime).sort(),
|
|
196
203
|
};
|
|
197
204
|
}
|
|
198
205
|
else if (nextCursor.lastMessageTime === msg.time.completed) {
|
|
@@ -298,6 +305,7 @@ export function toCachedSessionUsage(summary) {
|
|
|
298
305
|
return acc;
|
|
299
306
|
}, {});
|
|
300
307
|
return {
|
|
308
|
+
billingVersion: USAGE_BILLING_CACHE_VERSION,
|
|
301
309
|
input: summary.input,
|
|
302
310
|
output: summary.output,
|
|
303
311
|
// Always 0 after merge into output; kept for serialization shape.
|
package/dist/usage_service.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { TtlValueCache } from './cache.js';
|
|
2
2
|
import { calcEquivalentApiCostForMessage, canonicalApiCostProviderID, modelCostKey, parseModelCostRates, SUBSCRIPTION_API_COST_PROVIDERS, } from './cost.js';
|
|
3
|
-
import { dateKeyFromTimestamp, scanSessionsByCreatedRange } from './storage.js';
|
|
3
|
+
import { dateKeyFromTimestamp, scanSessionsByCreatedRange, 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, } from './usage.js';
|
|
6
|
+
import { emptyUsageSummary, fromCachedSessionUsage, mergeUsage, summarizeMessagesIncremental, toCachedSessionUsage, USAGE_BILLING_CACHE_VERSION, } from './usage.js';
|
|
7
7
|
export function createUsageService(deps) {
|
|
8
8
|
const forceRescanSessions = new Set();
|
|
9
9
|
const dirtyGeneration = new Map();
|
|
@@ -189,6 +189,11 @@ export function createUsageService(deps) {
|
|
|
189
189
|
deps.state.sessionDateMap[sessionID] = dateKey;
|
|
190
190
|
deps.persistence.markDirty(dateKey);
|
|
191
191
|
};
|
|
192
|
+
const isUsageBillingCurrent = (cached) => {
|
|
193
|
+
if (!cached)
|
|
194
|
+
return false;
|
|
195
|
+
return cached.billingVersion === USAGE_BILLING_CACHE_VERSION;
|
|
196
|
+
};
|
|
192
197
|
const summarizeSessionUsage = async (sessionID, generationAtStart) => {
|
|
193
198
|
const entries = await loadSessionEntries(sessionID);
|
|
194
199
|
const sessionState = deps.state.sessions[sessionID];
|
|
@@ -206,9 +211,14 @@ export function createUsageService(deps) {
|
|
|
206
211
|
return { usage: empty, persist: false };
|
|
207
212
|
}
|
|
208
213
|
const modelCostMap = await getModelCostMap();
|
|
209
|
-
const
|
|
214
|
+
const staleBillingCache = Boolean(sessionState?.usage) &&
|
|
215
|
+
!isUsageBillingCurrent(sessionState?.usage);
|
|
216
|
+
const forceRescan = forceRescanSessions.has(sessionID) || staleBillingCache;
|
|
210
217
|
if (forceRescan)
|
|
211
218
|
forceRescanSessions.delete(sessionID);
|
|
219
|
+
if (staleBillingCache) {
|
|
220
|
+
debug(`usage cache billing refresh for session ${sessionID}`);
|
|
221
|
+
}
|
|
212
222
|
const { usage, cursor } = summarizeMessagesIncremental(entries, sessionState?.usage, sessionState?.cursor, forceRescan, {
|
|
213
223
|
calcApiCost: (message) => calcEquivalentApiCost(message, modelCostMap),
|
|
214
224
|
});
|
|
@@ -268,7 +278,7 @@ export function createUsageService(deps) {
|
|
|
268
278
|
const needsFetch = [];
|
|
269
279
|
for (const childID of descendantIDs) {
|
|
270
280
|
const cached = deps.state.sessions[childID]?.usage;
|
|
271
|
-
if (cached && !isDirty(childID)) {
|
|
281
|
+
if (cached && !isDirty(childID) && isUsageBillingCurrent(cached)) {
|
|
272
282
|
mergeUsage(merged, fromCachedSessionUsage(cached, 1));
|
|
273
283
|
}
|
|
274
284
|
else {
|
|
@@ -309,7 +319,9 @@ export function createUsageService(deps) {
|
|
|
309
319
|
return SUBSCRIPTION_API_COST_PROVIDERS.has(canonical);
|
|
310
320
|
});
|
|
311
321
|
};
|
|
312
|
-
const
|
|
322
|
+
const shouldRecomputeUsageCache = (cached) => {
|
|
323
|
+
if (!isUsageBillingCurrent(cached))
|
|
324
|
+
return true;
|
|
313
325
|
if (!hasPricing)
|
|
314
326
|
return false;
|
|
315
327
|
if (cached.assistantMessages <= 0)
|
|
@@ -325,7 +337,7 @@ export function createUsageService(deps) {
|
|
|
325
337
|
const needsFetch = [];
|
|
326
338
|
for (const session of sessions) {
|
|
327
339
|
if (session.state.usage) {
|
|
328
|
-
if (
|
|
340
|
+
if (shouldRecomputeUsageCache(session.state.usage)) {
|
|
329
341
|
needsFetch.push(session);
|
|
330
342
|
}
|
|
331
343
|
else {
|
|
@@ -343,35 +355,58 @@ export function createUsageService(deps) {
|
|
|
343
355
|
if (session.state.usage) {
|
|
344
356
|
return {
|
|
345
357
|
sessionID: session.sessionID,
|
|
358
|
+
dateKey: session.dateKey,
|
|
346
359
|
computed: fromCachedSessionUsage(session.state.usage, 1),
|
|
347
360
|
persist: false,
|
|
361
|
+
cursor: session.state.cursor,
|
|
348
362
|
};
|
|
349
363
|
}
|
|
350
364
|
const empty = emptyUsageSummary();
|
|
351
365
|
empty.sessionCount = 1;
|
|
352
366
|
return {
|
|
353
367
|
sessionID: session.sessionID,
|
|
368
|
+
dateKey: session.dateKey,
|
|
354
369
|
computed: empty,
|
|
355
370
|
persist: false,
|
|
371
|
+
cursor: undefined,
|
|
356
372
|
};
|
|
357
373
|
}
|
|
358
|
-
const { usage: computed } = summarizeMessagesIncremental(entries, undefined, undefined, true, {
|
|
374
|
+
const { usage: computed, cursor } = summarizeMessagesIncremental(entries, undefined, undefined, true, {
|
|
359
375
|
calcApiCost: (message) => calcEquivalentApiCost(message, modelCostMap),
|
|
360
376
|
});
|
|
361
|
-
return {
|
|
377
|
+
return {
|
|
378
|
+
sessionID: session.sessionID,
|
|
379
|
+
dateKey: session.dateKey,
|
|
380
|
+
computed,
|
|
381
|
+
persist: true,
|
|
382
|
+
cursor,
|
|
383
|
+
};
|
|
362
384
|
});
|
|
363
385
|
let dirty = false;
|
|
364
|
-
|
|
386
|
+
const diskOnlyUpdates = [];
|
|
387
|
+
for (const { sessionID, dateKey, computed, persist, cursor } of fetched) {
|
|
365
388
|
mergeUsage(usage, { ...computed, sessionCount: 0 });
|
|
366
389
|
const memoryState = deps.state.sessions[sessionID];
|
|
367
390
|
if (persist && memoryState) {
|
|
368
391
|
memoryState.usage = toCachedSessionUsage(computed);
|
|
369
|
-
|
|
392
|
+
memoryState.cursor = cursor;
|
|
393
|
+
const resolvedDateKey = deps.state.sessionDateMap[sessionID] ||
|
|
370
394
|
dateKeyFromTimestamp(memoryState.createdAt);
|
|
371
|
-
deps.state.sessionDateMap[sessionID] =
|
|
372
|
-
deps.persistence.markDirty(
|
|
395
|
+
deps.state.sessionDateMap[sessionID] = resolvedDateKey;
|
|
396
|
+
deps.persistence.markDirty(resolvedDateKey);
|
|
373
397
|
dirty = true;
|
|
374
398
|
}
|
|
399
|
+
else if (persist) {
|
|
400
|
+
diskOnlyUpdates.push({
|
|
401
|
+
sessionID,
|
|
402
|
+
dateKey,
|
|
403
|
+
usage: toCachedSessionUsage(computed),
|
|
404
|
+
cursor,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (diskOnlyUpdates.length > 0) {
|
|
409
|
+
await updateSessionsInDayChunks(deps.statePath, diskOnlyUpdates).catch(swallow('updateSessionsInDayChunks'));
|
|
375
410
|
}
|
|
376
411
|
if (dirty)
|
|
377
412
|
deps.persistence.scheduleSave();
|