@leo000001/opencode-quota-sidebar 2.0.0 → 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.
@@ -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, scanSessionsByCreatedRange, updateSessionsInDayChunks, } from './storage.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
  };
@@ -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 (canonicalProviderID === 'openai')
117
- return 'read-only';
118
- return 'none';
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;
@@ -222,6 +245,21 @@ export function createUsageService(deps) {
222
245
  return false;
223
246
  return cached.billingVersion === USAGE_BILLING_CACHE_VERSION;
224
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
+ };
225
263
  const summarizeSessionUsage = async (sessionID, generationAtStart) => {
226
264
  const entries = await loadSessionEntries(sessionID);
227
265
  const sessionState = deps.state.sessions[sessionID];
@@ -240,7 +278,8 @@ export function createUsageService(deps) {
240
278
  }
241
279
  const modelCostMap = await getModelCostMap();
242
280
  const staleBillingCache = Boolean(sessionState?.usage) &&
243
- !isUsageBillingCurrent(sessionState?.usage);
281
+ (!isUsageBillingCurrent(sessionState?.usage) ||
282
+ hasStaleZeroApiCost(sessionState?.usage, modelCostMap));
244
283
  const forceRescan = forceRescanSessions.has(sessionID) || staleBillingCache;
245
284
  if (forceRescan)
246
285
  forceRescanSessions.delete(sessionID);
@@ -255,6 +294,7 @@ export function createUsageService(deps) {
255
294
  // Update cursor in state
256
295
  if (sessionState) {
257
296
  sessionState.cursor = cursor;
297
+ sessionState.dirty = false;
258
298
  }
259
299
  if ((dirtyGeneration.get(sessionID) || 0) === generationAtStart) {
260
300
  cleanGeneration.set(sessionID, generationAtStart);
@@ -290,11 +330,16 @@ export function createUsageService(deps) {
290
330
  const summarizeSessionUsageForDisplay = async (sessionID, includeChildren) => {
291
331
  const root = await summarizeSessionUsageLocked(sessionID);
292
332
  const usage = root.usage;
333
+ let dirty = false;
293
334
  if (root.persist) {
294
335
  persistSessionUsage(sessionID, toCachedSessionUsage(usage));
336
+ dirty = true;
295
337
  }
296
- if (!includeChildren)
338
+ if (!includeChildren) {
339
+ if (dirty)
340
+ deps.persistence.scheduleSave();
297
341
  return usage;
342
+ }
298
343
  const descendantIDs = await deps.descendantsResolver.listDescendantSessionIDs(sessionID, {
299
344
  maxDepth: deps.config.sidebar.childrenMaxDepth,
300
345
  maxSessions: deps.config.sidebar.childrenMaxSessions,
@@ -323,6 +368,7 @@ export function createUsageService(deps) {
323
368
  const child = await summarizeSessionUsageLocked(childID);
324
369
  if (child.persist) {
325
370
  persistSessionUsage(childID, toCachedSessionUsage(child.usage));
371
+ dirty = true;
326
372
  }
327
373
  return child.usage;
328
374
  });
@@ -330,15 +376,17 @@ export function createUsageService(deps) {
330
376
  mergeUsage(merged, childUsage, { includeCost: false });
331
377
  }
332
378
  }
379
+ if (dirty)
380
+ deps.persistence.scheduleSave();
333
381
  return merged;
334
382
  };
335
383
  const RANGE_USAGE_CONCURRENCY = 5;
336
384
  const summarizeRangeUsage = async (period) => {
337
385
  const startAt = periodStart(period);
386
+ const endAt = Date.now();
338
387
  await deps.persistence.flushSave();
339
- const sessions = await scanSessionsByCreatedRange(deps.statePath, startAt, Date.now(), deps.state);
388
+ const sessions = await scanAllSessions(deps.statePath, deps.state);
340
389
  const usage = emptyUsageSummary();
341
- usage.sessionCount = sessions.length;
342
390
  const modelCostMap = await getModelCostMap();
343
391
  const hasPricing = Object.keys(modelCostMap).length > 0;
344
392
  const hasAnySubscriptionProvider = (cached) => {
@@ -367,62 +415,78 @@ export function createUsageService(deps) {
367
415
  return false;
368
416
  return true;
369
417
  };
370
- const needsFetch = [];
371
- for (const session of sessions) {
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) => {
418
+ if (sessions.length > 0) {
419
+ const fetched = await mapConcurrent(sessions, RANGE_USAGE_CONCURRENCY, async (session) => {
386
420
  const entries = await loadSessionEntries(session.sessionID);
387
421
  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
422
  return {
400
423
  sessionID: session.sessionID,
401
424
  dateKey: session.dateKey,
402
- computed: empty,
425
+ createdAt: session.state.createdAt,
426
+ lastMessageTime: session.state.cursor?.lastMessageTime,
427
+ computed: emptyUsageSummary(),
428
+ fullUsage: undefined,
429
+ loadFailed: true,
403
430
  persist: false,
404
431
  cursor: undefined,
405
432
  };
406
433
  }
407
- const { usage: computed, cursor } = summarizeMessagesIncremental(entries, undefined, undefined, true, {
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, {
408
453
  calcApiCost: (message) => calcEquivalentApiCost(message, modelCostMap),
409
454
  classifyCacheMode: (message) => classifyCacheMode(message, modelCostMap),
410
455
  });
411
456
  return {
412
457
  sessionID: session.sessionID,
413
458
  dateKey: session.dateKey,
459
+ createdAt: session.state.createdAt,
460
+ lastMessageTime: cursor.lastMessageTime,
414
461
  computed,
462
+ fullUsage,
463
+ loadFailed: false,
415
464
  persist: true,
416
465
  cursor,
417
466
  };
418
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
+ }
419
480
  let dirty = false;
420
481
  const diskOnlyUpdates = [];
421
- for (const { sessionID, dateKey, computed, persist, cursor } of fetched) {
422
- mergeUsage(usage, { ...computed, sessionCount: 0 });
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
+ }
423
487
  const memoryState = deps.state.sessions[sessionID];
424
- if (persist && memoryState) {
425
- memoryState.usage = toCachedSessionUsage(computed);
488
+ if (persist && fullUsage && memoryState) {
489
+ memoryState.usage = toCachedSessionUsage(fullUsage);
426
490
  memoryState.cursor = cursor;
427
491
  const resolvedDateKey = deps.state.sessionDateMap[sessionID] ||
428
492
  dateKeyFromTimestamp(memoryState.createdAt);
@@ -430,11 +494,11 @@ export function createUsageService(deps) {
430
494
  deps.persistence.markDirty(resolvedDateKey);
431
495
  dirty = true;
432
496
  }
433
- else if (persist) {
497
+ else if (persist && fullUsage) {
434
498
  diskOnlyUpdates.push({
435
499
  sessionID,
436
500
  dateKey,
437
- usage: toCachedSessionUsage(computed),
501
+ usage: toCachedSessionUsage(fullUsage),
438
502
  cursor,
439
503
  });
440
504
  }
@@ -455,6 +519,15 @@ export function createUsageService(deps) {
455
519
  };
456
520
  const markSessionDirty = (sessionID) => {
457
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
+ }
458
531
  };
459
532
  const markForceRescan = (sessionID) => {
460
533
  forceRescanSessions.add(sessionID);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leo000001/opencode-quota-sidebar",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "OpenCode plugin that shows quota and token usage in session titles",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",