ccdeck 3.38.2 → 3.38.4

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.
@@ -230,14 +230,39 @@ 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
- function chargeUsage(state, bucket, blob, fields, speedBucket) {
235
- for (const k of fields) {
236
- const n = grabUsageField(blob, k);
237
- state.usage[k] += n;
238
- if (bucket) bucket[k] += n;
239
- if (speedBucket) speedBucket[k] += n;
240
- }
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) {
236
+ for (const k of fields) chargeTokens(state, bucket, speedBucket, charged, k, grabUsageField(blob, k));
237
+ }
238
+
239
+ /** Charge a request's record from its parsed `message.usage`, the same way.
240
+ *
241
+ * Not off the text, as chargeUsage reads a line: USAGE_BLOCK_RE's `[^}]+`
242
+ * stops at the first `}`, and a subagent's streaming snapshot writes its
243
+ * `cache_creation` sub-object before `output_tokens` (all 49,046 snapshots on
244
+ * one machine), so the output a snapshot had streamed read as 0 until the
245
+ * final record came, and stayed 0 for a request that never finished. A
246
+ * request's record names its model, so the line is parsed already; the
247
+ * Projects report reads the same object (countersFrom in account-projects.mjs). */
248
+ function chargeRequestUsage(state, bucket, usage, speedBucket, charged) {
249
+ const split = usage?.cache_creation;
250
+ for (const k of USAGE_BLOCK_FIELDS) chargeTokens(state, bucket, speedBucket, charged, k, tokenCount(usage?.[k]));
251
+ for (const k of CACHE_SPLIT_FIELDS) chargeTokens(state, bucket, speedBucket, charged, k, tokenCount(split?.[k]));
252
+ }
253
+
254
+ /** Add `n` tokens of counter `k` to every place one charge goes. */
255
+ function chargeTokens(state, bucket, speedBucket, charged, k, n) {
256
+ state.usage[k] += n;
257
+ if (bucket) bucket[k] += n;
258
+ if (speedBucket) speedBucket[k] += n;
259
+ charged[k] += n;
260
+ }
261
+
262
+ /** A usage counter as a token count: a whole number of tokens, or 0 for
263
+ * anything else, as USAGE_FIELD_RE reads one off the text. */
264
+ function tokenCount(v) {
265
+ return typeof v === "number" && Number.isFinite(v) && v > 0 ? Math.trunc(v) : 0;
241
266
  }
242
267
 
243
268
  /** Add one bucket's counters, and its speed shares, into `dst`. */
@@ -327,6 +352,10 @@ function newTranscriptState() {
327
352
  // mirror of it. Costs no extra read: `message.model` and the `"usage"` block
328
353
  // are on the same line, and this pass already parses both.
329
354
  usageByModel: {},
355
+ // What this file has read of its requests: the newest request record, and
356
+ // the request billed from a snapshot whose final record has not been read
357
+ // yet, with what it was charged — see requestBilling.
358
+ requests: newRequestLedger(),
330
359
  ctx: newContextBreakdown(),
331
360
  };
332
361
  }
@@ -422,35 +451,151 @@ function foldModelLine(state, obj) {
422
451
  }
423
452
 
424
453
  /**
425
- * Does this record repeat the usage an earlier line of the same API request
426
- * already carried?
454
+ * Is this record a later content block of a reply an earlier line began?
427
455
  *
428
456
  * Claude Code writes one assistant line per content block of a response —
429
- * thinking, text, each tool call — and every one of them carries the whole
430
- * request's `usage`. Only the first, `apiBlockIndex` 0, bills it (#1641).
431
- * Measured across the forty most recent transcripts on one machine: 6,765 of
432
- * 14,138 assistant usage records were such repeats, identical to the block 0
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).
457
+ * thinking, text, each tool call — and marks each with `apiBlockIndex`, 0 for
458
+ * the first. They are one reply, so the context breakdown counts block 0 and
459
+ * not the rest (#1650). A record without the field was written before Claude
460
+ * Code marked its blocks, and is counted as it always was.
441
461
  */
