@leo000001/opencode-quota-sidebar 2.0.7 → 2.0.9

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/dist/cost.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { AssistantMessage } from '@opencode-ai/sdk';
2
2
  import type { CacheCoverageMode } from './types.js';
3
- export declare const SUBSCRIPTION_API_COST_PROVIDERS: Set<string>;
3
+ export declare const API_COST_ENABLED_PROVIDERS: Set<string>;
4
4
  export declare function canonicalApiCostProviderID(providerID: string): string;
5
5
  export type ModelCostRates = {
6
6
  input: number;
@@ -15,6 +15,7 @@ export type ModelCostRates = {
15
15
  };
16
16
  };
17
17
  export declare function modelCostKey(providerID: string, modelID: string): string;
18
+ export declare function modelCostLookupKeys(providerID: string, modelID: string): string[];
18
19
  export declare function parseModelCostRates(value: unknown): ModelCostRates | undefined;
19
20
  export declare function guessModelCostDivisor(rates: ModelCostRates): 1 | 1000000;
20
21
  export declare function cacheCoverageModeFromRates(rates: ModelCostRates | undefined): CacheCoverageMode;
package/dist/cost.js CHANGED
@@ -1,5 +1,13 @@
1
1
  import { asNumber, isRecord } from './helpers.js';
