@leo000001/opencode-quota-sidebar 4.0.5 → 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.
- package/README.md +504 -446
- package/README.zh-CN.md +516 -458
- package/dist/cli.d.ts +38 -0
- package/dist/cli.js +153 -42
- package/dist/cli_render.d.ts +4 -4
- package/dist/cli_render.js +74 -74
- package/dist/cost.d.ts +21 -4
- package/dist/cost.js +493 -264
- package/dist/format.d.ts +5 -5
- package/dist/format.js +288 -287
- package/dist/history_usage.d.ts +15 -9
- package/dist/history_usage.js +28 -22
- package/dist/index.d.ts +3 -3
- package/dist/index.js +35 -34
- package/dist/models_dev_pricing.d.ts +6 -0
- package/dist/models_dev_pricing.js +226 -0
- package/dist/opencode_pricing.d.ts +14 -0
- package/dist/opencode_pricing.js +273 -0
- package/dist/storage.d.ts +3 -3
- package/dist/storage.js +27 -28
- package/dist/storage_parse.d.ts +1 -1
- package/dist/storage_parse.js +51 -45
- package/dist/storage_paths.d.ts +1 -0
- package/dist/storage_paths.js +26 -11
- package/dist/title_apply.d.ts +5 -22
- package/dist/title_apply.js +19 -61
- package/dist/tui.d.ts +1 -1
- package/dist/tui.tsx +481 -471
- package/dist/tui_helpers.d.ts +5 -3
- package/dist/tui_helpers.js +62 -34
- package/dist/types.d.ts +8 -10
- package/dist/usage.d.ts +9 -6
- package/dist/usage.js +27 -21
- package/dist/usage_service.d.ts +8 -7
- package/dist/usage_service.js +261 -150
- package/package.json +1 -1
package/dist/usage_service.js
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
|
-
import { TtlValueCache } from
|
|
2
|
-
import {
|
|
3
|
-
import { deleteSessionFromDayChunk, dateKeyFromTimestamp, scanAllSessions, updateSessionsInDayChunks, } from
|
|
4
|
-
import { periodStart } from
|
|
5
|
-
import { debug, debugError, isRecord, mapConcurrent, swallow, } from
|
|
6
|
-
import { accumulateMessagesInCompletedRange, emptyUsageSummary, fromCachedSessionUsage, mergeCursorFromEntries, mergeUsage, summarizeMessagesIncremental, toCachedSessionUsage, USAGE_BILLING_CACHE_VERSION, } from
|
|
7
|
-
import { decodeMessageEntries, isMissingSessionError, nextCursorFromResponse, } from
|
|
8
|
-
import { computeHistoryUsage } from
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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(
|
|
52
|
-
const
|
|
53
|
-
typeof response ===
|
|
54
|
-
|
|
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
|
-
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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 (
|
|
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 !==
|
|
231
|
+
if (fromRates !== "none")
|
|
121
232
|
return fromRates;
|
|
122
233
|
if (message.tokens.cache.write > 0)
|
|
123
|
-
return
|
|
234
|
+
return "read-write";
|
|
124
235
|
if (message.tokens.cache.read <= 0)
|
|
125
|
-
return
|
|
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
|
|
240
|
+
return "read-only";
|
|
130
241
|
}
|
|
131
242
|
// Heuristic fallback: classify by provider identity when pricing is missing.
|
|
132
|
-
if (canonicalProviderID ===
|
|
133
|
-
message.modelID.toLowerCase().includes(
|
|
134
|
-
return
|
|
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
|
|
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:
|
|
151
|
-
return { status:
|
|
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) ?
|
|
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:
|
|
286
|
+
return { status: "error" };
|
|
176
287
|
return {
|
|
177
|
-
status:
|
|
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) ?
|
|
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.
|
|
313
|
+
if (cached.billingVersion !== USAGE_BILLING_CACHE_VERSION)
|
|
223
314
|
return false;
|
|
224
|
-
|
|
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 !==
|
|
320
|
+
if (info.role !== "assistant")
|
|
233
321
|
return false;
|
|
234
322
|
const providerID = canonicalApiCostProviderID(info.providerID);
|
|
235
|
-
if (
|
|
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,
|
|
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 ===
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
277
|
-
const
|
|
354
|
+
const pricingKeys = [...collectPricingKeys(entries)].sort();
|
|
355
|
+
const pricingFingerprint = pricingFingerprintForEntries(entries, modelCostMap);
|
|
278
356
|
const staleBillingCache = Boolean(sessionState?.usage) &&
|
|
279
|
-
|
|
357
|
+
sessionState?.usage?.billingVersion !== USAGE_BILLING_CACHE_VERSION;
|
|
280
358
|
const pricingRefreshCache = sessionState?.usage &&
|
|
281
|
-
shouldRecomputeUsageCache(sessionState.usage,
|
|
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 {
|
|
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 &&
|
|
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 ===
|
|
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,
|
|
516
|
+
const trackFullUsage = shouldTrackFullUsageForRange(session.state.usage, modelCostMap);
|
|
425
517
|
const fullUsage = trackFullUsage ? emptyUsageSummary() : undefined;
|
|
426
518
|
let cursor;
|
|
427
|
-
|
|
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 !==
|
|
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 ===
|
|
441
|
-
missing: load.status ===
|
|
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,
|
|
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(
|
|
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 ===
|
|
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(
|
|
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(
|
|
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(
|
|
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 ===
|
|
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;
|