@leo000001/opencode-quota-sidebar 4.0.9 → 4.0.11

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,16 +1,19 @@
1
- import { TtlValueCache } from './cache.js';
2
- import { API_COST_ENABLED_PROVIDERS, cacheCoverageModeFromRates, calcEquivalentApiCostForMessage, canonicalApiCostProviderID, canonicalPricingProviderID, getBundledModelCostMap, modelCostLookupKeys, modelCostKey, parseModelCostRates, } from './cost.js';
3
- import { deleteSessionFromDayChunk, dateKeyFromTimestamp, scanAllSessions, updateSessionsInDayChunks, } from './storage.js';
4
- import { periodStart } from './period.js';
5
- import { debug, debugError, isRecord, mapConcurrent, swallow, } from './helpers.js';
6
- import { accumulateMessagesInCompletedRange, emptyUsageSummary, fromCachedSessionUsage, mergeCursorFromEntries, mergeUsage, summarizeMessagesIncremental, toCachedSessionUsage, USAGE_BILLING_CACHE_VERSION, } from './usage.js';
7
- import { decodeMessageEntries, isMissingSessionError, nextCursorFromResponse, } from './history_messages.js';
8
- import { computeHistoryUsage } from './history_usage.js';
1
+ import { TtlValueCache } from "./cache.js";
2
+ import { applyExplicitRatesFromSource, applyDerivedTierRatesFromSource, API_COST_RULES_VERSION, cacheCoverageModeFromRates, calcEquivalentApiCostForMessage, canonicalApiCostProviderID, derivedTierBaseModelID, explicitModelCostMap, getBundledModelCostMap, mergeModelCostSource, modelCostLookupKeys, modelCostKey, } from "./cost.js";
3
+ import { deleteSessionFromDayChunk, dateKeyFromTimestamp, scanAllSessions, updateSessionsInDayChunks, } from "./storage.js";
4
+ import { periodStart } from "./period.js";
5
+ import { debug, debugError, isRecord, mapConcurrent, swallow, } from "./helpers.js";
6
+ import { accumulateMessagesInCompletedRange, emptyUsageSummary, fromCachedSessionUsage, mergeCursorFromEntries, mergeUsage, summarizeMessagesIncremental, toCachedSessionUsage, USAGE_BILLING_CACHE_VERSION, } from "./usage.js";
7
+ import { decodeMessageEntries, isMissingSessionError, nextCursorFromResponse, } from "./history_messages.js";
8
+ import { computeHistoryUsage } from "./history_usage.js";
9
+ import { loadModelsDevPricingModels, modelsDevHasProvider, } from "./models_dev_pricing.js";
10
+ import { loadOpenCodePricingModels, } from "./opencode_pricing.js";
11
+ import { opencodeConfigPaths } from "./storage_paths.js";
9
12
  const READ_ONLY_CACHE_PROVIDERS = new Set([
10
- 'openai',
11
- 'github-copilot',
12
- 'venice',
13
- 'openrouter',
13
+ "openai",
14
+ "github-copilot",
15
+ "venice",
16
+ "openrouter",
14
17
  ]);
