ccdeck 3.38.2 → 3.38.3
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/web/assets/{BrowserWatchModal-CwkyKvpa.js → BrowserWatchModal-DTjbVOvo.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-D8YK0c4C.js → UsageHistoryModal-BcXa5ICK.js} +1 -1
- package/dist/web/assets/{index-5Bss6_uX.js → index-ClJbck-M.js} +30 -30
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/server/account-projects.mjs +83 -20
- package/src/server/lan-engine.mjs +16 -4
- package/src/server/transcript-scan.mjs +110 -30
package/dist/web/index.html
CHANGED
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
stored === "light" || (stored !== "dark" && light) ? "light" : "dark");
|
|
65
65
|
})();
|
|
66
66
|
</script>
|
|
67
|
-
<script type="module" crossorigin src="/assets/index-
|
|
67
|
+
<script type="module" crossorigin src="/assets/index-ClJbck-M.js"></script>
|
|
68
68
|
<link rel="stylesheet" crossorigin href="/assets/index-6_mJZ_aP.css">
|
|
69
69
|
</head>
|
|
70
70
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccdeck",
|
|
3
|
-
"version": "3.38.
|
|
3
|
+
"version": "3.38.3",
|
|
4
4
|
"description": "Local dashboard for Claude Code and Codex CLI sessions: which Claude Code session needs input, every tool call and Claude Code subagent live, token cost and quota, several Claude accounts. Run npx ccdeck.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/release-notes.json
CHANGED
|
@@ -74,6 +74,12 @@
|
|
|
74
74
|
"3.38.1",
|
|
75
75
|
"3.38.2"
|
|
76
76
|
],
|
|
77
|
+
"3.38.3": [
|
|
78
|
+
{
|
|
79
|
+
"title": "🧾 Subagent costs count their whole reply",
|
|
80
|
+
"body": "A subagent's request was counted from the first moment of its reply, before most of it had been written, so much of its output was left out of the deck's token and cost counts, and a subagent turn in fast mode was priced at the standard rate. Each request is now counted from its finished reply. Days with subagents will read higher than before: that is the part that was missing, not a new charge. Projects counts its history again once after the update, so past days come out right too. Codex sessions on GPT-6 Sol, GPT-6.1 Sol and GPT-6 Luna now show a cost instead of not priced."
|
|
81
|
+
}
|
|
82
|
+
],
|
|
77
83
|
"3.37.1": [
|
|
78
84
|
{
|
|
79
85
|
"title": "🔁 Expired logins repair from your other machines",
|
|
@@ -33,7 +33,8 @@ import { readSwapLog, accountAtTime, trackedSince, seedActive, markGap } from ".
|
|
|
33
33
|
// and a session's card cannot disagree about which lines billed anything, nor
|
|
34
34
|
// about the speed they were billed at — see transcript-scan.mjs.
|
|
35
35
|
import {
|
|
36
|
-
billedSpeed, hasSpend, MAX_SPEEDS_PER_MODEL,
|
|
36
|
+
billedSpeed, hasSpend, MAX_SPEEDS_PER_MODEL, UNRECOGNISED_SPEED,
|
|
37
|
+
requestBilling, streamingRequestId, BILL_LINE, REBILL_REQUEST, SKIP_LINE,
|
|
37
38
|
} from "./transcript-scan.mjs";
|
|
38
39
|
|
|
39
40
|
/** The pseudo-account for messages the swap log cannot place — everything
|
|
@@ -41,7 +42,7 @@ import {
|
|
|
41
42
|
* a per-account total is never quietly inflated by work that is not that
|
|
42
43
|
* account's. A NUL keeps it from ever colliding with a real `email@@org` key. */
|
|
43
44
|
export const UNATTRIBUTED = "\u0000unattributed";
|
|
44
|
-
const STATE_VERSION =
|
|
45
|
+
const STATE_VERSION = 5;
|
|
45
46
|
|
|
46
47
|
/** Where the tally and cursors live, beside cswap-auto's own state. */
|
|
47
48
|
export function statePath(home = homedir()) {
|
|
@@ -104,6 +105,16 @@ function addInto(dst, src) {
|
|
|
104
105
|
return dst;
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
/** Take `src`'s six counters back out of `dst`. */
|
|
109
|
+
function takeFrom(dst, src) {
|
|
110
|
+
dst.i -= src.i; dst.o -= src.o; dst.cr -= src.cr;
|
|
111
|
+
dst.cc -= src.cc; dst.c1h -= src.c1h; dst.c5m -= src.c5m;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isZero(c) {
|
|
115
|
+
return !c.i && !c.o && !c.cr && !c.cc && !c.c1h && !c.c5m;
|
|
116
|
+
}
|
|
117
|
+
|
|
107
118
|
/** The share of `counters` billed at `speed` (#754), created on first sight:
|
|
108
119
|
* `s[speed]`, a SUBSET of the counters it sits on, the way the 1h/5m split is
|
|
109
120
|
* of `cc`, so the totals read exactly as they did and the client prices the
|
|
@@ -112,11 +123,18 @@ function addInto(dst, src) {
|
|
|
112
123
|
* past the cap is kept as UNRECOGNISED_SPEED, which no rate table names, so it
|
|
113
124
|
* is counted and left unpriced rather than priced at the standard rate. */
|
|
114
125
|
function speedShareOf(counters, speed) {
|
|
126
|
+
const key = speedShareKey(counters, speed);
|
|
127
|
+
return counters.s[key];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The key `speed`'s share is kept under in `counters`, the share created on
|
|
131
|
+
* first sight — see speedShareOf. */
|
|
132
|
+
function speedShareKey(counters, speed) {
|
|
115
133
|
const shares = counters.s ??= {};
|
|
116
|
-
if (Object.hasOwn(shares, speed)) return
|
|
134
|
+
if (Object.hasOwn(shares, speed)) return speed;
|
|
117
135
|
const key = Object.keys(shares).length < MAX_SPEEDS_PER_MODEL ? speed : UNRECOGNISED_SPEED;
|
|
118
136
|
if (!Object.hasOwn(shares, key)) shares[key] = zero();
|
|
119
|
-
return
|
|
137
|
+
return key;
|
|
120
138
|
}
|
|
121
139
|
|
|
122
140
|
function zero() { return { i: 0, o: 0, cr: 0, cc: 0, c1h: 0, c5m: 0 }; }
|
|
@@ -223,22 +241,28 @@ export function projectPath(cwd) {
|
|
|
223
241
|
*
|
|
224
242
|
* `timeline` is the sorted swap log; the account is whoever it says was active
|
|
225
243
|
* at this line's timestamp, or UNATTRIBUTED when the timestamp precedes it.
|
|
226
|
-
* `from` names the transcript's project directory — see folderOf
|
|
244
|
+
* `from` names the transcript's project directory — see folderOf — and, as
|
|
245
|
+
* `requests` and `path`, where the request this file is still waiting on a
|
|
246
|
+
* final record for is kept between passes.
|
|
227
247
|
*/
|
|
228
248
|
export function foldLine(tally, line, timeline, from) {
|
|
229
249
|
if (!line || !line.includes('"usage"')) return;
|
|
230
250
|
let obj = null;
|
|
231
251
|
try { obj = JSON.parse(line); } catch { return; }
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
|
|
252
|
+
// A request is billed once, from its final record, which in a subagent's
|
|
253
|
+
// file follows a streaming snapshot — see requestBilling. A call that keeps
|
|
254
|
+
// no requests for the file never sees one replaced, and bills block 0.
|
|
255
|
+
const requests = from?.requests ?? null;
|
|
256
|
+
const streaming = requests?.[from.path] ?? null;
|
|
257
|
+
const billing = requestBilling(obj, streaming?.id ?? null);
|
|
258
|
+
if (billing === SKIP_LINE) return;
|
|
236
259
|
const usage = obj?.message?.usage;
|
|
237
260
|
if (!usage || typeof usage !== "object") return;
|
|
238
261
|
const ts = Date.parse(obj?.timestamp);
|
|
239
262
|
if (!Number.isFinite(ts)) return;
|
|
240
263
|
const c = countersFrom(usage);
|
|
241
264
|
if (!hasSpend(asUsageTotals(c))) return; // a usage block that billed nothing
|
|
265
|
+
if (billing === REBILL_REQUEST) refund(tally, streaming);
|
|
242
266
|
const model = typeof obj?.message?.model === "string" ? obj.message.model : "";
|
|
243
267
|
const lineCwd = (typeof obj?.cwd === "string" && obj.cwd) ? obj.cwd : "";
|
|
244
268
|
const cwd = projectPath(folderOf(lineCwd, from)); // a worktree counts under the repo it checks out
|
|
@@ -251,7 +275,41 @@ export function foldLine(tally, line, timeline, from) {
|
|
|
251
275
|
// board prices it so (#754). Kept as a share rather than a model of its own,
|
|
252
276
|
// so a row's tokens and its model read exactly as before.
|
|
253
277
|
const speed = billedSpeed(obj);
|
|
254
|
-
|
|
278
|
+
const share = speed ? speedShareKey(counters, speed) : null;
|
|
279
|
+
if (share) addInto(counters.s[share], c);
|
|
280
|
+
if (billing === REBILL_REQUEST) dropIfEmpty(tally, streaming);
|
|
281
|
+
if (billing === BILL_LINE || !requests) return;
|
|
282
|
+
// What a request still streaming was charged, and where, so its final
|
|
283
|
+
// record can take that back, in this pass or a later one.
|
|
284
|
+
const id = streamingRequestId(obj);
|
|
285
|
+
if (id === null) delete requests[from.path];
|
|
286
|
+
else requests[from.path] = { id, at: [key, cwd, day, model], s: share, c };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Take a streaming request's charge back out of the row it was charged to,
|
|
290
|
+
* for its later record to charge in its place. A row pruned since has
|
|
291
|
+
* nothing left to take back. */
|
|
292
|
+
function refund(tally, { at: [key, cwd, day, model], s, c }) {
|
|
293
|
+
const counters = tally[key]?.[cwd]?.[day]?.[model];
|
|
294
|
+
if (!counters) return;
|
|
295
|
+
takeFrom(counters, c);
|
|
296
|
+
if (s && counters.s?.[s]) takeFrom(counters.s[s], c);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Remove what a refund left at zero — a speed share, or a whole row whose
|
|
300
|
+
* request's final record landed on another day or account — so the report
|
|
301
|
+
* never lists an empty row. */
|
|
302
|
+
function dropIfEmpty(tally, { at: [key, cwd, day, model], s }) {
|
|
303
|
+
const days = tally[key]?.[cwd];
|
|
304
|
+
const counters = days?.[day]?.[model];
|
|
305
|
+
if (!counters) return;
|
|
306
|
+
if (s && counters.s?.[s] && isZero(counters.s[s])) delete counters.s[s];
|
|
307
|
+
if (counters.s && !Object.keys(counters.s).length) delete counters.s;
|
|
308
|
+
if (!isZero(counters)) return;
|
|
309
|
+
delete days[day][model];
|
|
310
|
+
if (!Object.keys(days[day]).length) delete days[day];
|
|
311
|
+
if (!Object.keys(days).length) delete tally[key][cwd];
|
|
312
|
+
if (!Object.keys(tally[key]).length) delete tally[key];
|
|
255
313
|
}
|
|
256
314
|
|
|
257
315
|
/** Read the bytes of `path` from `start` to `size`, folding each COMPLETE line,
|
|
@@ -423,7 +481,7 @@ export function createProjectRollup({
|
|
|
423
481
|
setInterval: setIv = setInterval,
|
|
424
482
|
clearInterval: clearIv = clearInterval,
|
|
425
483
|
} = {}) {
|
|
426
|
-
let state = { version: STATE_VERSION, cursors: {}, tally: {}, folders: {} };
|
|
484
|
+
let state = { version: STATE_VERSION, cursors: {}, tally: {}, folders: {}, requests: {} };
|
|
427
485
|
let timer = null;
|
|
428
486
|
let running = false;
|
|
429
487
|
let dirty = false;
|
|
@@ -437,14 +495,17 @@ export function createProjectRollup({
|
|
|
437
495
|
if (disk && disk.version === STATE_VERSION && disk.tally && disk.cursors) {
|
|
438
496
|
state = disk;
|
|
439
497
|
state.folders ??= {};
|
|
440
|
-
|
|
498
|
+
state.requests ??= {};
|
|
499
|
+
} else if (disk && [1, 2, 3, 4].includes(disk.version)) {
|
|
441
500
|
// Version 1 counted every API content block; version 2 keyed each row
|
|
442
501
|
// by the line's own cwd, a row per folder the agent cd'd into (#1278);
|
|
443
502
|
// version 3 kept no speed, so every fast turn read at the standard
|
|
444
|
-
// rate
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
|
|
503
|
+
// rate; version 4 billed a subagent's request from its streaming
|
|
504
|
+
// snapshot, most of its output and its speed missing (#1883). None of
|
|
505
|
+
// them can be re-keyed, since it no longer knows which transcript a
|
|
506
|
+
// count came from. Keep the heartbeat so a restart gap remains fenced,
|
|
507
|
+
// but rebuild the tally from transcripts.
|
|
508
|
+
state = { version: STATE_VERSION, cursors: {}, tally: {}, folders: {}, requests: {}, lastAlive: disk.lastAlive };
|
|
448
509
|
dirty = true;
|
|
449
510
|
}
|
|
450
511
|
} catch { /* first run: empty state */ }
|
|
@@ -474,9 +535,9 @@ export function createProjectRollup({
|
|
|
474
535
|
// resync the cursor to the current end: the replaced content is lost, never
|
|
475
536
|
// counted twice. Transcripts append and do not shrink in normal use, so this
|
|
476
537
|
// is the rare, safe side to err on.
|
|
477
|
-
if (st.size < offset) { state.cursors[path] = st.size; dirty = true; return; }
|
|
538
|
+
if (st.size < offset) { state.cursors[path] = st.size; delete state.requests[path]; dirty = true; return; }
|
|
478
539
|
if (st.size <= offset) return;
|
|
479
|
-
const from = { slug, folders: state.folders };
|
|
540
|
+
const from = { slug, folders: state.folders, requests: state.requests, path };
|
|
480
541
|
let fh = null;
|
|
481
542
|
try {
|
|
482
543
|
fh = await open(path, "r");
|
|
@@ -504,9 +565,11 @@ export function createProjectRollup({
|
|
|
504
565
|
const timeline = await readSwapLog(swapLog);
|
|
505
566
|
const files = await listTranscripts(roots);
|
|
506
567
|
for (const [path, slug] of files) await foldFile(path, slug, timeline);
|
|
507
|
-
// Forget cursors
|
|
508
|
-
// directories with no transcript left, so
|
|
568
|
+
// Forget the cursors and streaming requests of files that are gone, and
|
|
569
|
+
// the folders of project directories with no transcript left, so none of
|
|
570
|
+
// the three maps grows forever.
|
|
509
571
|
for (const p of Object.keys(state.cursors)) if (!files.has(p)) { delete state.cursors[p]; dirty = true; }
|
|
572
|
+
for (const p of Object.keys(state.requests)) if (!files.has(p)) { delete state.requests[p]; dirty = true; }
|
|
510
573
|
const slugs = new Set(files.values());
|
|
511
574
|
for (const s of Object.keys(state.folders)) if (!slugs.has(s)) { delete state.folders[s]; dirty = true; }
|
|
512
575
|
// Heartbeat: record that the deck was alive now, so a later boot can see
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
// anything.
|
|
42
42
|
import {
|
|
43
43
|
addTrusted, ANNOUNCE_MS, credentialAad, dropTrusted, identityFrom, open, peerWhy, plan, PRESENT_MS,
|
|
44
|
-
stillListed, transferChallenge, trustedPeer,
|
|
44
|
+
sharedWith, stillListed, transferChallenge, trustedPeer,
|
|
45
45
|
} from "./lan-sync.mjs";
|
|
46
46
|
import { mintInvite, readInvite } from "./lan-invite.mjs";
|
|
47
47
|
import { createInviteOffer } from "./lan-invite-offer.mjs";
|
|
@@ -1028,13 +1028,25 @@ export function createEngine({
|
|
|
1028
1028
|
had.met = had.met || row.met;
|
|
1029
1029
|
if (row.name && !had.name) had.name = row.name;
|
|
1030
1030
|
};
|
|
1031
|
+
// WHAT IS TICKED HERE AND NOT OFFERED THAT DECK, so its dialog draws no
|
|
1032
|
+
// lane going out for it: what an arrival ticked, when the accept switch
|
|
1033
|
+
// paired it — see sharedWith in lan-sync.mjs. The page never sees `onward`
|
|
1034
|
+
// or which pin a switch made, so the engine says the difference itself,
|
|
1035
|
+
// and only where there is one, which on most decks is nowhere.
|
|
1036
|
+
const notOfferedTo = id => {
|
|
1037
|
+
const offered = sharedWith(cfg, id);
|
|
1038
|
+
const held = cfg.shared.filter(key => !offered.includes(key));
|
|
1039
|
+
return held.length ? { notOffered: held } : {};
|
|
1040
|
+
};
|
|
1031
1041
|
// WHAT THE DECK'S OWN DIALOG DRAWS, by identity: the card it sent,
|
|
1032
|
-
// the logins it offered last,
|
|
1033
|
-
//
|
|
1034
|
-
// halves of a merged row read the same
|
|
1042
|
+
// the logins it offered last, when somebody here said yes, and what
|
|
1043
|
+
// this deck does not offer it. All four are keyed by the fingerprint
|
|
1044
|
+
// that proved itself, so both halves of a merged row read the same
|
|
1045
|
+
// answer.
|
|
1035
1046
|
const card = id => ({
|
|
1036
1047
|
...heardOf(id),
|
|
1037
1048
|
pairedAt: trustedPeer(cfg.trusted, id)?.at ?? null,
|
|
1049
|
+
...notOfferedTo(id),
|
|
1038
1050
|
});
|
|
1039
1051
|
for (const p of [...beacon.peers.values(), ...dials.rows()]) {
|
|
1040
1052
|
if (!stillListed(p, now())) continue;
|
|
@@ -230,13 +230,15 @@ const USAGE_TOTAL_FIELDS = [...USAGE_BLOCK_FIELDS, ...CACHE_SPLIT_FIELDS];
|
|
|
230
230
|
* into both, read once, so the split and the total it splits go on summing
|
|
231
231
|
* to each other. A request billed at a speed other than standard is charged
|
|
232
232
|
* to that speed's share of the bucket as well, which is a subset of the
|
|
233
|
-
* bucket and not an addition to it.
|
|
234
|
-
|
|
233
|
+
* bucket and not an addition to it. What was charged is added to `charged`,
|
|
234
|
+
* so a request's bill can be taken back when its final record replaces it. */
|
|
235
|
+
function chargeUsage(state, bucket, blob, fields, speedBucket, charged) {
|
|
235
236
|
for (const k of fields) {
|
|
236
237
|
const n = grabUsageField(blob, k);
|
|
237
238
|
state.usage[k] += n;
|
|
238
239
|
if (bucket) bucket[k] += n;
|
|
239
240
|
if (speedBucket) speedBucket[k] += n;
|
|
241
|
+
charged[k] += n;
|
|
240
242
|
}
|
|
241
243
|
}
|
|
242
244
|
|
|
@@ -327,6 +329,9 @@ function newTranscriptState() {
|
|
|
327
329
|
// mirror of it. Costs no extra read: `message.model` and the `"usage"` block
|
|
328
330
|
// are on the same line, and this pass already parses both.
|
|
329
331
|
usageByModel: {},
|
|
332
|
+
// The request billed from a snapshot whose final record has not been read
|
|
333
|
+
// yet, and what it was charged — see requestBilling. Null between requests.
|
|
334
|
+
streamingRequest: null,
|
|
330
335
|
ctx: newContextBreakdown(),
|
|
331
336
|
};
|
|
332
337
|
}
|
|
@@ -422,35 +427,92 @@ function foldModelLine(state, obj) {
|
|
|
422
427
|
}
|
|
423
428
|
|
|
424
429
|
/**
|
|
425
|
-
*
|
|
426
|
-
* already carried?
|
|
430
|
+
* Is this record a later content block of a reply an earlier line began?
|
|
427
431
|
*
|
|
428
432
|
* Claude Code writes one assistant line per content block of a response —
|
|
429
|
-
* thinking, text, each tool call — and
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
* before them, and summing them all came to 1.94x the request-by-request
|
|
434
|
-
* total. This is the rule the Projects report (account-projects.mjs) and
|
|
435
|
-
* ccusage count by, and the Projects report asks this function. A record
|
|
436
|
-
* without the field was written before Claude Code marked its blocks, and is
|
|
437
|
-
* counted as it always was.
|
|
438
|
-
*
|
|
439
|
-
* The same lines are one reply, so the context breakdown asks this too before
|
|
440
|
-
* it counts an assistant message (#1650).
|
|
433
|
+
* thinking, text, each tool call — and marks each with `apiBlockIndex`, 0 for
|
|
434
|
+
* the first. They are one reply, so the context breakdown counts block 0 and
|
|
435
|
+
* not the rest (#1650). A record without the field was written before Claude
|
|
436
|
+
* Code marked its blocks, and is counted as it always was.
|
|
441
437
|
*/
|
|
442
|
-
function
|
|
438
|
+
function continuesRequest(record) {
|
|
443
439
|
return record?.apiBlockIndex !== undefined && record.apiBlockIndex !== 0;
|
|
444
440
|
}
|
|
445
441
|
|
|
442
|
+
// How a record's usage enters the bill — see requestBilling.
|
|
443
|
+
const BILL_LINE = "line";
|
|
444
|
+
const BILL_REQUEST = "request";
|
|
445
|
+
const REBILL_REQUEST = "rebill";
|
|
446
|
+
const SKIP_LINE = "skip";
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* How one record's usage is billed, given the request this file is still
|
|
450
|
+
* waiting on a final record for (`streamingId`, or null).
|
|
451
|
+
*
|
|
452
|
+
* Every content block's line carries a usage block, and summing them all
|
|
453
|
+
* counted a request once per block: 1.94x the request-by-request total across
|
|
454
|
+
* forty real transcripts (#1641). Which of those lines holds the request's
|
|
455
|
+
* usage depends on the file. In the main transcript every line restates the
|
|
456
|
+
* final usage. In a subagent's `subagents/agent-*.jsonl` Claude Code writes the
|
|
457
|
+
* first line while the response is still streaming — `stop_reason: null`, the
|
|
458
|
+
* output counted so far, no `usage.speed` — and the final record follows,
|
|
459
|
+
* often after the tool results of the call it streamed (#1883). Billing block 0
|
|
460
|
+
* charged one machine's 19,659 subagent requests 102K output tokens against
|
|
461
|
+
* the 22.9M their final records carry, and priced every fast-mode subagent
|
|
462
|
+
* turn at the standard rate, the snapshot naming no speed.
|
|
463
|
+
*
|
|
464
|
+
* So a request is billed from its final record, the first that carries a
|
|
465
|
+
* `stop_reason`. A snapshot is billed when it arrives, so a live card does not
|
|
466
|
+
* wait for the reply, and every later record of that request takes the bill's
|
|
467
|
+
* place rather than adding to it; a request whose final record never came (an
|
|
468
|
+
* interrupted agent) stays billed from its last snapshot. The scan remembers
|
|
469
|
+
* the request it is waiting on, so the two can arrive in different passes,
|
|
470
|
+
* which for a live subagent is the usual case.
|
|
471
|
+
*
|
|
472
|
+
* BILL_LINE a record from before Claude Code marked its blocks, billed
|
|
473
|
+
* line by line as it always was
|
|
474
|
+
* BILL_REQUEST the first record of a request
|
|
475
|
+
* REBILL_REQUEST a later record of the request still streaming: it replaces
|
|
476
|
+
* that request's bill
|
|
477
|
+
* SKIP_LINE a later block of a request already billed from its final
|
|
478
|
+
* record, which restates it
|
|
479
|
+
*
|
|
480
|
+
* The Projects report (account-projects.mjs) bills its own pass of the same
|
|
481
|
+
* lines by this rule.
|
|
482
|
+
*/
|
|
483
|
+
function requestBilling(record, streamingId) {
|
|
484
|
+
if (record?.apiBlockIndex === undefined) return BILL_LINE;
|
|
485
|
+
const id = requestIdOf(record);
|
|
486
|
+
if (id !== null && id === streamingId && isUsageRecord(record)) return REBILL_REQUEST;
|
|
487
|
+
return record.apiBlockIndex === 0 ? BILL_REQUEST : SKIP_LINE;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** The request a record belongs to while its final record has yet to come —
|
|
491
|
+
* the id requestBilling is next asked with — or null once it is final. */
|
|
492
|
+
function streamingRequestId(record) {
|
|
493
|
+
return typeof record?.message?.stop_reason === "string" ? null : requestIdOf(record);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function requestIdOf(record) {
|
|
497
|
+
const id = record?.requestId ?? record?.message?.id;
|
|
498
|
+
return typeof id === "string" && id ? id : null;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function isUsageRecord(record) {
|
|
502
|
+
const usage = record?.message?.usage;
|
|
503
|
+
return !!usage && typeof usage === "object";
|
|
504
|
+
}
|
|
505
|
+
|
|
446
506
|
/** The usage blocks a line was billed for, charged to the file's totals and to
|
|
447
507
|
* their model's bucket. Runs after foldModelLine on the same line — see
|
|
448
508
|
* below for why the order is the attribution. `record` is the line parsed,
|
|
449
509
|
* or null when it names no model. */
|
|
450
510
|
function foldUsageLine(state, line, record) {
|
|
451
|
-
// A
|
|
452
|
-
|
|
453
|
-
|
|
511
|
+
// A request is billed once, from its final record — see requestBilling.
|
|
512
|
+
const streaming = state.streamingRequest;
|
|
513
|
+
const billing = requestBilling(record, streaming?.id ?? null);
|
|
514
|
+
if (billing === SKIP_LINE) return;
|
|
515
|
+
if (billing === REBILL_REQUEST) refundUsage(state, streaming);
|
|
454
516
|
// Usage totals sum every block in the file, resets included — every block the
|
|
455
517
|
// model was actually billed for, which is why the `toolUseResult` tail is cut
|
|
456
518
|
// off first (see billedUsageText) — and are summed a second time into the
|
|
@@ -473,11 +535,28 @@ function foldUsageLine(state, line, record) {
|
|
|
473
535
|
const billed = billedUsageText(line);
|
|
474
536
|
const bucket = usageBucketFor(state, state.lastModel);
|
|
475
537
|
const speedBucket = speedBucketFor(bucket, billedSpeed(record));
|
|
538
|
+
const charged = newUsageTotals();
|
|
476
539
|
for (const m of billed.matchAll(USAGE_BLOCK_RE)) {
|
|
477
|
-
chargeUsage(state, bucket, m[1], USAGE_BLOCK_FIELDS, speedBucket);
|
|
540
|
+
chargeUsage(state, bucket, m[1], USAGE_BLOCK_FIELDS, speedBucket, charged);
|
|
478
541
|
}
|
|
479
542
|
for (const m of billed.replace(ITERATIONS_ARRAY_RE, "").matchAll(CACHE_CREATION_BLOCK_RE)) {
|
|
480
|
-
chargeUsage(state, bucket, m[1], CACHE_SPLIT_FIELDS, speedBucket);
|
|
543
|
+
chargeUsage(state, bucket, m[1], CACHE_SPLIT_FIELDS, speedBucket, charged);
|
|
544
|
+
}
|
|
545
|
+
if (billing === BILL_LINE) return;
|
|
546
|
+
// What this request was charged, and where, kept until its final record
|
|
547
|
+
// replaces it.
|
|
548
|
+
const id = streamingRequestId(record);
|
|
549
|
+
state.streamingRequest = id === null ? null : { id, bucket, speedBucket, charged };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** Take back what a request was charged, from every counter it was charged
|
|
553
|
+
* to, for its later record to charge in its place. */
|
|
554
|
+
function refundUsage(state, { bucket, speedBucket, charged }) {
|
|
555
|
+
for (const k of USAGE_TOTAL_FIELDS) {
|
|
556
|
+
const n = charged[k];
|
|
557
|
+
state.usage[k] -= n;
|
|
558
|
+
if (bucket) bucket[k] -= n;
|
|
559
|
+
if (speedBucket) speedBucket[k] -= n;
|
|
481
560
|
}
|
|
482
561
|
}
|
|
483
562
|
|
|
@@ -496,8 +575,8 @@ function foldContextLine(state, line, record) {
|
|
|
496
575
|
ctx.msgsUser += (ctxText.match(TYPE_USER_RE) ?? []).length;
|
|
497
576
|
// One reply is one message, however many content blocks it was written in:
|
|
498
577
|
// a later block's line is a reply block 0 has already counted — see
|
|
499
|
-
//
|
|
500
|
-
if (!
|
|
578
|
+
// continuesRequest. Its tool call is its own, and is counted below.
|
|
579
|
+
if (!continuesRequest(record)) ctx.msgsAssistant += (ctxText.match(TYPE_ASSISTANT_RE) ?? []).length;
|
|
501
580
|
ctx.toolUses += (ctxText.match(TYPE_TOOL_USE_RE) ?? []).length;
|
|
502
581
|
ctx.toolResults += (ctxText.match(TYPE_TOOL_RESULT_RE) ?? []).length;
|
|
503
582
|
ctx.systemReminders += (ctxText.match(SYSTEM_REMINDER_RE) ?? []).length;
|
|
@@ -655,12 +734,13 @@ function scanTranscript(path) {
|
|
|
655
734
|
}
|
|
656
735
|
|
|
657
736
|
// What session-enrichment.mjs calls, and the rules the Projects report
|
|
658
|
-
// (account-projects.mjs) reads its own pass of the transcripts by: which
|
|
659
|
-
//
|
|
660
|
-
//
|
|
661
|
-
//
|
|
662
|
-
// came from.
|
|
737
|
+
// (account-projects.mjs) reads its own pass of the transcripts by: which record
|
|
738
|
+
// of a request it is billed from, which lines billed anything, and the speed
|
|
739
|
+
// each was billed at with the cap on how many speeds one model keeps apart.
|
|
740
|
+
// Listed here rather than marked at each declaration so that every declaration
|
|
741
|
+
// above reads exactly as it did where it came from.
|
|
663
742
|
export {
|
|
664
|
-
scanTranscript, newUsageTotals, hasSpend, mergeUsageByModel, copyUsageBucket,
|
|
743
|
+
scanTranscript, newUsageTotals, hasSpend, mergeUsageByModel, copyUsageBucket,
|
|
744
|
+
requestBilling, streamingRequestId, BILL_LINE, REBILL_REQUEST, SKIP_LINE,
|
|
665
745
|
billedSpeed, MAX_SPEEDS_PER_MODEL, UNRECOGNISED_SPEED,
|
|
666
746
|
};
|