442
- function repeatsRequestUsage(record) {
462
+ function continuesRequest(record) {
443
463
  return record?.apiBlockIndex !== undefined && record.apiBlockIndex !== 0;
444
464
  }
445
465
 
466
+ // How a record's usage enters the bill — see requestBilling.
467
+ const BILL_LINE = "line";
468
+ const BILL_REQUEST = "request";
469
+ const REBILL_REQUEST = "rebill";
470
+ const SKIP_LINE = "skip";
471
+
472
+ /**
473
+ * How one record's usage is billed, given the request this file is still
474
+ * waiting on a final record for (`streamingId`, or null).
475
+ *
476
+ * Every content block's line carries a usage block, and summing them all
477
+ * counted a request once per block: 1.94x the request-by-request total across
478
+ * forty real transcripts (#1641). Which of those lines holds the request's
479
+ * usage depends on the file. In the main transcript every line restates the
480
+ * final usage. In a subagent's `subagents/agent-*.jsonl` Claude Code writes the
481
+ * first line while the response is still streaming — `stop_reason: null`, the
482
+ * output counted so far, no `usage.speed` — and the final record follows,
483
+ * often after the tool results of the call it streamed (#1883). Billing block 0
484
+ * charged one machine's 19,659 subagent requests 102K output tokens against
485
+ * the 22.9M their final records carry, and priced every fast-mode subagent
486
+ * turn at the standard rate, the snapshot naming no speed.
487
+ *
488
+ * So a request is billed from its final record, the first that carries a
489
+ * `stop_reason`. A snapshot is billed when it arrives, so a live card does not
490
+ * wait for the reply, and every later record of that request takes the bill's
491
+ * place rather than adding to it; a request whose final record never came (an
492
+ * interrupted agent) stays billed from its last snapshot. The scan remembers
493
+ * the request it is waiting on, so the two can arrive in different passes,
494
+ * which for a live subagent is the usual case.
495
+ *
496
+ * A resumed subagent writes its history into its file again before the new
497
+ * turn: the same records, with the same `uuid`, `requestId` and `timestamp`,
498
+ * usually byte for byte. Billed as they came, every request in that history was
499
+ * charged once more per replay — on one machine 98 requests in two files, 821
500
+ * times over. A restated record is told apart by its timestamp, which is older
501
+ * than the newest request record the file has already read: nothing Claude
502
+ * Code writes fresh is, none of 162,790 request records on that machine. Telling
503
+ * them apart by request id would need every id a file has billed, kept for as
504
+ * long as the file can grow and persisted with the Projects tally; the newest
505
+ * timestamp is one number. See restatesEarlierRecord.
506
+ *
507
+ * A file that starts in the middle of a request — a fork's file opens with its
508
+ * parent's turn, a resumed agent's with the one it stopped in — has no block 0
509
+ * for it, and that request is not billed here. It is not this file's: on that
510
+ * machine 36 of 38 such openings were requests the parent's or the earlier
511
+ * agent's transcript bills in full, block 0 and final record. The other two
512
+ * were workflow agents whose transcript continued in a second file mid-request,
513
+ * the snapshot billed at the end of the first and the final record opening the
514
+ * second; billing that record here would need to know what the other file
515
+ * charged for it.
516
+ *
517
+ * BILL_LINE a record from before Claude Code marked its blocks, billed
518
+ * line by line as it always was
519
+ * BILL_REQUEST the first record of a request
520
+ * REBILL_REQUEST a later record of the request still streaming: it replaces
521
+ * that request's bill
522
+ * SKIP_LINE a later block of a request already billed from its final
523
+ * record, which restates it, or a record restated by a replay
524
+ *
525
+ * `ledger` is what the file has read of its requests so far (newRequestLedger),
526
+ * or null for none; noteRequestRecord adds each record to it. The Projects
527
+ * report (account-projects.mjs) bills its own pass of the same lines by this
528
+ * rule.
529
+ */
530
+ function requestBilling(record, ledger) {
531
+ if (record?.apiBlockIndex === undefined) return BILL_LINE;
532
+ if (restatesEarlierRecord(record, ledger)) return SKIP_LINE;
533
+ const id = requestIdOf(record);
534
+ if (id !== null && id === ledger?.streaming?.id && isUsageRecord(record)) return REBILL_REQUEST;
535
+ return record.apiBlockIndex === 0 ? BILL_REQUEST : SKIP_LINE;
536
+ }
537
+
538
+ /** What one file has read of its requests, for requestBilling: when the newest
539
+ * request record was written and which request it was, and the request still
540
+ * streaming — `{ id, … }` with whatever its caller needs to take its charge
541
+ * back — or null. Plain data, so the Projects report can persist it. */
542
+ function newRequestLedger() {
543
+ return { newestAt: null, newestId: null, streaming: null };
544
+ }
545
+
546
+ /** Is `record` a copy of one the file has already read, written again by a
547
+ * resumed subagent restating its history? The copy keeps its original
548
+ * timestamp, so it is older than the newest request record read — or, written
549
+ * at that same moment, it is block 0 of the very request that record belongs
550
+ * to, which a request has only one of. A later block at that moment is not a
551
+ * copy: Claude Code writes two blocks of one request within a millisecond
552
+ * often enough (38 times on that machine). A record without a timestamp is
553
+ * never taken for a copy. */
554
+ function restatesEarlierRecord(record, ledger) {
555
+ const at = Date.parse(record.timestamp);
556
+ const newest = ledger?.newestAt ?? null;
557
+ if (newest === null || !Number.isFinite(at)) return false;
558
+ if (at !== newest) return at < newest;
559
+ const id = requestIdOf(record);
560
+ return record.apiBlockIndex === 0 && id !== null && id === ledger.newestId;
561
+ }
562
+
563
+ /** Add a request record to the file's ledger, once it has been billed by
564
+ * requestBilling — skipped or not, it is a record the file has read. */
565
+ function noteRequestRecord(ledger, record) {
566
+ const at = Date.parse(record.timestamp);
567
+ if (!Number.isFinite(at) || (ledger.newestAt !== null && at < ledger.newestAt)) return;
568
+ ledger.newestAt = at;
569
+ ledger.newestId = requestIdOf(record);
570
+ }
571
+
572
+ /** The request a record belongs to while its final record has yet to come —
573
+ * the id requestBilling is next asked with — or null once it is final. */
574
+ function streamingRequestId(record) {
575
+ return typeof record?.message?.stop_reason === "string" ? null : requestIdOf(record);
576
+ }
577
+
578
+ function requestIdOf(record) {
579
+ const id = record?.requestId ?? record?.message?.id;
580
+ return typeof id === "string" && id ? id : null;
581
+ }
582
+
583
+ function isUsageRecord(record) {
584
+ const usage = record?.message?.usage;
585
+ return !!usage && typeof usage === "object";
586
+ }
587
+
446
588
  /** The usage blocks a line was billed for, charged to the file's totals and to
447
589
  * their model's bucket. Runs after foldModelLine on the same line — see
448
590
  * below for why the order is the attribution. `record` is the line parsed,
449
591
  * or null when it names no model. */
450
592
  function foldUsageLine(state, line, record) {
451
- // A later content block of a request already charged — see
452
- // repeatsRequestUsage.
453
- if (repeatsRequestUsage(record)) return;
593
+ // A request is billed once, from its final record — see requestBilling.
594
+ const ledger = state.requests;
595
+ const billing = requestBilling(record, ledger);
596
+ if (billing !== BILL_LINE) noteRequestRecord(ledger, record);
597
+ if (billing === SKIP_LINE) return;
598
+ if (billing === REBILL_REQUEST) refundUsage(state, ledger.streaming);
454
599
  // Usage totals sum every block in the file, resets included — every block the
455
600
  // model was actually billed for, which is why the `toolUseResult` tail is cut
456
601
  // off first (see billedUsageText) — and are summed a second time into the
@@ -470,14 +615,34 @@ function foldUsageLine(state, line, record) {
470
615
  // text: a split that read a wider stretch of the line than the total it splits
471
616
  // would re-introduce #685's double count on one side of the arithmetic only,
472
617
  // and the two would stop summing to each other.
473
- const billed = billedUsageText(line);
474
618
  const bucket = usageBucketFor(state, state.lastModel);
475
619
  const speedBucket = speedBucketFor(bucket, billedSpeed(record));
476
- for (const m of billed.matchAll(USAGE_BLOCK_RE)) {
477
- chargeUsage(state, bucket, m[1], USAGE_BLOCK_FIELDS, speedBucket);
620
+ const charged = newUsageTotals();
621
+ if (billing === BILL_LINE) {
622
+ const billed = billedUsageText(line);
623
+ for (const m of billed.matchAll(USAGE_BLOCK_RE)) {
624
+ chargeUsage(state, bucket, m[1], USAGE_BLOCK_FIELDS, speedBucket, charged);
625
+ }
626
+ for (const m of billed.replace(ITERATIONS_ARRAY_RE, "").matchAll(CACHE_CREATION_BLOCK_RE)) {
627
+ chargeUsage(state, bucket, m[1], CACHE_SPLIT_FIELDS, speedBucket, charged);
628
+ }
629
+ return;
478
630
  }
479
- for (const m of billed.replace(ITERATIONS_ARRAY_RE, "").matchAll(CACHE_CREATION_BLOCK_RE)) {
480
- chargeUsage(state, bucket, m[1], CACHE_SPLIT_FIELDS, speedBucket);
631
+ chargeRequestUsage(state, bucket, record.message?.usage, speedBucket, charged);
632
+ // What this request was charged, and where, kept until its final record
633
+ // replaces it.
634
+ const id = streamingRequestId(record);
635
+ ledger.streaming = id === null ? null : { id, bucket, speedBucket, charged };
636
+ }
637
+
638
+ /** Take back what a request was charged, from every counter it was charged
639
+ * to, for its later record to charge in its place. */
640
+ function refundUsage(state, { bucket, speedBucket, charged }) {
641
+ for (const k of USAGE_TOTAL_FIELDS) {
642
+ const n = charged[k];
643
+ state.usage[k] -= n;
644
+ if (bucket) bucket[k] -= n;
645
+ if (speedBucket) speedBucket[k] -= n;
481
646
  }
482
647
  }
483
648
 
@@ -496,8 +661,8 @@ function foldContextLine(state, line, record) {
496
661
  ctx.msgsUser += (ctxText.match(TYPE_USER_RE) ?? []).length;
497
662
  // One reply is one message, however many content blocks it was written in:
498
663
  // a later block's line is a reply block 0 has already counted — see
499
- // repeatsRequestUsage. Its tool call is its own, and is counted below.
500
- if (!repeatsRequestUsage(record)) ctx.msgsAssistant += (ctxText.match(TYPE_ASSISTANT_RE) ?? []).length;
664
+ // continuesRequest. Its tool call is its own, and is counted below.
665
+ if (!continuesRequest(record)) ctx.msgsAssistant += (ctxText.match(TYPE_ASSISTANT_RE) ?? []).length;
501
666
  ctx.toolUses += (ctxText.match(TYPE_TOOL_USE_RE) ?? []).length;
502
667
  ctx.toolResults += (ctxText.match(TYPE_TOOL_RESULT_RE) ?? []).length;
503
668
  ctx.systemReminders += (ctxText.match(SYSTEM_REMINDER_RE) ?? []).length;
@@ -655,12 +820,13 @@ function scanTranscript(path) {
655
820
  }
656
821
 
657
822
  // What session-enrichment.mjs calls, and the rules the Projects report
658
- // (account-projects.mjs) reads its own pass of the transcripts by: which lines
659
- // billed anything, and the speed each was billed at with the cap on how many
660
- // speeds one model keeps apart. Listed here rather than marked at each
661
- // declaration so that every declaration above reads exactly as it did where it
662
- // came from.
823
+ // (account-projects.mjs) reads its own pass of the transcripts by: which record
824
+ // of a request it is billed from, which lines billed anything, and the speed
825
+ // each was billed at with the cap on how many speeds one model keeps apart.
826
+ // Listed here rather than marked at each declaration so that every declaration
827
+ // above reads exactly as it did where it came from.
663
828
  export {
664
- scanTranscript, newUsageTotals, hasSpend, mergeUsageByModel, copyUsageBucket, repeatsRequestUsage,
665
- billedSpeed, MAX_SPEEDS_PER_MODEL, UNRECOGNISED_SPEED,
829
+ scanTranscript, newUsageTotals, hasSpend, mergeUsageByModel, copyUsageBucket,
830
+ requestBilling, newRequestLedger, noteRequestRecord, streamingRequestId,
831
+ BILL_LINE, REBILL_REQUEST, SKIP_LINE, billedSpeed, MAX_SPEEDS_PER_MODEL, UNRECOGNISED_SPEED,
666
832
  };