2
- export const SUBSCRIPTION_API_COST_PROVIDERS = new Set(['openai', 'anthropic']);
2
+ export const API_COST_ENABLED_PROVIDERS = new Set([
3
+ 'openai',
4
+ 'anthropic',
5
+ 'kimi-for-coding',
6
+ ]);
7
+ const MODEL_COST_RATE_ALIASES = {
8
+ 'kimi-for-coding:k2p5': ['moonshotai-cn:kimi-k2.5'],
9
+ 'kimi-for-coding:kimi-k2-thinking': ['moonshotai-cn:kimi-k2-thinking'],
10
+ };
3
11
  function normalizeKnownProviderID(providerID) {
4
12
  if (providerID.startsWith('github-copilot'))
5
13
  return 'github-copilot';
@@ -7,7 +15,7 @@ function normalizeKnownProviderID(providerID) {
7
15
  }
8
16
  export function canonicalApiCostProviderID(providerID) {
9
17
  const normalized = normalizeKnownProviderID(providerID);
10
- if (SUBSCRIPTION_API_COST_PROVIDERS.has(normalized))
18
+ if (API_COST_ENABLED_PROVIDERS.has(normalized))
11
19
  return normalized;
12
20
  const lowered = providerID.toLowerCase();
13
21
  if (lowered.includes('copilot'))
@@ -22,6 +30,24 @@ export function canonicalApiCostProviderID(providerID) {
22
30
  export function modelCostKey(providerID, modelID) {
23
31
  return `${providerID}:${modelID}`;
24
32
  }
33
+ export function modelCostLookupKeys(providerID, modelID) {
34
+ const keys = [];
35
+ const canonicalProviderID = canonicalApiCostProviderID(providerID);
36
+ const push = (key) => {
37
+ if (!keys.includes(key))
38
+ keys.push(key);
39
+ };
40
+ push(modelCostKey(providerID, modelID));
41
+ if (canonicalProviderID !== providerID) {
42
+ push(modelCostKey(canonicalProviderID, modelID));
43
+ }
44
+ for (const key of [...keys]) {
45
+ for (const alias of MODEL_COST_RATE_ALIASES[key] || []) {
46
+ push(alias);
47
+ }
48
+ }
49
+ return keys;
50
+ }
25
51
  export function parseModelCostRates(value) {
26
52
  if (!isRecord(value))
27
53
  return undefined;
package/dist/usage.d.ts CHANGED
@@ -6,7 +6,7 @@ import type { CacheCoverageMetrics, CacheCoverageMode, CacheUsageBuckets, Cached
6
6
  * fields). This is distinct from the plugin *state* version managed by the
7
7
  * persistence layer; billing version only governs usage-cache staleness.
8
8
  */
9
- export declare const USAGE_BILLING_CACHE_VERSION = 4;
9
+ export declare const USAGE_BILLING_CACHE_VERSION = 5;
10
10
  export type ProviderUsage = {
11
11
  providerID: string;
12
12
  input: number;
package/dist/usage.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * fields). This is distinct from the plugin *state* version managed by the
5
5
  * persistence layer; billing version only governs usage-cache staleness.
6
6
  */
7
- export const USAGE_BILLING_CACHE_VERSION = 4;
7
+ export const USAGE_BILLING_CACHE_VERSION = 5;
8
8
  function emptyCacheUsageBucket() {
9
9
  return {
10
10
  input: 0,
@@ -1,8 +1,8 @@
1
1
  import { TtlValueCache } from './cache.js';
2
- import { cacheCoverageModeFromRates, calcEquivalentApiCostForMessage, canonicalApiCostProviderID, modelCostKey, parseModelCostRates, SUBSCRIPTION_API_COST_PROVIDERS, } from './cost.js';
3
- import { dateKeyFromTimestamp, scanAllSessions, updateSessionsInDayChunks, } from './storage.js';
2
+ import { API_COST_ENABLED_PROVIDERS, cacheCoverageModeFromRates, calcEquivalentApiCostForMessage, canonicalApiCostProviderID, modelCostLookupKeys, modelCostKey, parseModelCostRates, } from './cost.js';
3
+ import { deleteSessionFromDayChunk, dateKeyFromTimestamp, scanAllSessions, updateSessionsInDayChunks, } from './storage.js';
4
4
  import { periodStart } from './period.js';
5
- import { debug, isRecord, mapConcurrent, swallow } from './helpers.js';
5
+ import { debug, debugError, isRecord, mapConcurrent, swallow, } from './helpers.js';
6
6
  import { emptyUsageSummary, fromCachedSessionUsage, mergeUsage, summarizeMessagesInCompletedRange, summarizeMessagesIncremental, toCachedSessionUsage, USAGE_BILLING_CACHE_VERSION, } from './usage.js';
7
7
  const READ_ONLY_CACHE_PROVIDERS = new Set([
8
8
  'openai',
@@ -87,10 +87,11 @@ export function createUsageService(deps) {
87
87
  };
88
88
  const calcEquivalentApiCost = (message, modelCostMap) => {
89
89
  const providerID = canonicalApiCostProviderID(message.providerID);
90
- if (!SUBSCRIPTION_API_COST_PROVIDERS.has(providerID))
90
+ if (!API_COST_ENABLED_PROVIDERS.has(providerID))
91
91
  return 0;
92
- const rates = modelCostMap[modelCostKey(message.providerID, message.modelID)] ||
93
- modelCostMap[modelCostKey(providerID, message.modelID)];
92
+ const rates = modelCostLookupKeys(message.providerID, message.modelID)
93
+ .map((key) => modelCostMap[key])
94
+ .find(Boolean);
94
95
  if (!rates) {
95
96
  const key = modelCostKey(providerID, message.modelID);
96
97
  if (!missingApiCostRateKeys.has(key)) {
@@ -103,8 +104,9 @@ export function createUsageService(deps) {
103
104
  };
104
105
  const classifyCacheMode = (message, modelCostMap) => {
105
106
  const canonicalProviderID = canonicalApiCostProviderID(message.providerID);
106
- const baseRates = modelCostMap[modelCostKey(message.providerID, message.modelID)] ||
107
- modelCostMap[modelCostKey(canonicalProviderID, message.modelID)];
107
+ const baseRates = modelCostLookupKeys(message.providerID, message.modelID)
108
+ .map((key) => modelCostMap[key])
109
+ .find(Boolean);
108
110
  const effectiveRates = baseRates &&
109
111
  message.tokens.input + message.tokens.cache.read > 200_000 &&
110
112
  baseRates.contextOver200k
@@ -218,18 +220,81 @@ export function createUsageService(deps) {
218
220
  return undefined;
219
221
  return decoded;
220
222
  };
221
- const loadSessionEntries = async (sessionID) => {
222
- const response = await deps.client.session
223
- .messages({
224
- path: { id: sessionID },
225
- query: { directory: deps.directory },
226
- throwOnError: true,
227
- })
228
- .catch(swallow('loadSessionEntries'));
229
- if (!response)
223
+ const errorStatusCode = (value, seen = new Set()) => {
224
+ if (!isRecord(value) || seen.has(value))
230
225
  return undefined;
231
- const data = response.data;
232
- return decodeMessageEntries(data);
226
+ seen.add(value);
227
+ const status = value.status;
228
+ if (typeof status === 'number' && Number.isFinite(status))
229
+ return status;
230
+ const statusCode = value.statusCode;
231
+ if (typeof statusCode === 'number' && Number.isFinite(statusCode)) {
232
+ return statusCode;
233
+ }
234
+ return (errorStatusCode(value.response, seen) ||
235
+ errorStatusCode(value.cause, seen) ||
236
+ errorStatusCode(value.error, seen));
237
+ };
238
+ const errorText = (value, seen = new Set()) => {
239
+ if (!value || seen.has(value))
240
+ return '';
241
+ if (typeof value === 'string')
242
+ return value;
243
+ if (typeof value === 'number' || typeof value === 'boolean')
244
+ return `${value}`;
245
+ if (value instanceof Error) {
246
+ seen.add(value);
247
+ return [
248
+ value.message,
249
+ errorText(value.cause, seen),
250
+ ]
251
+ .filter(Boolean)
252
+ .join('\n');
253
+ }
254
+ if (!isRecord(value))
255
+ return '';
256
+ seen.add(value);
257
+ return [
258
+ typeof value.message === 'string' ? value.message : '',
259
+ typeof value.error === 'string' ? value.error : '',
260
+ typeof value.detail === 'string' ? value.detail : '',
261
+ typeof value.title === 'string' ? value.title : '',
262
+ errorText(value.response, seen),
263
+ errorText(value.data, seen),
264
+ errorText(value.cause, seen),
265
+ ]
266
+ .filter(Boolean)
267
+ .join('\n');
268
+ };
269
+ const isMissingSessionError = (error) => {
270
+ const status = errorStatusCode(error);
271
+ if (status === 404 || status === 410)
272
+ return true;
273
+ const text = errorText(error).toLowerCase();
274
+ if (!text)
275
+ return false;
276
+ return (/\b(session|conversation)\b.*\b(not found|missing|deleted|does not exist)\b/.test(text) ||
277
+ /\b(not found|missing|deleted|does not exist)\b.*\b(session|conversation)\b/.test(text));
278
+ };
279
+ const loadSessionEntries = async (sessionID) => {
280
+ try {
281
+ const response = await deps.client.session.messages({
282
+ path: { id: sessionID },
283
+ query: { directory: deps.directory },
284
+ throwOnError: true,
285
+ });
286
+ const data = response.data;
287
+ const entries = decodeMessageEntries(data);
288
+ if (!entries)
289
+ return { status: 'error' };
290
+ return { status: 'ok', entries };
291
+ }
292
+ catch (error) {
293
+ debugError(`loadSessionEntries ${sessionID}`, error);
294
+ return {
295
+ status: isMissingSessionError(error) ? 'missing' : 'error',
296
+ };
297
+ }
233
298
  };
234
299
  const persistSessionUsage = (sessionID, usage) => {
235
300
  const sessionState = deps.state.sessions[sessionID];
@@ -246,23 +311,9 @@ export function createUsageService(deps) {
246
311
  return false;
247
312
  return cached.billingVersion === USAGE_BILLING_CACHE_VERSION;
248
313
  };
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
314
  const summarizeSessionUsage = async (sessionID, generationAtStart, options) => {
265
- const entries = await loadSessionEntries(sessionID);
315
+ const load = await loadSessionEntries(sessionID);
316
+ const entries = load.status === 'ok' ? load.entries : undefined;
266
317
  const sessionState = deps.state.sessions[sessionID];
267
318
  // If we can't load messages (transient API failure), fall back to cached
268
319
  // usage if available and avoid mutating cursor/dirty state.
@@ -281,9 +332,7 @@ export function createUsageService(deps) {
281
332
  return { usage: empty, persist: false };
282
333
  }
283
334
  const modelCostMap = await getModelCostMap();
284
- const staleBillingCache = Boolean(sessionState?.usage) &&
285
- (!isUsageBillingCurrent(sessionState?.usage) ||
286
- hasStaleZeroApiCost(sessionState?.usage, modelCostMap));
335
+ const staleBillingCache = Boolean(sessionState?.usage) && !isUsageBillingCurrent(sessionState?.usage);
287
336
  const forceRescan = forceRescanSessions.has(sessionID) || staleBillingCache;
288
337
  if (forceRescan)
289
338
  forceRescanSessions.delete(sessionID);
@@ -406,7 +455,7 @@ export function createUsageService(deps) {
406
455
  return true;
407
456
  return providerIDs.some((providerID) => {
408
457
  const canonical = canonicalApiCostProviderID(providerID);
409
- return SUBSCRIPTION_API_COST_PROVIDERS.has(canonical);
458
+ return API_COST_ENABLED_PROVIDERS.has(canonical);
410
459
  });
411
460
  };
412
461
  const shouldRecomputeUsageCache = (cached) => {
@@ -426,8 +475,8 @@ export function createUsageService(deps) {
426
475
  };
427
476
  if (sessions.length > 0) {
428
477
  const fetched = await mapConcurrent(sessions, RANGE_USAGE_CONCURRENCY, async (session) => {
429
- const entries = await loadSessionEntries(session.sessionID);
430
- if (!entries) {
478
+ const load = await loadSessionEntries(session.sessionID);
479
+ if (load.status !== 'ok') {
431
480
  return {
432
481
  sessionID: session.sessionID,
433
482
  dateKey: session.dateKey,
@@ -436,16 +485,19 @@ export function createUsageService(deps) {
436
485
  dirty: session.state.dirty === true,
437
486
  computed: emptyUsageSummary(),
438
487
  fullUsage: undefined,
439
- loadFailed: true,
488
+ loadFailed: load.status === 'error',
489
+ missing: load.status === 'missing',
440
490
  persist: false,
441
491
  cursor: undefined,
442
492
  };
443
493
  }
494
+ const entries = load.entries;
444
495
  const computed = summarizeMessagesInCompletedRange(entries, startAt, endAt, 0, {
445
496
  calcApiCost: (message) => calcEquivalentApiCost(message, modelCostMap),
446
497
  classifyCacheMode: (message) => classifyCacheMode(message, modelCostMap),
447
498
  });
448
- const shouldPersistFullUsage = !session.state.usage || shouldRecomputeUsageCache(session.state.usage);
499
+ const shouldPersistFullUsage = !session.state.usage ||
500
+ shouldRecomputeUsageCache(session.state.usage);
449
501
  if (!shouldPersistFullUsage) {
450
502
  return {
451
503
  sessionID: session.sessionID,
@@ -456,6 +508,7 @@ export function createUsageService(deps) {
456
508
  computed,
457
509
  fullUsage: undefined,
458
510
  loadFailed: false,
511
+ missing: false,
459
512
  persist: false,
460
513
  cursor: session.state.cursor,
461
514
  };
@@ -473,10 +526,35 @@ export function createUsageService(deps) {
473
526
  computed,
474
527
  fullUsage,
475
528
  loadFailed: false,
529
+ missing: false,
476
530
  persist: true,
477
531
  cursor,
478
532
  };
479
533
  });
534
+ const missingSessions = fetched.filter((item) => item.missing);
535
+ if (missingSessions.length > 0) {
536
+ let stateChanged = false;
537
+ for (const missing of missingSessions) {
538
+ deps.state.deletedSessionDateMap[missing.sessionID] = missing.dateKey;
539
+ delete deps.state.sessions[missing.sessionID];
540
+ delete deps.state.sessionDateMap[missing.sessionID];
541
+ deps.persistence.markDirty(missing.dateKey);
542
+ forgetSession(missing.sessionID);
543
+ stateChanged = true;
544
+ }
545
+ await Promise.all(missingSessions.map(async (missing) => {
546
+ const deletedFromChunk = await deleteSessionFromDayChunk(deps.statePath, missing.sessionID, missing.dateKey).catch((error) => {
547
+ swallow('deleteSessionFromDayChunk')(error);
548
+ return false;
549
+ });
550
+ if (!deletedFromChunk)
551
+ return;
552
+ delete deps.state.deletedSessionDateMap[missing.sessionID];
553
+ stateChanged = true;
554
+ }));
555
+ if (stateChanged)
556
+ deps.persistence.scheduleSave();
557
+ }
480
558
  const failedLoads = fetched.filter((item) => {
481
559
  if (!item.loadFailed)
482
560
  return false;
@@ -493,7 +571,7 @@ export function createUsageService(deps) {
493
571
  }
494
572
  let dirty = false;
495
573
  const diskOnlyUpdates = [];
496
- for (const { sessionID, dateKey, computed, fullUsage, persist, cursor } of fetched) {
574
+ for (const { sessionID, dateKey, computed, fullUsage, persist, cursor, } of fetched) {
497
575
  if (computed.assistantMessages > 0) {
498
576
  computed.sessionCount = 1;
499
577
  mergeUsage(usage, computed);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leo000001/opencode-quota-sidebar",
3
- "version": "2.0.7",
3
+ "version": "2.0.9",
4
4
  "description": "OpenCode plugin that shows quota and token usage in session titles",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",