ccdeck 3.38.1 → 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.
@@ -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
- function chargeUsage(state, bucket, blob, fields, speedBucket) {
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
- * Does this record repeat the usage an earlier line of the same API request
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 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).
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 repeatsRequestUsage(record) {
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 later content block of a request already charged — see
452
- // repeatsRequestUsage.
453
- if (repeatsRequestUsage(record)) return;
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
- // repeatsRequestUsage. Its tool call is its own, and is counted below.
500
- if (!repeatsRequestUsage(record)) ctx.msgsAssistant += (ctxText.match(TYPE_ASSISTANT_RE) ?? []).length;
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 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.
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, repeatsRequestUsage,
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
  };