letmecode 0.1.20 → 0.1.22

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.
@@ -10,12 +10,26 @@ import { applyRateLimits, asRecord, buildWindowLists, createLimitWindowAggregate
10
10
  import { addDailyUsage, buildDailyUsageRows, createDailyUsageAggregates } from "./daily.js";
11
11
  import { resolveUsageRate } from "./pricing.js";
12
12
  const RATE_CARD = {
13
+ "claude-fable-5": { input: 10, cacheRead: 1, cacheWrite: 12.5, cacheWrite5m: 12.5, cacheWrite1h: 20, output: 50 },
13
14
  "claude-opus-4-8": { input: 5, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite5m: 6.25, cacheWrite1h: 10, output: 25 },
14
15
  "claude-opus-4-7": { input: 5, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite5m: 6.25, cacheWrite1h: 10, output: 25 },
15
16
  "claude-opus-4-6": { input: 5, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite5m: 6.25, cacheWrite1h: 10, output: 25 },
16
17
  "claude-opus-4-5": { input: 5, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite5m: 6.25, cacheWrite1h: 10, output: 25 },
17
18
  "claude-opus-4-1": { input: 15, cacheRead: 1.5, cacheWrite: 18.75, cacheWrite5m: 18.75, cacheWrite1h: 30, output: 75 },
18
19
  "claude-opus-4": { input: 15, cacheRead: 1.5, cacheWrite: 18.75, cacheWrite5m: 18.75, cacheWrite1h: 30, output: 75 },
20
+ "claude-sonnet-5": {
21
+ input: 3,
22
+ cacheRead: 0.3,
23
+ cacheWrite: 3.75,
24
+ cacheWrite5m: 3.75,
25
+ cacheWrite1h: 6,
26
+ output: 15,
27
+ // Intro pricing (~0.66x) through 2026-08-31; reverts to the standard rate above on 2026-09-01.
28
+ introOffer: {
29
+ effectiveUntilMs: Date.UTC(2026, 8, 1),
30
+ rate: { input: 2, cacheRead: 0.2, cacheWrite: 2.5, cacheWrite5m: 2.5, cacheWrite1h: 4, output: 10 }
31
+ }
32
+ },
19
33
  "claude-sonnet-4-6": { input: 3, cacheRead: 0.3, cacheWrite: 3.75, cacheWrite5m: 3.75, cacheWrite1h: 6, output: 15 },
20
34
  "claude-sonnet-4-5": { input: 3, cacheRead: 0.3, cacheWrite: 3.75, cacheWrite5m: 3.75, cacheWrite1h: 6, output: 15 },
21
35
  "claude-sonnet-4": { input: 3, cacheRead: 0.3, cacheWrite: 3.75, cacheWrite5m: 3.75, cacheWrite1h: 6, output: 15 },
@@ -46,12 +60,14 @@ const parsedClaudeSessionFilesCache = new Map();
46
60
  const claudeBinaryPathCache = new Map();
47
61
  const claudeUsageOutputCache = new Map();
48
62
  const claudeAuthStatusOutputCache = new Map();
63
+ const claudeOauthCredentialsOutputCache = new Map();
49
64
  export class ClaudeUsageProvider extends UsageProviderBase {
50
65
  constructor(options = {}) {
51
66
  super(options.id ?? "claude", options.label ?? "Claude");
52
67
  this.root = path.resolve(options.root ?? os.homedir());
53
68
  this.readUsageCommandOutput = options.readUsageCommandOutput;
54
69
  this.readAuthStatusOutput = options.readAuthStatusOutput;
70
+ this.readOauthCredentials = options.readOauthCredentials;
55
71
  this.now = options.now ?? (() => new Date());
56
72
  }
57
73
  async getStats(options = {}) {
@@ -82,7 +98,6 @@ export class ClaudeUsageProvider extends UsageProviderBase {
82
98
  `malformed=${file.malformedLines}`,
83
99
  `assistantUsageEvents=${file.events.length}`,
84
100
  `matchingEvents=${matchingEvents.length}`,
85
- `source=${file.sourceKind}`,
86
101
  `entrypoints=${summarizeEventCounts(file.events.map((event) => event.entrypoint || "<empty>"))}`,
87
102
  `models=${summarizeDistinctValues(file.events.map((event) => event.modelId || "unknown"))}`
88
103
  ].join(" "));
@@ -125,7 +140,7 @@ export class ClaudeUsageProvider extends UsageProviderBase {
125
140
  warnings.push(`Collapsed ${parsedEvents.duplicateUsageKeys} duplicate Claude usage event(s) by request/message key.`);
126
141
  }
127
142
  if (options.verbose && parsedEvents.duplicateUsageKeyCollisions > 0) {
128
- warnings.push(`Detected ${parsedEvents.duplicateUsageKeyCollisions} Claude usage key collision(s) with different token usage; merged same-key rows by per-field maxima to avoid double-counting cumulative snapshots.`);
143
+ warnings.push(`Detected ${parsedEvents.duplicateUsageKeyCollisions} Claude usage key collision(s) with different token usage; kept the most complete same-key snapshot to avoid double-counting cumulative snapshots.`);
129
144
  }
130
145
  if (options.verbose && parsedEvents.duplicateUnkeyedEvents > 0) {
131
146
  warnings.push(`Collapsed ${parsedEvents.duplicateUnkeyedEvents} adjacent duplicate unkeyed Claude usage event(s) by usage signature.`);
@@ -148,7 +163,7 @@ export class ClaudeUsageProvider extends UsageProviderBase {
148
163
  const liveLimitWindows = await buildLiveLimitWindows({
149
164
  root: this.root,
150
165
  readUsageCommandOutput: this.readUsageCommandOutput,
151
- readAuthStatusOutput: this.readAuthStatusOutput,
166
+ readOauthCredentials: this.readOauthCredentials,
152
167
  traceLogger: options.traceLogger,
153
168
  now: this.now(),
154
169
  selectedEvents
@@ -213,14 +228,14 @@ function normalizeUsage(value) {
213
228
  inferenceGeo: String(usage.inference_geo ?? "")
214
229
  };
215
230
  }
216
- function resolveRate(modelId) {
217
- return resolveUsageRate(RATE_CARD, modelId, 0, { prefixMatch: true });
231
+ function resolveRate(modelId, timestampMs) {
232
+ return resolveUsageRate(RATE_CARD, modelId, 0, { prefixMatch: true, timestampMs });
218
233
  }
219
234
  function isInternalClaudeModel(modelId) {
220
235
  return modelId === "<synthetic>";
221
236
  }
222
- function creditsFor(modelId, usage) {
223
- const rate = resolveRate(modelId);
237
+ function creditsFor(modelId, usage, timestampMs) {
238
+ const rate = resolveRate(modelId, timestampMs);
224
239
  if (!rate) {
225
240
  return 0;
226
241
  }
@@ -234,7 +249,7 @@ function creditsFor(modelId, usage) {
234
249
  inferenceMultiplier *
235
250
  USD_TO_CREDITS);
236
251
  }
237
- function usageToTotals(modelId, usage) {
252
+ function usageToTotals(modelId, usage, timestampMs) {
238
253
  const cacheWriteBreakdown = resolveClaudeCacheWriteBreakdown(usage);
239
254
  const cacheWriteInputTokens = cacheWriteBreakdown.cacheWrite5mInputTokens +
240
255
  cacheWriteBreakdown.cacheWrite1hInputTokens;
@@ -250,7 +265,7 @@ function usageToTotals(modelId, usage) {
250
265
  usage.cacheReadInputTokens +
251
266
  cacheWriteInputTokens +
252
267
  usage.outputTokens,
253
- estimatedCredits: creditsFor(modelId, usage),
268
+ estimatedCredits: creditsFor(modelId, usage, timestampMs),
254
269
  eventCount: 1
255
270
  };
256
271
  }
@@ -387,26 +402,20 @@ async function loadParsedClaudeSessionFiles(sessionsRoot, traceLogger) {
387
402
  const files = [];
388
403
  traceClaude(traceLogger, `Scanning session files under ${sessionsRoot}.`);
389
404
  for await (const filePath of walkSessionFiles(sessionsRoot)) {
390
- files.push(await parseSessionFile(filePath, sessionsRoot));
405
+ files.push(await parseSessionFile(filePath));
391
406
  }
392
- inferClaudeSessionFileSources(files);
393
407
  traceClaude(traceLogger, `Completed session file scan under ${sessionsRoot}: ${files.length} file(s) parsed.`);
394
408
  return files;
395
409
  })();
396
410
  parsedClaudeSessionFilesCache.set(cacheKey, pending);
397
411
  return pending;
398
412
  }
399
- async function parseSessionFile(filePath, sessionsRoot) {
413
+ async function parseSessionFile(filePath) {
400
414
  const stream = fs.createReadStream(filePath, { encoding: "utf8" });
401
415
  const lineReader = readline.createInterface({ input: stream, crlfDelay: Infinity });
402
416
  let linesRead = 0;
403
417
  let malformedLines = 0;
404
418
  const events = [];
405
- const assistantEntryPoints = new Set();
406
- let hasIdeOpenedFileAttachment = false;
407
- let hasIdeOpenedFileMarker = false;
408
- let hasIdeTooling = false;
409
- let hasQueueOperations = false;
410
419
  for await (const line of lineReader) {
411
420
  linesRead += 1;
412
421
  if (!line.trim()) {
@@ -420,19 +429,6 @@ async function parseSessionFile(filePath, sessionsRoot) {
420
429
  malformedLines += 1;
421
430
  continue;
422
431
  }
423
- if (payloadObject.type === "queue-operation") {
424
- hasQueueOperations = true;
425
- }
426
- if (messageContainsIdeOpenedFileMarker(asRecord(payloadObject.message))) {
427
- hasIdeOpenedFileMarker = true;
428
- }
429
- const attachment = asRecord(payloadObject.attachment);
430
- if (attachment?.type === "opened_file_in_ide") {
431
- hasIdeOpenedFileAttachment = true;
432
- }
433
- if (attachmentHasIdeTooling(attachment)) {
434
- hasIdeTooling = true;
435
- }
436
432
  if (payloadObject.type !== "assistant") {
437
433
  continue;
438
434
  }
@@ -448,7 +444,6 @@ async function parseSessionFile(filePath, sessionsRoot) {
448
444
  const normalizedUsage = normalizeUsage(usage);
449
445
  const usageKeys = buildUsageEventKeys(payloadObject, message);
450
446
  const usageSignature = buildUsageSignature(payloadObject, modelId, normalizedUsage);
451
- assistantEntryPoints.add(entrypoint);
452
447
  events.push({
453
448
  entrypoint,
454
449
  filePath,
@@ -458,106 +453,17 @@ async function parseSessionFile(filePath, sessionsRoot) {
458
453
  timestampMs: eventTimeMs,
459
454
  modelId,
460
455
  usage: normalizedUsage,
461
- totals: usageToTotals(modelId, normalizedUsage),
456
+ totals: usageToTotals(modelId, normalizedUsage, eventTimeMs),
462
457
  rateLimits
463
458
  });
464
459
  }
465
460
  return {
466
461
  filePath,
467
- sessionGroupKey: buildClaudeSessionGroupKey(sessionsRoot, filePath),
468
462
  linesRead,
469
463
  malformedLines,
470
- sourceKind: "unknown",
471
- sourceReason: "unclassified",
472
- signals: {
473
- assistantEntryPoints: [...assistantEntryPoints].sort(),
474
- hasIdeOpenedFileAttachment,
475
- hasIdeOpenedFileMarker,
476
- hasIdeTooling,
477
- hasQueueOperations
478
- },
479
464
  events
480
465
  };
481
466
  }
482
- function buildClaudeSessionGroupKey(sessionsRoot, filePath) {
483
- const relativePath = path.relative(sessionsRoot, filePath);
484
- if (!relativePath || relativePath.startsWith("..")) {
485
- return filePath;
486
- }
487
- const normalizedRelativePath = relativePath.split(path.sep).join("/");
488
- const subagentMatch = normalizedRelativePath.match(/^(.*\/[^/]+)\/subagents\/[^/]+\.jsonl$/);
489
- if (subagentMatch?.[1]) {
490
- return subagentMatch[1];
491
- }
492
- return normalizedRelativePath.replace(/\.jsonl$/i, "");
493
- }
494
- function inferClaudeSessionFileSources(files) {
495
- const groups = new Map();
496
- for (const file of files) {
497
- const group = groups.get(file.sessionGroupKey) ?? {
498
- assistantEntryPoints: new Set(),
499
- hasIdeHints: false
500
- };
501
- for (const entrypoint of file.signals.assistantEntryPoints) {
502
- group.assistantEntryPoints.add(entrypoint);
503
- }
504
- group.hasIdeHints =
505
- group.hasIdeHints ||
506
- file.signals.hasIdeOpenedFileAttachment ||
507
- file.signals.hasIdeOpenedFileMarker ||
508
- file.signals.hasIdeTooling ||
509
- file.signals.hasQueueOperations;
510
- groups.set(file.sessionGroupKey, group);
511
- }
512
- for (const file of files) {
513
- const group = groups.get(file.sessionGroupKey);
514
- const { kind, reason } = classifyClaudeSessionGroup(group);
515
- file.sourceKind = kind;
516
- file.sourceReason = reason;
517
- }
518
- }
519
- function classifyClaudeSessionGroup(group) {
520
- if (!group) {
521
- return { kind: "unknown", reason: "missing session group signals" };
522
- }
523
- if (group.assistantEntryPoints.has("claude-vscode")) {
524
- return { kind: "vscode", reason: "explicit claude-vscode entrypoint" };
525
- }
526
- if (group.assistantEntryPoints.has("sdk-cli") || group.assistantEntryPoints.has("claude")) {
527
- return { kind: "cli", reason: "explicit sdk-cli/claude entrypoint" };
528
- }
529
- if (group.assistantEntryPoints.has("cli")) {
530
- return group.hasIdeHints
531
- ? { kind: "vscode", reason: "generic cli entrypoint with IDE session hints" }
532
- : { kind: "cli", reason: "generic cli entrypoint without IDE session hints" };
533
- }
534
- return { kind: "unknown", reason: "no assistant entrypoints" };
535
- }
536
- function attachmentHasIdeTooling(attachment) {
537
- if (attachment?.type !== "deferred_tools_delta") {
538
- return false;
539
- }
540
- return extractStringArray(attachment.addedNames).some((name) => name.startsWith("mcp__ide__"));
541
- }
542
- function messageContainsIdeOpenedFileMarker(message) {
543
- const content = message?.content;
544
- if (typeof content === "string") {
545
- return content.includes("<ide_opened_file>");
546
- }
547
- if (!Array.isArray(content)) {
548
- return false;
549
- }
550
- return content.some((item) => {
551
- const contentItem = asRecord(item);
552
- return typeof contentItem?.text === "string" && contentItem.text.includes("<ide_opened_file>");
553
- });
554
- }
555
- function extractStringArray(value) {
556
- if (!Array.isArray(value)) {
557
- return [];
558
- }
559
- return value.filter((item) => typeof item === "string");
560
- }
561
467
  function buildUsageEventKeys(payloadObject, message) {
562
468
  const sessionId = String(payloadObject.sessionId ?? "");
563
469
  const requestId = typeof payloadObject.requestId === "string" ? payloadObject.requestId : "";
@@ -637,8 +543,14 @@ function recordParsedUsageEvent(parsedEvents, event) {
637
543
  }
638
544
  }
639
545
  function mergeParsedUsageEvents(previous, next) {
640
- const mergedUsage = mergeClaudeUsage(previous.usage, next.usage);
641
- const modelId = selectMergedEventModelId(previous, next);
546
+ // Same-key events are repeated/streamed snapshots of one logical request. Rather than
547
+ // synthesizing a field-wise maximum (which can fabricate token totals when a snapshot
548
+ // splits cache-write tokens across the 5m/1h buckets differently), keep the single most
549
+ // complete real snapshot and discard the rest.
550
+ const primaryEvent = selectMergedSnapshotEvent(previous, next);
551
+ const otherEvent = primaryEvent === previous ? next : previous;
552
+ const usage = primaryEvent.usage;
553
+ const modelId = selectMergedEventModelId(primaryEvent, otherEvent);
642
554
  const latestEvent = normalizeTimestamp(next.timestampMs) >= normalizeTimestamp(previous.timestampMs) ? next : previous;
643
555
  const sessionId = extractUsageKeySessionId(previous.usageKeys) || extractUsageKeySessionId(next.usageKeys);
644
556
  return {
@@ -646,38 +558,34 @@ function mergeParsedUsageEvents(previous, next) {
646
558
  filePath: latestEvent.filePath,
647
559
  lineNumber: latestEvent.lineNumber,
648
560
  usageKeys: [...new Set([...previous.usageKeys, ...next.usageKeys])],
649
- usageSignature: buildUsageSignatureFromParts(sessionId, modelId, mergedUsage),
561
+ usageSignature: buildUsageSignatureFromParts(sessionId, modelId, usage),
650
562
  timestampMs: Math.max(normalizeTimestamp(previous.timestampMs), normalizeTimestamp(next.timestampMs)),
651
563
  modelId,
652
- usage: mergedUsage,
653
- totals: usageToTotals(modelId, mergedUsage),
564
+ usage,
565
+ totals: usageToTotals(modelId, usage, primaryEvent.timestampMs),
654
566
  rateLimits: latestEvent.rateLimits ?? previous.rateLimits ?? next.rateLimits
655
567
  };
656
568
  }
657
- function mergeClaudeUsage(previous, next) {
658
- return {
659
- inputTokens: Math.max(previous.inputTokens, next.inputTokens),
660
- cacheReadInputTokens: Math.max(previous.cacheReadInputTokens, next.cacheReadInputTokens),
661
- cacheCreationInputTokens: Math.max(previous.cacheCreationInputTokens, next.cacheCreationInputTokens),
662
- cacheCreation5mInputTokens: Math.max(previous.cacheCreation5mInputTokens, next.cacheCreation5mInputTokens),
663
- cacheCreation1hInputTokens: Math.max(previous.cacheCreation1hInputTokens, next.cacheCreation1hInputTokens),
664
- outputTokens: Math.max(previous.outputTokens, next.outputTokens),
665
- inferenceGeo: next.inferenceGeo || previous.inferenceGeo
666
- };
667
- }
668
- function selectMergedEventModelId(previous, next) {
669
- if (previous.modelId === next.modelId) {
670
- return previous.modelId;
569
+ // Pick the snapshot that carries the most usage. Cumulative snapshots are monotonic, so the
570
+ // largest total is the final state; this also keeps a real synthetic-followup row (0 tokens)
571
+ // from clobbering the real usage it follows. Ties fall back to the later, then the earlier-seen
572
+ // event for deterministic output.
573
+ function selectMergedSnapshotEvent(previous, next) {
574
+ if (next.totals.totalTokens !== previous.totals.totalTokens) {
575
+ return next.totals.totalTokens > previous.totals.totalTokens ? next : previous;
671
576
  }
672
- if (isInternalClaudeModel(previous.modelId) && !isInternalClaudeModel(next.modelId)) {
673
- return next.modelId;
577
+ return normalizeTimestamp(next.timestampMs) > normalizeTimestamp(previous.timestampMs) ? next : previous;
578
+ }
579
+ function selectMergedEventModelId(primary, other) {
580
+ if (primary.modelId === other.modelId) {
581
+ return primary.modelId;
674
582
  }
675
- if (isInternalClaudeModel(next.modelId) && !isInternalClaudeModel(previous.modelId)) {
676
- return previous.modelId;
583
+ // The chosen snapshot's own model is authoritative, except when it is the internal
584
+ // <synthetic> placeholder and the other event names a real, priceable model.
585
+ if (isInternalClaudeModel(primary.modelId) && !isInternalClaudeModel(other.modelId)) {
586
+ return other.modelId;
677
587
  }
678
- return next.totals.estimatedCredits >= previous.totals.estimatedCredits
679
- ? next.modelId
680
- : previous.modelId;
588
+ return primary.modelId;
681
589
  }
682
590
  function canCollapseAdjacentUnkeyedUsageEvents(previous, next) {
683
591
  return previous.filePath === next.filePath && next.lineNumber === previous.lineNumber + 1;
@@ -756,16 +664,16 @@ function buildClaudeCommandEnvironment() {
756
664
  };
757
665
  }
758
666
  async function buildLiveLimitWindows(options) {
759
- const [usageOutput, subscriptionType] = await Promise.all([
667
+ const [usageOutput, credentials] = await Promise.all([
760
668
  readClaudeUsageCommandOutput(options.root, options.readUsageCommandOutput, options.traceLogger),
761
- readClaudeSubscriptionType(options.root, options.readAuthStatusOutput, options.traceLogger)
669
+ readClaudeOauthCredentials(options.root, options.readOauthCredentials, options.traceLogger)
762
670
  ]);
763
671
  const snapshots = parseLiveUsageWindowSnapshots(usageOutput, options.now);
764
672
  traceClaude(options.traceLogger, `Parsed ${snapshots.length} live usage snapshot(s) from /usage output.`);
765
673
  if (snapshots.length === 0) {
766
674
  traceClaude(options.traceLogger, "No live usage snapshots matched the expected /usage format.");
767
675
  }
768
- const resolvedPlanType = resolveClaudeLivePlanType(subscriptionType, snapshots);
676
+ const resolvedPlanType = resolveClaudeLivePlanType(credentials);
769
677
  traceClaude(options.traceLogger, `Resolved live plan type ${resolvedPlanType}.`);
770
678
  const primaryLimitWindows = snapshots
771
679
  .filter((snapshot) => snapshot.scope === "primary")
@@ -798,20 +706,82 @@ async function buildLiveLimitWindows(options) {
798
706
  secondaryLimitWindows
799
707
  };
800
708
  }
801
- function resolveClaudeLivePlanType(subscriptionType, snapshots) {
802
- if (snapshots.some((snapshot) => snapshot.modelScope === "sonnet-only")) {
803
- return "team_premium";
709
+ // Reported as-is: the claude.ai OAuth `rateLimitTier` is not mapped to a friendly tier name
710
+ // anymore, it is combined with `subscriptionType` verbatim (e.g. "team|default_claude_max_5x").
711
+ function resolveClaudeLivePlanType(credentials) {
712
+ const subscriptionType = credentials?.subscriptionType;
713
+ if (!subscriptionType) {
714
+ return "live";
804
715
  }
805
- return subscriptionType || "live";
716
+ return credentials?.rateLimitTier ? `${subscriptionType}|${credentials.rateLimitTier}` : subscriptionType;
806
717
  }
807
- async function readClaudeSubscriptionType(root, override, traceLogger) {
808
- const output = await readClaudeAuthStatusOutput(root, override, traceLogger);
809
- const subscriptionType = parseClaudeSubscriptionType(output);
810
- if (output && !subscriptionType) {
811
- traceClaude(traceLogger, "Could not parse subscription type from auth status output.");
812
- }
813
- traceClaude(traceLogger, `Subscription type result: ${subscriptionType ?? "<none>"}.`);
814
- return subscriptionType;
718
+ async function readClaudeOauthCredentials(root, override, traceLogger) {
719
+ const output = await readClaudeOauthCredentialsOutput(root, override, traceLogger);
720
+ const credentials = parseClaudeOauthCredentials(output);
721
+ if (output && !credentials) {
722
+ traceClaude(traceLogger, "Could not parse claude.ai OAuth credentials from credentials file.");
723
+ }
724
+ traceClaude(traceLogger, `OAuth credentials result: subscriptionType=${credentials?.subscriptionType ?? "<none>"} rateLimitTier=${credentials?.rateLimitTier ?? "<none>"}.`);
725
+ return credentials;
726
+ }
727
+ async function readClaudeOauthCredentialsOutput(root, override, traceLogger) {
728
+ if (override) {
729
+ try {
730
+ const output = await override();
731
+ traceClaude(traceLogger, "Using injected OAuth credentials override.");
732
+ return output;
733
+ }
734
+ catch {
735
+ traceClaude(traceLogger, "Injected OAuth credentials override failed.");
736
+ return null;
737
+ }
738
+ }
739
+ const credentialsPath = path.join(path.resolve(root), ".claude", ".credentials.json");
740
+ const cacheKey = credentialsPath;
741
+ const cached = claudeOauthCredentialsOutputCache.get(cacheKey);
742
+ if (cached) {
743
+ traceClaude(traceLogger, "OAuth credentials cache hit.");
744
+ return cached;
745
+ }
746
+ const pending = (async () => {
747
+ try {
748
+ const output = await fs.promises.readFile(credentialsPath, { encoding: "utf8" });
749
+ traceClaude(traceLogger, `Read OAuth credentials from ${credentialsPath}.`);
750
+ return output;
751
+ }
752
+ catch {
753
+ traceClaude(traceLogger, `No readable OAuth credentials at ${credentialsPath}.`);
754
+ return null;
755
+ }
756
+ })();
757
+ claudeOauthCredentialsOutputCache.set(cacheKey, pending);
758
+ return pending;
759
+ }
760
+ function parseClaudeOauthCredentials(output) {
761
+ if (!output) {
762
+ return null;
763
+ }
764
+ let payload;
765
+ try {
766
+ payload = JSON.parse(output);
767
+ }
768
+ catch {
769
+ return null;
770
+ }
771
+ const oauth = asRecord(asRecord(payload)?.claudeAiOauth);
772
+ if (!oauth) {
773
+ return null;
774
+ }
775
+ const subscriptionType = typeof oauth.subscriptionType === "string" && oauth.subscriptionType.trim()
776
+ ? oauth.subscriptionType.trim()
777
+ : null;
778
+ const rateLimitTier = typeof oauth.rateLimitTier === "string" && oauth.rateLimitTier.trim()
779
+ ? oauth.rateLimitTier.trim()
780
+ : null;
781
+ if (!subscriptionType && !rateLimitTier) {
782
+ return null;
783
+ }
784
+ return { subscriptionType, rateLimitTier };
815
785
  }
816
786
  async function readClaudeAuthStatusOutput(root, override, traceLogger) {
817
787
  if (override) {
@@ -859,10 +829,6 @@ async function readClaudeAuthStatusOutput(root, override, traceLogger) {
859
829
  claudeAuthStatusOutputCache.set(cacheKey, pending);
860
830
  return pending;
861
831
  }
862
- function parseClaudeSubscriptionType(output) {
863
- const snapshot = parseClaudeAuthStatusSnapshot(output);
864
- return snapshot?.subscriptionType ?? null;
865
- }
866
832
  function parseClaudeAuthStatusSnapshot(output) {
867
833
  if (!output) {
868
834
  return null;
@@ -1063,34 +1029,58 @@ function parseLiveUsageWindowSnapshots(usageOutput, now) {
1063
1029
  if (!usageOutput) {
1064
1030
  return [];
1065
1031
  }
1066
- const snapshots = new Map();
1067
1032
  const normalizedOutput = usageOutput.replace(ANSI_ESCAPE_SEQUENCE, "");
1033
+ const parsedLines = [];
1068
1034
  for (const line of normalizedOutput.split(/\r?\n/)) {
1069
1035
  const match = line
1070
1036
  .trim()
1071
- .match(/^Current\s+(session|week)(?:\s+\(([^)]+)\))?:\s+(\d+)%\s+used\b.*?\bresets\s+(.+)$/i);
1037
+ .match(/^Current\s+(session|week)(?:\s+\(([^)]+)\))?:\s+(\d+)%\s+used\b(?:.*?\bresets\s+(.+))?$/i);
1072
1038
  if (!match) {
1073
1039
  continue;
1074
1040
  }
1075
- const label = match[1].toLowerCase() === "session" ? "session" : "week";
1076
- const windowQualifier = (match[2] ?? "").trim().toLowerCase();
1077
1041
  const usedPercent = Number(match[3]);
1078
- const windowMinutes = label === "session" ? CLAUDE_SESSION_WINDOW_MINUTES : CLAUDE_WEEK_WINDOW_MINUTES;
1079
- const resetsAtMs = parseResetTimestampUtc(match[4], now.getTime(), windowMinutes);
1080
- if (!Number.isFinite(usedPercent) || !resetsAtMs) {
1042
+ if (!Number.isFinite(usedPercent)) {
1081
1043
  continue;
1082
1044
  }
1083
- const isSonnetOnlyWeek = label === "week" && windowQualifier === "sonnet only";
1084
- const limitId = isSonnetOnlyWeek ? "current-week-sonnet-only" : `current-${label}`;
1085
- snapshots.set(limitId, {
1086
- scope: label === "session" ? "primary" : "secondary",
1045
+ const label = match[1].toLowerCase() === "session" ? "session" : "week";
1046
+ parsedLines.push({
1087
1047
  label,
1088
- limitId,
1089
- modelScope: isSonnetOnlyWeek ? "sonnet-only" : "all-models",
1090
- modelType: isSonnetOnlyWeek ? "sonnet only" : undefined,
1048
+ windowQualifier: (match[2] ?? "").trim().toLowerCase(),
1091
1049
  usedPercent,
1050
+ windowMinutes: label === "session" ? CLAUDE_SESSION_WINDOW_MINUTES : CLAUDE_WEEK_WINDOW_MINUTES,
1051
+ resetString: match[4]?.trim() || null
1052
+ });
1053
+ }
1054
+ const resetMsByLabel = new Map();
1055
+ for (const parsed of parsedLines) {
1056
+ if (!parsed.resetString || resetMsByLabel.has(parsed.label)) {
1057
+ continue;
1058
+ }
1059
+ const resetsAtMs = parseResetTimestampUtc(parsed.resetString, now.getTime(), parsed.windowMinutes);
1060
+ if (resetsAtMs) {
1061
+ resetMsByLabel.set(parsed.label, resetsAtMs);
1062
+ }
1063
+ }
1064
+ const snapshots = new Map();
1065
+ for (const parsed of parsedLines) {
1066
+ const resetsAtMs = resetMsByLabel.get(parsed.label) ?? null;
1067
+ if (!resetsAtMs) {
1068
+ continue;
1069
+ }
1070
+ const isModelScopedWeek = parsed.label === "week" && parsed.windowQualifier !== "" && parsed.windowQualifier !== "all models";
1071
+ const modelFamily = isModelScopedWeek ? parsed.windowQualifier.replace(/\s+only$/, "") : null;
1072
+ const limitId = modelFamily
1073
+ ? `current-week-${modelFamily.replace(/\s+/g, "-")}-only`
1074
+ : `current-${parsed.label}`;
1075
+ snapshots.set(limitId, {
1076
+ scope: parsed.label === "session" ? "primary" : "secondary",
1077
+ label: parsed.label,
1078
+ limitId,
1079
+ modelFamily,
1080
+ modelType: modelFamily ? parsed.windowQualifier : undefined,
1081
+ usedPercent: parsed.usedPercent,
1092
1082
  resetsAtMs,
1093
- windowMinutes
1083
+ windowMinutes: parsed.windowMinutes
1094
1084
  });
1095
1085
  }
1096
1086
  return [...snapshots.values()].sort((left, right) => left.windowMinutes - right.windowMinutes || left.limitId.localeCompare(right.limitId));
@@ -1147,6 +1137,9 @@ function buildLiveLimitWindowRow(snapshot, planType, selectedEvents, now) {
1147
1137
  event.timestampMs < snapshot.resetsAtMs &&
1148
1138
  matchesClaudeLiveSnapshotModelScope(snapshot, event.modelId));
1149
1139
  const totals = sumUsageTotals(inWindowEvents.map((event) => event.totals));
1140
+ if (snapshot.usedPercent > 0 && totals.eventCount === 0) {
1141
+ totals.estimatedCreditsStatus = "unavailable";
1142
+ }
1150
1143
  const fallbackLastSeenMs = Math.min(now.getTime(), snapshot.resetsAtMs);
1151
1144
  const firstSeenMs = inWindowEvents.reduce((minimum, event) => Math.min(minimum, event.timestampMs), Number.POSITIVE_INFINITY);
1152
1145
  const lastSeenMs = inWindowEvents.reduce((maximum, event) => Math.max(maximum, event.timestampMs), Number.NEGATIVE_INFINITY);
@@ -1168,10 +1161,10 @@ function buildLiveLimitWindowRow(snapshot, planType, selectedEvents, now) {
1168
1161
  };
1169
1162
  }
1170
1163
  function matchesClaudeLiveSnapshotModelScope(snapshot, modelId) {
1171
- if (snapshot.modelScope !== "sonnet-only") {
1164
+ if (!snapshot.modelFamily) {
1172
1165
  return true;
1173
1166
  }
1174
- return modelId.toLowerCase().includes("sonnet");
1167
+ return modelId.toLowerCase().includes(snapshot.modelFamily);
1175
1168
  }
1176
1169
  function buildModelUsageRowsForEvents(events) {
1177
1170
  const byModel = new Map();
@@ -32,8 +32,11 @@ export function addUsageTotals(target, source) {
32
32
  target.totalTokens += source.totalTokens;
33
33
  target.estimatedCredits += source.estimatedCredits;
34
34
  target.eventCount += source.eventCount;
35
- if (source.cacheStatus === "unavailable") {
36
- target.cacheStatus = "unavailable";
35
+ if (source.cacheReadStatus === "unavailable") {
36
+ target.cacheReadStatus = "unavailable";
37
+ }
38
+ if (source.cacheWriteStatus === "unavailable") {
39
+ target.cacheWriteStatus = "unavailable";
37
40
  }
38
41
  if (source.estimatedCreditsStatus === "unavailable") {
39
42
  target.estimatedCreditsStatus = "unavailable";
@@ -0,0 +1,55 @@
1
+ import { resolveUsageRate } from "../pricing.js";
2
+ /**
3
+ * Copilot-specific estimated API-equivalent rate card (micro-credits per
4
+ * million tokens). This is intentionally separate from the Codex and
5
+ * Antigravity rate cards — Copilot bills the same model families at different
6
+ * effective rates, so there is no single shared source of truth to reuse.
7
+ */
8
+ export const RATE_CARD = {
9
+ "gpt-5-mini": { input: 25, cacheRead: 2.5, cacheWrite: 25, cacheWrite5m: 25, cacheWrite1h: 25, output: 200 },
10
+ "gpt-5.3-codex": { input: 175, cacheRead: 17.5, cacheWrite: 175, cacheWrite5m: 175, cacheWrite1h: 175, output: 1400 },
11
+ "gpt-5.4": { input: 250, cacheRead: 25, cacheWrite: 250, cacheWrite5m: 250, cacheWrite1h: 250, output: 1500, longContext: { thresholdTokens: 272000, rate: { input: 500, cacheRead: 50, cacheWrite: 500, cacheWrite5m: 500, cacheWrite1h: 500, output: 2250 } } },
12
+ "gpt-5.4-mini": { input: 75, cacheRead: 7.5, cacheWrite: 75, cacheWrite5m: 75, cacheWrite1h: 75, output: 450 },
13
+ "gpt-5.4-nano": { input: 20, cacheRead: 2, cacheWrite: 20, cacheWrite5m: 20, cacheWrite1h: 20, output: 125 },
14
+ "gpt-5.5": { input: 500, cacheRead: 50, cacheWrite: 500, cacheWrite5m: 500, cacheWrite1h: 500, output: 3000, longContext: { thresholdTokens: 272000, rate: { input: 1000, cacheRead: 100, cacheWrite: 1000, cacheWrite5m: 1000, cacheWrite1h: 1000, output: 4500 } } },
15
+ "claude-haiku-4-5": { input: 100, cacheRead: 10, cacheWrite: 125, cacheWrite5m: 125, cacheWrite1h: 200, output: 500 },
16
+ "claude-sonnet-4-5": { input: 300, cacheRead: 30, cacheWrite: 375, cacheWrite5m: 375, cacheWrite1h: 600, output: 1500 },
17
+ "claude-sonnet-4-6": { input: 300, cacheRead: 30, cacheWrite: 375, cacheWrite5m: 375, cacheWrite1h: 600, output: 1500 },
18
+ "claude-opus-4-5": { input: 500, cacheRead: 50, cacheWrite: 625, cacheWrite5m: 625, cacheWrite1h: 1000, output: 2500 },
19
+ "claude-opus-4-6": { input: 500, cacheRead: 50, cacheWrite: 625, cacheWrite5m: 625, cacheWrite1h: 1000, output: 2500 },
20
+ "claude-opus-4-7": { input: 500, cacheRead: 50, cacheWrite: 625, cacheWrite5m: 625, cacheWrite1h: 1000, output: 2500 },
21
+ "claude-opus-4-8": { input: 500, cacheRead: 50, cacheWrite: 625, cacheWrite5m: 625, cacheWrite1h: 1000, output: 2500 },
22
+ "claude-fable-5": { input: 1000, cacheRead: 100, cacheWrite: 1250, cacheWrite5m: 1250, cacheWrite1h: 2000, output: 5000 },
23
+ "gemini-2.5-pro": { input: 125, cacheRead: 12.5, cacheWrite: 125, cacheWrite5m: 125, cacheWrite1h: 125, output: 1000 },
24
+ "gemini-3-flash": { input: 50, cacheRead: 5, cacheWrite: 50, cacheWrite5m: 50, cacheWrite1h: 50, output: 300 },
25
+ "gemini-3.1-pro": { input: 200, cacheRead: 20, cacheWrite: 200, cacheWrite5m: 200, cacheWrite1h: 200, output: 1200, longContext: { thresholdTokens: 200000, rate: { input: 400, cacheRead: 40, cacheWrite: 400, cacheWrite5m: 400, cacheWrite1h: 400, output: 1800 } } },
26
+ "gemini-3.5-flash": { input: 150, cacheRead: 15, cacheWrite: 150, cacheWrite5m: 150, cacheWrite1h: 150, output: 900 },
27
+ "mai-code-1-flash": { input: 75, cacheRead: 7.5, cacheWrite: 75, cacheWrite5m: 75, cacheWrite1h: 75, output: 450 },
28
+ "raptor-mini": { input: 25, cacheRead: 2.5, cacheWrite: 25, cacheWrite5m: 25, cacheWrite1h: 25, output: 200 }
29
+ };
30
+ /**
31
+ * Model id prefixes that Copilot does not bill (inline completions / next-edit
32
+ * suggestions). These are zero-rated rather than "unknown" so they never turn
33
+ * aggregate credit totals unknown.
34
+ */
35
+ export const NON_BILLABLE_MODEL_PREFIXES = [
36
+ "copilot-nes",
37
+ "copilot-suggestion",
38
+ "copilot-suggestions"
39
+ ];
40
+ /**
41
+ * Canonicalize a Copilot model id. The exporter already emits stable,
42
+ * human-readable ids (including dated suffixes like `gpt-5.4-2026-03-01`), and
43
+ * the dashboard surfaces those verbatim, so this only guards the empty case.
44
+ * Prefix-based rate resolution (see {@link rateForCopilotModel}) handles dated
45
+ * suffixes without collapsing the displayed id.
46
+ */
47
+ export function normalizeCopilotModelId(modelId) {
48
+ return modelId || "unknown";
49
+ }
50
+ export function rateForCopilotModel(modelId, inputTokens) {
51
+ return resolveUsageRate(RATE_CARD, modelId, inputTokens, { prefixMatch: true });
52
+ }
53
+ export function isNonBillableCopilotModel(modelId) {
54
+ return NON_BILLABLE_MODEL_PREFIXES.some((prefix) => modelId === prefix || modelId.startsWith(`${prefix}-`));
55
+ }