15
18
  export function createUsageService(deps) {
16
19
  const forceRescanSessions = new Set();
@@ -33,65 +36,175 @@ export function createUsageService(deps) {
33
36
  // (generation bumps), callers should not reuse a stale in-flight computation.
34
37
  const usageInFlight = new Map();
35
38
  const modelCostCache = new TtlValueCache();
39
+ let lastSuccessfulRuntimePricingLayer;
36
40
  const missingApiCostRateKeys = new Set();
41
+ const collectModelsDevRequests = (models, currentMap) => {
42
+ const requests = new Map();
43
+ const enqueue = (model) => {
44
+ if (!modelsDevHasProvider(model.providerID))
45
+ return;
46
+ requests.set(`${model.providerID}:${model.modelID}`, model);
47
+ };
48
+ const hasRates = (providerID, modelID) => modelCostLookupKeys(providerID, modelID).some((key) => Boolean(currentMap[key]));
49
+ for (const model of models) {
50
+ if (!hasRates(model.providerID, model.modelID))
51
+ enqueue(model);
52
+ const baseModelID = derivedTierBaseModelID(model);
53
+ if (!baseModelID || hasRates(model.providerID, baseModelID))
54
+ continue;
55
+ enqueue({
56
+ providerID: model.providerID,
57
+ modelID: baseModelID,
58
+ });
59
+ }
60
+ return [...requests.values()];
61
+ };
62
+ const runtimePricingModels = (providers) => {
63
+ const models = [];
64
+ for (const provider of providers) {
65
+ if (!isRecord(provider))
66
+ continue;
67
+ const providerID = typeof provider.id === "string" ? provider.id : undefined;
68
+ if (!providerID)
69
+ continue;
70
+ const providerModels = provider.models;
71
+ if (!isRecord(providerModels))
72
+ continue;
73
+ for (const [modelKey, modelValue] of Object.entries(providerModels)) {
74
+ if (!isRecord(modelValue))
75
+ continue;
76
+ const modelID = typeof modelValue.id === "string" ? modelValue.id : modelKey;
77
+ models.push({
78
+ providerID,
79
+ modelID,
80
+ modelKey,
81
+ cost: modelValue.cost,
82
+ options: isRecord(modelValue.options)
83
+ ? modelValue.options
84
+ : undefined,
85
+ headers: isRecord(modelValue.headers)
86
+ ? modelValue.headers
87
+ : undefined,
88
+ api: isRecord(modelValue.api) ? modelValue.api : undefined,
89
+ limit: isRecord(modelValue.limit) ? modelValue.limit : undefined,
90
+ });
91
+ }
92
+ }
93
+ return models;
94
+ };
37
95
  const getModelCostMap = async () => {
38
96
  const cached = modelCostCache.get();
39
97
  if (cached)
40
98
  return cached;
41
99
  const fallbackMap = getBundledModelCostMap();
100
+ const configModels = await loadOpenCodePricingModels(opencodeConfigPaths(deps.worktree || deps.directory, deps.directory));
42
101
  const providerClient = deps.client;
43
102
  if (!providerClient.provider?.list) {
44
- return modelCostCache.set(fallbackMap, 30_000);
103
+ return modelCostCache.set(mergeModelCostSource(fallbackMap, configModels), 30_000);
45
104
  }
46
105
  const response = await providerClient.provider
47
106
  .list({
48
107
  query: { directory: deps.directory },
49
108
  throwOnError: true,
50
109
  })
51
- .catch(swallow('getModelCostMap'));
52
- const all = response &&
53
- typeof response === 'object' &&
54
- 'data' in response &&
110
+ .catch(swallow("getModelCostMap"));
111
+ const hasRuntimeProviderList = response &&
112
+ typeof response === "object" &&
113
+ "data" in response &&
55
114
  isRecord(response.data) &&
56
- Array.isArray(response.data.all)
57
- ? response.data.all
115
+ Array.isArray(response.data.all);
116
+ const responseData = hasRuntimeProviderList &&
117
+ response &&
118
+ typeof response === "object" &&
119
+ "data" in response
120
+ ? response.data
121
+ : undefined;
122
+ const runtimeModels = hasRuntimeProviderList && responseData
123
+ ? runtimePricingModels(responseData.all)
58
124
  : [];
59
- const map = all.reduce((acc, provider) => {
60
- if (!isRecord(provider))
61
- return acc;
62
- const rawProviderID = typeof provider.id === 'string' ? provider.id : undefined;
63
- if (!rawProviderID)
64
- return acc;
65
- const canonicalProviderID = canonicalPricingProviderID(rawProviderID);
66
- const models = provider.models;
67
- if (!isRecord(models))
68
- return acc;
69
- for (const [modelKey, modelValue] of Object.entries(models)) {
70
- if (!isRecord(modelValue))
71
- continue;
72
- const rates = parseModelCostRates(modelValue.cost);
73
- if (!rates)
74
- continue;
75
- const modelID = typeof modelValue.id === 'string' ? modelValue.id : modelKey;
76
- const lookupKeys = new Set([
77
- ...modelCostLookupKeys(rawProviderID, modelID),
78
- ...modelCostLookupKeys(rawProviderID, modelKey),
79
- ]);
80
- if (canonicalProviderID !== rawProviderID) {
81
- lookupKeys.add(modelCostKey(canonicalProviderID, modelID));
82
- lookupKeys.add(modelCostKey(canonicalProviderID, modelKey));
83
- }
84
- for (const key of lookupKeys) {
85
- acc[key] = rates;
125
+ const configAndBundledLayer = mergeModelCostSource(fallbackMap, configModels);
126
+ const runtimeExplicitRates = explicitModelCostMap(runtimeModels);
127
+ const modelsDevModels = await loadModelsDevPricingModels(collectModelsDevRequests(runtimeModels, {
128
+ ...configAndBundledLayer,
129
+ ...runtimeExplicitRates,
130
+ }));
131
+ const modelsDevLayer = mergeModelCostSource({}, modelsDevModels);
132
+ const configExplicitRates = explicitModelCostMap(configModels);
133
+ const runtimeBaseLayer = hasRuntimeProviderList && runtimeModels.length > 0
134
+ ? mergeModelCostSource(lastSuccessfulRuntimePricingLayer || {}, runtimeModels)
135
+ : lastSuccessfulRuntimePricingLayer || {};
136
+ const runtimeLayer = applyDerivedTierRatesFromSource(runtimeBaseLayer, runtimeModels, {
137
+ ...fallbackMap,
138
+ ...modelsDevLayer,
139
+ ...lastSuccessfulRuntimePricingLayer,
140
+ ...runtimeExplicitRates,
141
+ }, { skipExplicitRates: runtimeExplicitRates });
142
+ if (hasRuntimeProviderList) {
143
+ lastSuccessfulRuntimePricingLayer = runtimeLayer;
144
+ }
145
+ let map = {
146
+ ...fallbackMap,
147
+ ...modelsDevLayer,
148
+ ...runtimeLayer,
149
+ };
150
+ map = applyExplicitRatesFromSource(map, runtimeModels, configExplicitRates);
151
+ map = applyDerivedTierRatesFromSource(map, runtimeModels, configExplicitRates, { skipExplicitRates: configExplicitRates });
152
+ const merged = mergeModelCostSource(map, configModels);
153
+ return modelCostCache.set(merged, Math.max(30_000, deps.config.quota.refreshMs));
154
+ };
155
+ const pricingKeyForMessage = (message) => `${message.providerID}:${message.modelID}`;
156
+ const collectPricingKeys = (entries, target = new Set()) => {
157
+ for (const { info } of entries) {
158
+ if (info.role !== "assistant")
159
+ continue;
160
+ target.add(pricingKeyForMessage(info));
161
+ }
162
+ return target;
163
+ };
164
+ const serializeRates = (rates) => rates
165
+ ? {
166
+ input: rates.input,
167
+ output: rates.output,
168
+ cacheRead: rates.cacheRead,
169
+ cacheWrite: rates.cacheWrite,
170
+ contextOver200k: rates.contextOver200k
171
+ ? {
172
+ input: rates.contextOver200k.input,
173
+ output: rates.contextOver200k.output,
174
+ cacheRead: rates.contextOver200k.cacheRead,
175
+ cacheWrite: rates.contextOver200k.cacheWrite,
86
176
  }
87
- }
88
- return acc;
89
- }, fallbackMap);
90
- return modelCostCache.set(map, Math.max(30_000, deps.config.quota.refreshMs));
177
+ : undefined,
178
+ }
179
+ : null;
180
+ const pricingFingerprintForKeys = (pricingKeys, modelCostMap) => {
181
+ const normalized = Array.from(new Set(pricingKeys)).sort();
182
+ return JSON.stringify({
183
+ version: API_COST_RULES_VERSION,
184
+ prices: normalized.map((pricingKey) => {
185
+ const separator = pricingKey.indexOf(":");
186
+ const providerID = separator >= 0 ? pricingKey.slice(0, separator) : pricingKey;
187
+ const modelID = separator >= 0 ? pricingKey.slice(separator + 1) : "";
188
+ const rates = modelCostLookupKeys(providerID, modelID)
189
+ .map((key) => modelCostMap[key])
190
+ .find(Boolean);
191
+ return {
192
+ providerID,
193
+ modelID,
194
+ rates: serializeRates(rates),
195
+ };
196
+ }),
197
+ });
198
+ };
199
+ const pricingFingerprintForEntries = (entries, modelCostMap) => pricingFingerprintForKeys([...collectPricingKeys(entries)].sort(), modelCostMap);
200
+ const expectedPricingFingerprintForCached = (cached, modelCostMap) => {
201
+ if (!cached?.pricingKeys)
202
+ return undefined;
203
+ return pricingFingerprintForKeys(cached.pricingKeys, modelCostMap);
91
204
  };
92
205
  const calcEquivalentApiCost = (message, modelCostMap) => {
93
206
  const providerID = canonicalApiCostProviderID(message.providerID);
94
- if (!API_COST_ENABLED_PROVIDERS.has(providerID))
207
+ if (providerID === "github-copilot")
95
208
  return 0;
96
209
  const rates = modelCostLookupKeys(message.providerID, message.modelID)
97
210
  .map((key) => modelCostMap[key])
@@ -111,31 +224,29 @@ export function createUsageService(deps) {
111
224
  const baseRates = modelCostLookupKeys(message.providerID, message.modelID)
112
225
  .map((key) => modelCostMap[key])
113
226
  .find(Boolean);
114
- const effectiveRates = baseRates &&
115
- message.tokens.input + message.tokens.cache.read > 200_000 &&
116
- baseRates.contextOver200k
227
+ const effectiveRates = baseRates && message.tokens.input > 200_000 && baseRates.contextOver200k
117
228
  ? baseRates.contextOver200k
118
229
  : baseRates;
119
230
  const fromRates = cacheCoverageModeFromRates(effectiveRates);
120
- if (fromRates !== 'none')
231
+ if (fromRates !== "none")
121
232
  return fromRates;
122
233
  if (message.tokens.cache.write > 0)
123
- return 'read-write';
234
+ return "read-write";
124
235
  if (message.tokens.cache.read <= 0)
125
- return 'none';
236
+ return "none";
126
237
  const rawProviderID = message.providerID.toLowerCase();
127
238
  if (READ_ONLY_CACHE_PROVIDERS.has(canonicalProviderID) ||
128
239
  READ_ONLY_CACHE_PROVIDERS.has(rawProviderID)) {
129
- return 'read-only';
240
+ return "read-only";
130
241
  }
131
242
  // Heuristic fallback: classify by provider identity when pricing is missing.
132
- if (canonicalProviderID === 'anthropic' ||
133
- message.modelID.toLowerCase().includes('claude')) {
134
- return 'read-write';
243
+ if (canonicalProviderID === "anthropic" ||
244
+ message.modelID.toLowerCase().includes("claude")) {
245
+ return "read-write";
135
246
  }
136
247
  // Last resort: if the message has cache.read tokens from an unknown provider,
137
248
  // treat it as read-only (the safer default — avoids overstating cached ratio).
138
- return 'read-only';
249
+ return "read-only";
139
250
  };
140
251
  const loadSessionEntries = async (sessionID) => {
141
252
  try {
@@ -147,13 +258,13 @@ export function createUsageService(deps) {
147
258
  const data = response.data;
148
259
  const entries = decodeMessageEntries(data);
149
260
  if (!entries)
150
- return { status: 'error' };
151
- return { status: 'ok', entries };
261
+ return { status: "error" };
262
+ return { status: "ok", entries };
152
263
  }
153
264
  catch (error) {
154
265
  debugError(`loadSessionEntries ${sessionID}`, error);
155
266
  return {
156
- status: isMissingSessionError(error) ? 'missing' : 'error',
267
+ status: isMissingSessionError(error) ? "missing" : "error",
157
268
  };
158
269
  }
159
270
  };
@@ -172,9 +283,9 @@ export function createUsageService(deps) {
172
283
  const data = response.data;
173
284
  const entries = decodeMessageEntries(data);
174
285
  if (!entries)
175
- return { status: 'error' };
286
+ return { status: "error" };
176
287
  return {
177
- status: 'ok',
288
+ status: "ok",
178
289
  entries,
179
290
  nextBefore: nextCursorFromResponse(response),
180
291
  };
@@ -182,7 +293,7 @@ export function createUsageService(deps) {
182
293
  catch (error) {
183
294
  debugError(`loadSessionEntriesPage ${sessionID}`, error);
184
295
  return {
185
- status: isMissingSessionError(error) ? 'missing' : 'error',
296
+ status: isMissingSessionError(error) ? "missing" : "error",
186
297
  };
187
298
  }
188
299
  };
@@ -196,74 +307,41 @@ export function createUsageService(deps) {
196
307
  deps.state.sessionDateMap[sessionID] = dateKey;
197
308
  deps.persistence.markDirty(dateKey);
198
309
  };
199
- const isUsageBillingCurrent = (cached) => {
200
- if (!cached)
201
- return false;
202
- return cached.billingVersion === USAGE_BILLING_CACHE_VERSION;
203
- };
204
- const hasAnySubscriptionProvider = (cached) => {
205
- const providerIDs = Object.keys(cached.providers);
206
- if (providerIDs.length === 0)
207
- return true;
208
- return providerIDs.some((providerID) => {
209
- const canonical = canonicalApiCostProviderID(providerID);
210
- return API_COST_ENABLED_PROVIDERS.has(canonical);
211
- });
212
- };
213
- const shouldRecomputeUsageCache = (cached, hasPricing, hasResolvableApiCostMessage) => {
214
- if (!isUsageBillingCurrent(cached))
215
- return true;
216
- if (!hasPricing)
217
- return false;
218
- if (!hasResolvableApiCostMessage)
219
- return false;
220
- if (cached.assistantMessages <= 0)
310
+ const isUsageBillingCurrent = (cached, pricingFingerprint) => {
311
+ if (!cached || !pricingFingerprint)
221
312
  return false;
222
- if (cached.apiCost > 0)
313
+ if (cached.billingVersion !== USAGE_BILLING_CACHE_VERSION)
223
314
  return false;
224
- if (cached.total <= 0)
225
- return false;
226
- if (!hasAnySubscriptionProvider(cached))
227
- return false;
228
- return true;
315
+ return cached.pricingFingerprint === pricingFingerprint;
229
316
  };
317
+ const shouldRecomputeUsageCache = (cached, pricingFingerprint) => !isUsageBillingCurrent(cached, pricingFingerprint);
230
318
  const hasResolvableApiCostMessages = (entries, modelCostMap) => {
231
319
  return entries.some(({ info }) => {
232
- if (info.role !== 'assistant')
320
+ if (info.role !== "assistant")
233
321
  return false;
234
322
  const providerID = canonicalApiCostProviderID(info.providerID);
235
- if (!API_COST_ENABLED_PROVIDERS.has(providerID))
323
+ if (providerID === "github-copilot")
236
324
  return false;
237
325
  return modelCostLookupKeys(info.providerID, info.modelID).some((key) => Boolean(modelCostMap[key]));
238
326
  });
239
327
  };
240
- const shouldTrackFullUsageForRange = (cached, hasPricing) => {
241
- if (!cached)
242
- return true;
243
- if (!isUsageBillingCurrent(cached))
244
- return true;
245
- if (!hasPricing)
246
- return false;
247
- if (cached.assistantMessages <= 0)
248
- return false;
249
- if (cached.apiCost > 0)
250
- return false;
251
- if (cached.total <= 0)
252
- return false;
253
- return hasAnySubscriptionProvider(cached);
254
- };
328
+ const shouldTrackFullUsageForRange = (cached, modelCostMap) => !isUsageBillingCurrent(cached, expectedPricingFingerprintForCached(cached, modelCostMap));
255
329
  const summarizeSessionUsage = async (sessionID, generationAtStart, options) => {
256
330
  const load = await loadSessionEntries(sessionID);
257
- const entries = load.status === 'ok' ? load.entries : undefined;
331
+ const entries = load.status === "ok" ? load.entries : undefined;
258
332
  const sessionState = deps.state.sessions[sessionID];
259
333
  // If we can't load messages (transient API failure), fall back to cached
260
334
  // usage if available and avoid mutating cursor/dirty state.
261
335
  if (!entries) {
262
- if (sessionState?.usage) {
263
- return {
264
- usage: fromCachedSessionUsage(sessionState.usage, 1),
265
- persist: false,
266
- };
336
+ if (sessionState?.usage && sessionState.dirty !== true) {
337
+ const modelCostMap = await getModelCostMap();
338
+ const cachedPricingFingerprint = expectedPricingFingerprintForCached(sessionState.usage, modelCostMap);
339
+ if (isUsageBillingCurrent(sessionState.usage, cachedPricingFingerprint)) {
340
+ return {
341
+ usage: fromCachedSessionUsage(sessionState.usage, 1),
342
+ persist: false,
343
+ };
344
+ }
267
345
  }
268
346
  if (options?.requireEntries) {
269
347
  throw new Error(`session usage unavailable: failed to load messages for ${sessionID}`);
@@ -273,13 +351,14 @@ export function createUsageService(deps) {
273
351
  return { usage: empty, persist: false };
274
352
  }
275
353
  const modelCostMap = await getModelCostMap();
276
- const hasPricing = Object.keys(modelCostMap).length > 0;
277
- const hasResolvablePricing = hasResolvableApiCostMessages(entries, modelCostMap);
354
+ const pricingKeys = [...collectPricingKeys(entries)].sort();
355
+ const pricingFingerprint = pricingFingerprintForEntries(entries, modelCostMap);
278
356
  const staleBillingCache = Boolean(sessionState?.usage) &&
279
- !isUsageBillingCurrent(sessionState?.usage);
357
+ sessionState?.usage?.billingVersion !== USAGE_BILLING_CACHE_VERSION;
280
358
  const pricingRefreshCache = sessionState?.usage &&
281
- shouldRecomputeUsageCache(sessionState.usage, hasPricing, hasResolvablePricing);
359
+ shouldRecomputeUsageCache(sessionState.usage, pricingFingerprint);
282
360
  const forceRescan = forceRescanSessions.has(sessionID) ||
361
+ sessionState?.dirty === true ||
283
362
  staleBillingCache ||
284
363
  Boolean(pricingRefreshCache);
285
364
  if (forceRescan)
@@ -303,7 +382,12 @@ export function createUsageService(deps) {
303
382
  if ((dirtyGeneration.get(sessionID) || 0) === generationAtStart) {
304
383
  cleanGeneration.set(sessionID, generationAtStart);
305
384
  }
306
- return { usage, persist: true };
385
+ return {
386
+ usage,
387
+ persist: true,
388
+ pricingFingerprint,
389
+ pricingKeys,
390
+ };
307
391
  };
308
392
  const summarizeSessionUsageLocked = async (sessionID, options) => {
309
393
  for (let attempt = 0; attempt < 2; attempt++) {
@@ -334,11 +418,15 @@ export function createUsageService(deps) {
334
418
  return summarizeSessionUsage(sessionID, generationAtStart);
335
419
  };
336
420
  const summarizeSessionUsageForDisplay = async (sessionID, includeChildren) => {
421
+ const modelCostMap = await getModelCostMap();
337
422
  const root = await summarizeSessionUsageLocked(sessionID);
338
423
  const usage = root.usage;
339
424
  let dirty = false;
340
425
  if (root.persist) {
341
- persistSessionUsage(sessionID, toCachedSessionUsage(usage));
426
+ persistSessionUsage(sessionID, toCachedSessionUsage(usage, {
427
+ pricingFingerprint: root.pricingFingerprint,
428
+ pricingKeys: root.pricingKeys,
429
+ }));
342
430
  dirty = true;
343
431
  }
344
432
  if (!includeChildren) {
@@ -361,7 +449,9 @@ export function createUsageService(deps) {
361
449
  const needsFetch = [];
362
450
  for (const childID of descendantIDs) {
363
451
  const cached = deps.state.sessions[childID]?.usage;
364
- if (cached && !isDirty(childID) && isUsageBillingCurrent(cached)) {
452
+ if (cached &&
453
+ !isDirty(childID) &&
454
+ isUsageBillingCurrent(cached, expectedPricingFingerprintForCached(cached, modelCostMap))) {
365
455
  // Keep measured cost aligned with OpenCode session semantics by only
366
456
  // using child sessions for token/API-cost aggregation.
367
457
  mergeUsage(merged, fromCachedSessionUsage(cached, 1), {
@@ -376,7 +466,10 @@ export function createUsageService(deps) {
376
466
  const fetched = await mapConcurrent(needsFetch, deps.config.sidebar.childrenConcurrency, async (childID) => {
377
467
  const child = await summarizeSessionUsageLocked(childID);
378
468
  if (child.persist) {
379
- persistSessionUsage(childID, toCachedSessionUsage(child.usage));
469
+ persistSessionUsage(childID, toCachedSessionUsage(child.usage, {
470
+ pricingFingerprint: child.pricingFingerprint,
471
+ pricingKeys: child.pricingKeys,
472
+ }));
380
473
  dirty = true;
381
474
  }
382
475
  return child.usage;
@@ -397,7 +490,7 @@ export function createUsageService(deps) {
397
490
  if (session.state.dirty === true)
398
491
  return true;
399
492
  const lastMessageTime = session.state.cursor?.lastMessageTime;
400
- if (typeof lastMessageTime === 'number' &&
493
+ if (typeof lastMessageTime === "number" &&
401
494
  Number.isFinite(lastMessageTime) &&
402
495
  lastMessageTime < startAt) {
403
496
  return false;
@@ -413,7 +506,6 @@ export function createUsageService(deps) {
413
506
  const sessions = filterRangeSessions(await scanAllSessions(deps.statePath, deps.state), startAt, endAt);
414
507
  const usage = emptyUsageSummary();
415
508
  const modelCostMap = await getModelCostMap();
416
- const hasPricing = Object.keys(modelCostMap).length > 0;
417
509
  if (sessions.length > 0) {
418
510
  const fetched = await mapConcurrent(sessions, RANGE_USAGE_CONCURRENCY, async (session) => {
419
511
  const usageOptions = {
@@ -421,14 +513,14 @@ export function createUsageService(deps) {
421
513
  classifyCacheMode: (message) => classifyCacheMode(message, modelCostMap),
422
514
  };
423
515
  const computed = emptyUsageSummary();
424
- const trackFullUsage = shouldTrackFullUsageForRange(session.state.usage, hasPricing);
516
+ const trackFullUsage = shouldTrackFullUsageForRange(session.state.usage, modelCostMap);
425
517
  const fullUsage = trackFullUsage ? emptyUsageSummary() : undefined;
426
518
  let cursor;
427
- let hasResolvableApiCostMessage = false;
519
+ const pricingKeys = new Set();
428
520
  let before;
429
521
  while (true) {
430
522
  const load = await loadSessionEntriesPage(session.sessionID, before);
431
- if (load.status !== 'ok') {
523
+ if (load.status !== "ok") {
432
524
  return {
433
525
  sessionID: session.sessionID,
434
526
  dateKey: session.dateKey,
@@ -437,8 +529,8 @@ export function createUsageService(deps) {
437
529
  dirty: session.state.dirty === true,
438
530
  computed: emptyUsageSummary(),
439
531
  fullUsage: undefined,
440
- loadFailed: load.status === 'error',
441
- missing: load.status === 'missing',
532
+ loadFailed: load.status === "error",
533
+ missing: load.status === "missing",
442
534
  persist: false,
443
535
  cursor: undefined,
444
536
  };
@@ -450,9 +542,7 @@ export function createUsageService(deps) {
450
542
  if (fullUsage) {
451
543
  accumulateMessagesInCompletedRange(fullUsage, entries, 0, Number.POSITIVE_INFINITY, usageOptions);
452
544
  cursor = mergeCursorFromEntries(cursor, entries);
453
- }
454
- if (!hasResolvableApiCostMessage) {
455
- hasResolvableApiCostMessage = hasResolvableApiCostMessages(entries, modelCostMap);
545
+ collectPricingKeys(entries, pricingKeys);
456
546
  }
457
547
  if (!load.nextBefore)
458
548
  break;
@@ -460,7 +550,7 @@ export function createUsageService(deps) {
460
550
  }
461
551
  const shouldPersistFullUsage = !!fullUsage &&
462
552
  (!session.state.usage ||
463
- shouldRecomputeUsageCache(session.state.usage, hasPricing, hasResolvableApiCostMessage));
553
+ shouldRecomputeUsageCache(session.state.usage, pricingFingerprintForKeys([...pricingKeys], modelCostMap)));
464
554
  return {
465
555
  sessionID: session.sessionID,
466
556
  dateKey: session.dateKey,
@@ -469,6 +559,10 @@ export function createUsageService(deps) {
469
559
  dirty: session.state.dirty === true,
470
560
  computed,
471
561
  fullUsage: shouldPersistFullUsage ? fullUsage : undefined,
562
+ pricingFingerprint: fullUsage
563
+ ? pricingFingerprintForKeys([...pricingKeys], modelCostMap)
564
+ : undefined,
565
+ pricingKeys: fullUsage ? [...pricingKeys].sort() : undefined,
472
566
  loadFailed: false,
473
567
  missing: false,
474
568
  persist: shouldPersistFullUsage,
@@ -488,7 +582,7 @@ export function createUsageService(deps) {
488
582
  }
489
583
  await Promise.all(missingSessions.map(async (missing) => {
490
584
  const deletedFromChunk = await deleteSessionFromDayChunk(deps.statePath, missing.sessionID, missing.dateKey).catch((error) => {
491
- swallow('deleteSessionFromDayChunk')(error);
585
+ swallow("deleteSessionFromDayChunk")(error);
492
586
  return false;
493
587
  });
494
588
  if (!deletedFromChunk)
@@ -505,7 +599,7 @@ export function createUsageService(deps) {
505
599
  if (item.dirty)
506
600
  return true;
507
601
  const lastMessageTime = item.lastMessageTime;
508
- if (typeof lastMessageTime === 'number' && lastMessageTime < startAt) {
602
+ if (typeof lastMessageTime === "number" && lastMessageTime < startAt) {
509
603
  return false;
510
604
  }
511
605
  return true;
@@ -515,14 +609,17 @@ export function createUsageService(deps) {
515
609
  }
516
610
  let dirty = false;
517
611
  const diskOnlyUpdates = [];
518
- for (const { sessionID, dateKey, computed, fullUsage, persist, cursor, } of fetched) {
612
+ for (const { sessionID, dateKey, computed, fullUsage, pricingFingerprint, pricingKeys, persist, cursor, } of fetched) {
519
613
  if (computed.assistantMessages > 0) {
520
614
  computed.sessionCount = 1;
521
615
  mergeUsage(usage, computed);
522
616
  }
523
617
  const memoryState = deps.state.sessions[sessionID];
524
618
  if (persist && fullUsage && memoryState) {
525
- memoryState.usage = toCachedSessionUsage(fullUsage);
619
+ memoryState.usage = toCachedSessionUsage(fullUsage, {
620
+ pricingFingerprint,
621
+ pricingKeys,
622
+ });
526
623
  memoryState.cursor = cursor;
527
624
  const resolvedDateKey = deps.state.sessionDateMap[sessionID] ||
528
625
  dateKeyFromTimestamp(memoryState.createdAt);
@@ -535,14 +632,17 @@ export function createUsageService(deps) {
535
632
  diskOnlyUpdates.push({
536
633
  sessionID,
537
634
  dateKey,
538
- usage: toCachedSessionUsage(fullUsage),
635
+ usage: toCachedSessionUsage(fullUsage, {
636
+ pricingFingerprint,
637
+ pricingKeys,
638
+ }),
539
639
  cursor,
540
640
  });
541
641
  }
542
642
  }
543
643
  if (diskOnlyUpdates.length > 0) {
544
644
  const persisted = await updateSessionsInDayChunks(deps.statePath, diskOnlyUpdates).catch((error) => {
545
- swallow('updateSessionsInDayChunks')(error);
645
+ swallow("updateSessionsInDayChunks")(error);
546
646
  return false;
547
647
  });
548
648
  if (!persisted) {
@@ -564,6 +664,8 @@ export function createUsageService(deps) {
564
664
  calcApiCost: (message, costMap) => calcEquivalentApiCost(message, costMap),
565
665
  classifyCacheMode: (message, costMap) => classifyCacheMode(message, costMap),
566
666
  hasResolvableApiCostMessages: (entries, costMap) => hasResolvableApiCostMessages(entries, costMap),
667
+ pricingFingerprintForKeys: (pricingKeys, costMap) => pricingFingerprintForKeys(pricingKeys, costMap),
668
+ isUsageBillingCurrent: (cached, costMap) => isUsageBillingCurrent(cached, expectedPricingFingerprintForCached(cached, costMap)),
567
669
  shouldTrackFullUsage: shouldTrackFullUsageForRange,
568
670
  shouldRecomputeUsageCache,
569
671
  throwOnLoadFailure: true,
@@ -585,7 +687,7 @@ export function createUsageService(deps) {
585
687
  }
586
688
  await Promise.all(missingSessions.map(async (missing) => {
587
689
  const deletedFromChunk = await deleteSessionFromDayChunk(deps.statePath, missing.sessionID, missing.dateKey).catch((error) => {
588
- swallow('deleteSessionFromDayChunk')(error);
690
+ swallow("deleteSessionFromDayChunk")(error);
589
691
  return false;
590
692
  });
591
693
  if (!deletedFromChunk)
@@ -603,7 +705,10 @@ export function createUsageService(deps) {
603
705
  continue;
604
706
  const memoryState = deps.state.sessions[item.sessionID];
605
707
  if (memoryState) {
606
- memoryState.usage = toCachedSessionUsage(item.fullUsage);
708
+ memoryState.usage = toCachedSessionUsage(item.fullUsage, {
709
+ pricingFingerprint: item.pricingFingerprint,
710
+ pricingKeys: item.pricingKeys,
711
+ });
607
712
  memoryState.cursor = item.cursor;
608
713
  const resolvedDateKey = deps.state.sessionDateMap[item.sessionID] ||
609
714
  dateKeyFromTimestamp(memoryState.createdAt);
@@ -616,14 +721,17 @@ export function createUsageService(deps) {
616
721
  diskOnlyUpdates.push({
617
722
  sessionID: item.sessionID,
618
723
  dateKey: item.dateKey,
619
- usage: toCachedSessionUsage(item.fullUsage),
724
+ usage: toCachedSessionUsage(item.fullUsage, {
725
+ pricingFingerprint: item.pricingFingerprint,
726
+ pricingKeys: item.pricingKeys,
727
+ }),
620
728
  cursor: item.cursor,
621
729
  });
622
730
  }
623
731
  }
624
732
  if (diskOnlyUpdates.length > 0) {
625
733
  const persisted = await updateSessionsInDayChunks(deps.statePath, diskOnlyUpdates).catch((error) => {
626
- swallow('updateSessionsInDayChunks')(error);
734
+ swallow("updateSessionsInDayChunks")(error);
627
735
  return false;
628
736
  });
629
737
  if (!persisted) {
@@ -636,13 +744,16 @@ export function createUsageService(deps) {
636
744
  return result;
637
745
  };
638
746
  const summarizeForTool = async (period, sessionID, includeChildren) => {
639
- if (period === 'session') {
747
+ if (period === "session") {
640
748
  if (!includeChildren) {
641
749
  const session = await summarizeSessionUsageLocked(sessionID, {
642
750
  requireEntries: true,
643
751
  });
644
752
  if (session.persist) {
645
- persistSessionUsage(sessionID, toCachedSessionUsage(session.usage));
753
+ persistSessionUsage(sessionID, toCachedSessionUsage(session.usage, {
754
+ pricingFingerprint: session.pricingFingerprint,
755
+ pricingKeys: session.pricingKeys,
756
+ }));
646
757
  deps.persistence.scheduleSave();
647
758
  }
648
759
  return session.usage;