taskchef 7.19.0 → 7.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +37 -10
- package/docs/images/ccusage-token-consumption.png +0 -0
- package/docs/spec.md +62 -13
- package/docs/workflows.md +13 -10
- package/package.json +2 -1
- package/src/dashboard/app.js +97 -16
- package/src/dashboard/index.html +4 -0
- package/src/dashboard/styles.css +7 -0
- package/src/dashboard-manager.js +10 -3
- package/src/dashboard.js +18 -1
- package/src/mcp.js +17 -4
- package/src/usage-tracker.js +425 -0
- package/src/usage.js +388 -0
- package/src/version.js +1 -1
package/src/usage.js
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { execFile as execFileCallback } from "node:child_process";
|
|
2
|
+
import { lstat, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
|
|
7
|
+
const execFile = promisify(execFileCallback);
|
|
8
|
+
const USAGE_FILE_NAME = ".taskchef-usage.json";
|
|
9
|
+
const UUID_PATTERN = /[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi;
|
|
10
|
+
const TOKEN_FIELDS = [
|
|
11
|
+
"inputTokens",
|
|
12
|
+
"cachedInputTokens",
|
|
13
|
+
"outputTokens",
|
|
14
|
+
"reasoningOutputTokens",
|
|
15
|
+
"totalTokens",
|
|
16
|
+
];
|
|
17
|
+
const USAGE_STATUSES = new Set(["calculating", "available", "unavailable"]);
|
|
18
|
+
const MAX_USAGE_FILE_BYTES = 16 * 1024 * 1024;
|
|
19
|
+
const USAGE_WRITE_BUDGET_BYTES = 8 * 1024 * 1024;
|
|
20
|
+
const MAX_PERSISTED_TASKS = 1_000;
|
|
21
|
+
const MAX_PERSISTED_TURNS = 250;
|
|
22
|
+
|
|
23
|
+
function nonNegativeNumber(value, name) {
|
|
24
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
25
|
+
throw new Error(`${name} must be a non-negative number`);
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function matchingSession(session, threadId) {
|
|
31
|
+
const identity = `${session.sessionId ?? ""} ${session.sessionFile ?? ""}`;
|
|
32
|
+
const [primaryThreadId] = identity.match(UUID_PATTERN) ?? [];
|
|
33
|
+
return primaryThreadId?.toLowerCase() === threadId;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizeModelUsage(models, name) {
|
|
37
|
+
if (!models || typeof models !== "object" || Array.isArray(models)) {
|
|
38
|
+
throw new Error(`${name}.models must be an object`);
|
|
39
|
+
}
|
|
40
|
+
return Object.fromEntries(Object.entries(models).map(([model, usage]) => {
|
|
41
|
+
if (model.length === 0 || model.length > 256) throw new Error(`${name}.models has an invalid model name`);
|
|
42
|
+
return [model, {
|
|
43
|
+
inputTokens: nonNegativeNumber(usage.inputTokens, `${name}.models.${model}.inputTokens`),
|
|
44
|
+
cachedInputTokens: nonNegativeNumber(usage.cacheReadTokens, `${name}.models.${model}.cacheReadTokens`),
|
|
45
|
+
outputTokens: nonNegativeNumber(usage.outputTokens, `${name}.models.${model}.outputTokens`),
|
|
46
|
+
reasoningOutputTokens: nonNegativeNumber(
|
|
47
|
+
usage.reasoningOutputTokens,
|
|
48
|
+
`${name}.models.${model}.reasoningOutputTokens`,
|
|
49
|
+
),
|
|
50
|
+
totalTokens: nonNegativeNumber(usage.totalTokens, `${name}.models.${model}.totalTokens`),
|
|
51
|
+
}];
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function addModelUsage(target, source) {
|
|
56
|
+
for (const [model, usage] of Object.entries(source)) {
|
|
57
|
+
if (!Object.hasOwn(target, model)) {
|
|
58
|
+
target[model] = Object.fromEntries(TOKEN_FIELDS.map((field) => [field, 0]));
|
|
59
|
+
}
|
|
60
|
+
for (const field of TOKEN_FIELDS) target[model][field] += usage[field];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function timestampOrNull(value, name) {
|
|
65
|
+
if (value === null) return null;
|
|
66
|
+
if (typeof value !== "string" || Number.isNaN(Date.parse(value))) {
|
|
67
|
+
throw new Error(`${name} must be an ISO timestamp or null`);
|
|
68
|
+
}
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function normalizeProvenance(value, name, { includeSessionCount = false } = {}) {
|
|
73
|
+
if (!value || value.provider !== "ccusage") {
|
|
74
|
+
throw new Error(`${name}.provenance is invalid`);
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
provider: "ccusage",
|
|
78
|
+
version: typeof value.version === "string" ? value.version.slice(0, 64) : null,
|
|
79
|
+
...(includeSessionCount ? {
|
|
80
|
+
sessionCount: nonNegativeNumber(value.sessionCount, `${name}.provenance.sessionCount`),
|
|
81
|
+
} : {}),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalizeStoredSnapshot(value, name) {
|
|
86
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
87
|
+
throw new Error(`${name} must be an object`);
|
|
88
|
+
}
|
|
89
|
+
const snapshot = Object.fromEntries(TOKEN_FIELDS.map((field) => [
|
|
90
|
+
field,
|
|
91
|
+
nonNegativeNumber(value[field], `${name}.${field}`),
|
|
92
|
+
]));
|
|
93
|
+
if (value.estimatedCostUsd !== null) {
|
|
94
|
+
snapshot.estimatedCostUsd = nonNegativeNumber(value.estimatedCostUsd, `${name}.estimatedCostUsd`);
|
|
95
|
+
} else {
|
|
96
|
+
snapshot.estimatedCostUsd = null;
|
|
97
|
+
}
|
|
98
|
+
snapshot.costStatus = value.costStatus === "estimated" ? "estimated" : "unavailable";
|
|
99
|
+
const models = normalizeModelUsage(Object.fromEntries(Object.entries(value.models ?? {}).map(
|
|
100
|
+
([model, usage]) => [model, {
|
|
101
|
+
...usage,
|
|
102
|
+
cacheReadTokens: usage.cachedInputTokens,
|
|
103
|
+
}],
|
|
104
|
+
)), name);
|
|
105
|
+
if (Object.keys(models).length > 64 || Object.keys(models).some((model) => model.length > 256)) {
|
|
106
|
+
throw new Error(`${name}.models exceeds the usage cache limit`);
|
|
107
|
+
}
|
|
108
|
+
snapshot.models = models;
|
|
109
|
+
snapshot.provenance = normalizeProvenance(value.provenance, name, { includeSessionCount: true });
|
|
110
|
+
snapshot.sampledAt = timestampOrNull(value.sampledAt, `${name}.sampledAt`);
|
|
111
|
+
snapshot.sourceUpdatedAt = timestampOrNull(value.sourceUpdatedAt, `${name}.sourceUpdatedAt`);
|
|
112
|
+
return snapshot;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function normalizeStoredTurn(value, name) {
|
|
116
|
+
if (!value || typeof value !== "object" || !USAGE_STATUSES.has(value.status)) {
|
|
117
|
+
throw new Error(`${name} has an invalid status`);
|
|
118
|
+
}
|
|
119
|
+
const updatedAt = timestampOrNull(value.updatedAt, `${name}.updatedAt`);
|
|
120
|
+
if (value.status === "available") {
|
|
121
|
+
const normalized = Object.fromEntries(TOKEN_FIELDS.map((field) => [
|
|
122
|
+
field,
|
|
123
|
+
nonNegativeNumber(value[field], `${name}.${field}`),
|
|
124
|
+
]));
|
|
125
|
+
normalized.estimatedCostUsd = value.estimatedCostUsd === null
|
|
126
|
+
? null
|
|
127
|
+
: nonNegativeNumber(value.estimatedCostUsd, `${name}.estimatedCostUsd`);
|
|
128
|
+
return {
|
|
129
|
+
status: "available",
|
|
130
|
+
...normalized,
|
|
131
|
+
costStatus: normalized.estimatedCostUsd === null ? "unavailable" : "estimated",
|
|
132
|
+
provenance: normalizeProvenance(value.provenance, name),
|
|
133
|
+
sampledAt: timestampOrNull(value.sampledAt, `${name}.sampledAt`),
|
|
134
|
+
sourceUpdatedAt: timestampOrNull(value.sourceUpdatedAt, `${name}.sourceUpdatedAt`),
|
|
135
|
+
updatedAt,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
status: value.status,
|
|
140
|
+
...(value.status === "unavailable" ? {
|
|
141
|
+
reason: typeof value.reason === "string" ? value.reason.slice(0, 256) : "Usage is unavailable.",
|
|
142
|
+
} : {}),
|
|
143
|
+
updatedAt,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function normalizeStoredRecord(value, name) {
|
|
148
|
+
if (!value || typeof value !== "object" || !USAGE_STATUSES.has(value.status)) {
|
|
149
|
+
throw new Error(`${name} is invalid`);
|
|
150
|
+
}
|
|
151
|
+
const threadId = String(value.threadId ?? "").toLowerCase();
|
|
152
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(threadId)) {
|
|
153
|
+
throw new Error(`${name}.threadId is invalid`);
|
|
154
|
+
}
|
|
155
|
+
const generationTurnRef = value.generationTurnRef ?? null;
|
|
156
|
+
if (generationTurnRef !== null
|
|
157
|
+
&& (typeof generationTurnRef !== "string" || generationTurnRef.length === 0 || generationTurnRef.length > 256)) {
|
|
158
|
+
throw new Error(`${name}.generationTurnRef is invalid`);
|
|
159
|
+
}
|
|
160
|
+
const zeroBaselineTurnRef = value.zeroBaselineTurnRef ?? null;
|
|
161
|
+
if (zeroBaselineTurnRef !== null
|
|
162
|
+
&& (typeof zeroBaselineTurnRef !== "string" || zeroBaselineTurnRef.length === 0 || zeroBaselineTurnRef.length > 256)) {
|
|
163
|
+
throw new Error(`${name}.zeroBaselineTurnRef is invalid`);
|
|
164
|
+
}
|
|
165
|
+
const generationTurnCount = nonNegativeNumber(
|
|
166
|
+
value.generationTurnCount ?? 0,
|
|
167
|
+
`${name}.generationTurnCount`,
|
|
168
|
+
);
|
|
169
|
+
const generationTerminal = value.generationTerminal === true;
|
|
170
|
+
const normalizeMap = (entries, normalizer, mapName) => {
|
|
171
|
+
if (!entries || typeof entries !== "object" || Array.isArray(entries)) {
|
|
172
|
+
throw new Error(`${mapName} must be an object`);
|
|
173
|
+
}
|
|
174
|
+
const pairs = Object.entries(entries);
|
|
175
|
+
if (pairs.length > 10_000) throw new Error(`${mapName} exceeds the usage cache limit`);
|
|
176
|
+
return Object.fromEntries(pairs.map(([key, item]) => [key, normalizer(item, `${mapName}.${key}`)]));
|
|
177
|
+
};
|
|
178
|
+
return {
|
|
179
|
+
threadId,
|
|
180
|
+
generationTurnRef,
|
|
181
|
+
generationTurnCount,
|
|
182
|
+
generationTerminal,
|
|
183
|
+
zeroBaselineTurnRef,
|
|
184
|
+
status: value.status,
|
|
185
|
+
updatedAt: timestampOrNull(value.updatedAt, `${name}.updatedAt`),
|
|
186
|
+
retryAfter: timestampOrNull(value.retryAfter ?? null, `${name}.retryAfter`),
|
|
187
|
+
task: value.task === null ? null : normalizeStoredSnapshot(value.task, `${name}.task`),
|
|
188
|
+
turns: normalizeMap(value.turns, normalizeStoredTurn, `${name}.turns`),
|
|
189
|
+
boundaries: normalizeMap(value.boundaries, normalizeStoredSnapshot, `${name}.boundaries`),
|
|
190
|
+
...(value.status === "unavailable" ? {
|
|
191
|
+
reason: typeof value.reason === "string" ? value.reason.slice(0, 256) : "Usage is unavailable.",
|
|
192
|
+
} : {}),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function aggregateCcusageSessions(payload, threadId, {
|
|
197
|
+
sampledAt = new Date().toISOString(),
|
|
198
|
+
version = null,
|
|
199
|
+
} = {}) {
|
|
200
|
+
if (!payload || typeof payload !== "object" || !Array.isArray(payload.sessions)) {
|
|
201
|
+
throw new Error("ccusage output must contain a sessions array");
|
|
202
|
+
}
|
|
203
|
+
const normalizedThreadId = String(threadId).toLowerCase();
|
|
204
|
+
const sessions = payload.sessions.filter((session) => matchingSession(session, normalizedThreadId));
|
|
205
|
+
if (sessions.length === 0) throw new Error("ccusage could not resolve this Codex thread");
|
|
206
|
+
|
|
207
|
+
const usage = Object.fromEntries(TOKEN_FIELDS.map((field) => [field, 0]));
|
|
208
|
+
const models = Object.create(null);
|
|
209
|
+
let estimatedCostUsd = 0;
|
|
210
|
+
let hasUnpricedUsage = false;
|
|
211
|
+
let sourceUpdatedAt = null;
|
|
212
|
+
for (const [index, session] of sessions.entries()) {
|
|
213
|
+
const name = `ccusage.sessions[${index}]`;
|
|
214
|
+
usage.inputTokens += nonNegativeNumber(session.inputTokens, `${name}.inputTokens`);
|
|
215
|
+
usage.cachedInputTokens += nonNegativeNumber(session.cacheReadTokens, `${name}.cacheReadTokens`);
|
|
216
|
+
usage.outputTokens += nonNegativeNumber(session.outputTokens, `${name}.outputTokens`);
|
|
217
|
+
usage.reasoningOutputTokens += nonNegativeNumber(
|
|
218
|
+
session.reasoningOutputTokens,
|
|
219
|
+
`${name}.reasoningOutputTokens`,
|
|
220
|
+
);
|
|
221
|
+
const sessionTotalTokens = nonNegativeNumber(session.totalTokens, `${name}.totalTokens`);
|
|
222
|
+
usage.totalTokens += sessionTotalTokens;
|
|
223
|
+
const sessionCost = session.costUSD === undefined || session.costUSD === null
|
|
224
|
+
? null
|
|
225
|
+
: nonNegativeNumber(session.costUSD, `${name}.costUSD`);
|
|
226
|
+
if (sessionCost !== null) estimatedCostUsd += sessionCost;
|
|
227
|
+
if (sessionTotalTokens > 0 && (sessionCost === null || sessionCost === 0)) hasUnpricedUsage = true;
|
|
228
|
+
addModelUsage(models, normalizeModelUsage(session.models, name));
|
|
229
|
+
if (typeof session.lastActivity === "string" && !Number.isNaN(Date.parse(session.lastActivity))) {
|
|
230
|
+
if (sourceUpdatedAt === null || Date.parse(session.lastActivity) > Date.parse(sourceUpdatedAt)) {
|
|
231
|
+
sourceUpdatedAt = session.lastActivity;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
...usage,
|
|
238
|
+
estimatedCostUsd: hasUnpricedUsage ? null : estimatedCostUsd,
|
|
239
|
+
costStatus: hasUnpricedUsage ? "unavailable" : "estimated",
|
|
240
|
+
models,
|
|
241
|
+
provenance: {
|
|
242
|
+
provider: "ccusage",
|
|
243
|
+
version,
|
|
244
|
+
sessionCount: sessions.length,
|
|
245
|
+
},
|
|
246
|
+
sampledAt,
|
|
247
|
+
sourceUpdatedAt,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export async function readCcusageThreadUsage(threadId, {
|
|
252
|
+
command = "ccusage",
|
|
253
|
+
run = execFile,
|
|
254
|
+
sampledAt = new Date().toISOString(),
|
|
255
|
+
timeoutMs = 8_000,
|
|
256
|
+
} = {}) {
|
|
257
|
+
let version = null;
|
|
258
|
+
try {
|
|
259
|
+
const result = await run(command, ["--version"], {
|
|
260
|
+
timeout: Math.min(timeoutMs, 2_000),
|
|
261
|
+
maxBuffer: 64 * 1024,
|
|
262
|
+
});
|
|
263
|
+
version = String(result.stdout).trim().replace(/^ccusage\s+/i, "") || null;
|
|
264
|
+
} catch {
|
|
265
|
+
// Usage remains useful if an older compatible ccusage cannot print its version.
|
|
266
|
+
}
|
|
267
|
+
let result;
|
|
268
|
+
try {
|
|
269
|
+
result = await run(command, ["codex", "session", "--json", "--offline"], {
|
|
270
|
+
timeout: timeoutMs,
|
|
271
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
272
|
+
env: { ...process.env, NO_COLOR: "1" },
|
|
273
|
+
});
|
|
274
|
+
} catch (error) {
|
|
275
|
+
if (error?.code === "ENOENT") throw new Error("ccusage is not installed");
|
|
276
|
+
if (error?.killed || error?.code === "ETIMEDOUT") throw new Error("ccusage timed out");
|
|
277
|
+
throw new Error("ccusage could not read Codex usage");
|
|
278
|
+
}
|
|
279
|
+
let payload;
|
|
280
|
+
try {
|
|
281
|
+
payload = JSON.parse(result.stdout);
|
|
282
|
+
} catch {
|
|
283
|
+
throw new Error("ccusage returned malformed JSON");
|
|
284
|
+
}
|
|
285
|
+
return aggregateCcusageSessions(payload, threadId, { sampledAt, version });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function usageFile(workspace) {
|
|
289
|
+
return path.join(workspace, USAGE_FILE_NAME);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export async function readUsageStore(workspace) {
|
|
293
|
+
const filePath = usageFile(workspace);
|
|
294
|
+
const details = await lstat(filePath).catch((error) => {
|
|
295
|
+
if (error.code === "ENOENT") return null;
|
|
296
|
+
throw error;
|
|
297
|
+
});
|
|
298
|
+
if (details === null) return { schemaVersion: 1, tasks: {} };
|
|
299
|
+
if (details.isSymbolicLink() || !details.isFile()) {
|
|
300
|
+
throw new Error("TaskChef usage cache must be a regular file");
|
|
301
|
+
}
|
|
302
|
+
if (details.size > MAX_USAGE_FILE_BYTES) {
|
|
303
|
+
return { schemaVersion: 1, tasks: {} };
|
|
304
|
+
}
|
|
305
|
+
const value = JSON.parse(await readFile(filePath, "utf8"));
|
|
306
|
+
if (value?.schemaVersion !== 1 || !value.tasks || typeof value.tasks !== "object") {
|
|
307
|
+
throw new Error("TaskChef usage cache has an unsupported schema");
|
|
308
|
+
}
|
|
309
|
+
const tasks = Object.entries(value.tasks);
|
|
310
|
+
if (tasks.length > 2_000) return { schemaVersion: 1, tasks: {} };
|
|
311
|
+
return {
|
|
312
|
+
schemaVersion: 1,
|
|
313
|
+
tasks: Object.fromEntries(tasks.map(([taskId, record]) => [
|
|
314
|
+
taskId,
|
|
315
|
+
normalizeStoredRecord(record, `usage task ${taskId}`),
|
|
316
|
+
])),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function compactUsageRecord(record) {
|
|
321
|
+
const turnEntries = Object.entries(record.turns ?? {}).slice(-MAX_PERSISTED_TURNS);
|
|
322
|
+
const boundaryEntries = Object.entries(record.boundaries ?? {}).slice(-1);
|
|
323
|
+
return {
|
|
324
|
+
...record,
|
|
325
|
+
turns: Object.fromEntries(turnEntries),
|
|
326
|
+
boundaries: Object.fromEntries(boundaryEntries),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function compactUsageStore(store) {
|
|
331
|
+
const entries = Object.entries(store.tasks ?? {})
|
|
332
|
+
.sort(([, left], [, right]) => Date.parse(right.updatedAt ?? 0) - Date.parse(left.updatedAt ?? 0))
|
|
333
|
+
.slice(0, MAX_PERSISTED_TASKS);
|
|
334
|
+
const tasks = {};
|
|
335
|
+
let approximateBytes = 64;
|
|
336
|
+
for (const [taskId, record] of entries) {
|
|
337
|
+
const compacted = compactUsageRecord(record);
|
|
338
|
+
const entryBytes = Buffer.byteLength(JSON.stringify([taskId, compacted]), "utf8");
|
|
339
|
+
if (Object.keys(tasks).length > 0 && approximateBytes + entryBytes > USAGE_WRITE_BUDGET_BYTES) break;
|
|
340
|
+
tasks[taskId] = compacted;
|
|
341
|
+
approximateBytes += entryBytes;
|
|
342
|
+
}
|
|
343
|
+
return { schemaVersion: 1, tasks };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export async function writeUsageStore(workspace, store) {
|
|
347
|
+
await mkdir(workspace, { recursive: true });
|
|
348
|
+
const filePath = usageFile(workspace);
|
|
349
|
+
const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
350
|
+
const serialized = `${JSON.stringify(compactUsageStore(store), null, 2)}\n`;
|
|
351
|
+
if (Buffer.byteLength(serialized, "utf8") > MAX_USAGE_FILE_BYTES) {
|
|
352
|
+
throw new Error("TaskChef usage cache cannot be compacted below the size limit");
|
|
353
|
+
}
|
|
354
|
+
await writeFile(temporaryPath, serialized, {
|
|
355
|
+
encoding: "utf8",
|
|
356
|
+
flag: "wx",
|
|
357
|
+
mode: 0o600,
|
|
358
|
+
});
|
|
359
|
+
try {
|
|
360
|
+
await rename(temporaryPath, filePath);
|
|
361
|
+
} catch (error) {
|
|
362
|
+
await unlink(temporaryPath).catch(() => {});
|
|
363
|
+
throw error;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function usageDelta(current, previous = null) {
|
|
368
|
+
const baseline = previous ?? Object.fromEntries(TOKEN_FIELDS.map((field) => [field, 0]));
|
|
369
|
+
const delta = {};
|
|
370
|
+
for (const field of TOKEN_FIELDS) {
|
|
371
|
+
const value = current[field] - baseline[field];
|
|
372
|
+
if (!Number.isFinite(value) || value < 0) return null;
|
|
373
|
+
delta[field] = value;
|
|
374
|
+
}
|
|
375
|
+
let estimatedCostUsd = current.estimatedCostUsd === null
|
|
376
|
+
|| (previous !== null && previous.estimatedCostUsd === null)
|
|
377
|
+
? null
|
|
378
|
+
: current.estimatedCostUsd - (previous?.estimatedCostUsd ?? 0);
|
|
379
|
+
if (estimatedCostUsd !== null && (!Number.isFinite(estimatedCostUsd) || estimatedCostUsd < 0)) {
|
|
380
|
+
estimatedCostUsd = null;
|
|
381
|
+
}
|
|
382
|
+
if (estimatedCostUsd === 0 && delta.totalTokens > 0) estimatedCostUsd = null;
|
|
383
|
+
return {
|
|
384
|
+
...delta,
|
|
385
|
+
estimatedCostUsd,
|
|
386
|
+
costStatus: estimatedCostUsd === null ? "unavailable" : "estimated",
|
|
387
|
+
};
|
|
388
|
+
}
|
package/src/version.js
CHANGED