letmecode 0.1.26 → 0.1.29

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/README.md CHANGED
@@ -1,8 +1,10 @@
1
1
  # LetMeCode
2
2
 
3
- Terminal AI real-money value usage dashboard for Codex, Claude, Copilot, and Antigravity.
3
+ Terminal AI token-usage and API-equivalent value dashboard for Codex, Claude, Copilot, and Antigravity.
4
4
 
5
- See your real $ usage data, inspect limits, see daily activity, and model-level token totals in a terminal UI.
5
+ Inspect provider limits, locally recorded token usage, daily activity, and model-level API-equivalent cost estimates in a terminal UI. Subscription quota percentages come from the providers; dollar values are estimates at public API rates, not subscription invoices.
6
+
7
+ Only complete current-format usage events are counted. Incomplete legacy Codex and Claude events are silently ignored instead of being priced with guessed defaults.
6
8
 
7
9
  ## Quick start
8
10
 
@@ -4,7 +4,7 @@ import { Box, Text, measureElement, useApp, useInput, useStdin, useStdout, rende
4
4
  import { buildHelpText, buildProviderStatsOptions, parseCliOptions } from "./cli-options.js";
5
5
  import { configureCopilotVsCodeLogging, createProviders } from "./providers/index.js";
6
6
  import { reportAnonymousUsage } from "./reporting.js";
7
- import { estimateLimitFullValue, selectLatestActiveLimitWindows } from "./providers/limits.js";
7
+ import { estimateLimitFullValue, resolveMeasuredUsedPercent, selectLatestActiveLimitWindows } from "./providers/limits.js";
8
8
  const ESC = String.fromCharCode(0x1b);
9
9
  // Normal mouse tracking (button press/release only) + SGR extended coordinates.
10
10
  // This makes the controls clickable while leaving Shift+drag native text selection
@@ -327,7 +327,7 @@ function SummaryPanel(props) {
327
327
  return (_jsxs(Box, { borderStyle: "round", paddingX: 1, flexDirection: "column", children: [_jsx(Text, { color: "cyan", children: "Summary" }), _jsxs(Box, { children: [_jsxs(Box, { flexDirection: "column", width: 45, children: [_jsx(DetailRow, { label: "Plan", value: summary.distinctPlanTypes.join(", ") || "none" }), _jsx(DetailRow, { label: "Models", value: summary.distinctModels.join(", ") || "none" }), _jsx(DetailRow, { label: "Period", value: period }), _jsx(Text, { children: " " }), _jsx(Text, { color: "cyan", children: "Usage" }), _jsx(DetailRow, { label: "Events", value: formatInteger(totals.eventCount) }), _jsx(DetailRow, { label: "Input", value: formatOverviewTokenCount(totals.inputTokens) }), _jsx(DetailRow, { label: "Output", value: formatOverviewTokenCount(totals.outputTokens) }), _jsx(DetailRow, { label: "Cache read", value: formatCacheOverviewTokenCount(totals.cacheReadStatus, totals.cacheReadInputTokens) }), _jsx(DetailRow, { label: "Reasoning", value: formatOverviewTokenCount(totals.reasoningOutputTokens) }), _jsx(DetailRow, { label: "Total", value: formatOverviewTokenCount(totals.totalTokens) }), _jsx(DetailRow, { label: "API equiv.", value: formatUsageUsd(totals) })] }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "cyan", children: "Efficiency" }), _jsx(DetailRow, { label: "Cache ratio", value: formatPercent(cacheRatio) }), (() => {
328
328
  const { label, value } = formatInputOutputRatio(totals, props.stats.providerId);
329
329
  return _jsx(DetailRow, { label: label, value: value, padLength: Math.max(14, label.length + 1), noSlice: true });
330
- })(), _jsx(DetailRow, { label: "Avg/event", value: `${formatOverviewTokenCount(averageTokensPerEvent)} tokens` }), _jsx(DetailRow, { label: "Cost/event", value: formatUnitUsd(costPerEvent) }), _jsx(Text, { children: " " }), _jsx(Text, { color: "cyan", children: "Data source" }), _jsx(DetailRow, { label: "Files", value: formatInteger(summary.filesScanned) }), _jsx(DetailRow, { label: "Lines", value: formatInteger(summary.linesRead) }), _jsx(DetailRow, { label: "Path", value: summary.rootPath })] })] })] }));
330
+ })(), _jsx(DetailRow, { label: "Avg/event", value: `${formatOverviewTokenCount(averageTokensPerEvent)} tokens` }), _jsx(DetailRow, { label: "API eq/event", value: formatUnitUsd(costPerEvent) }), _jsx(Text, { children: " " }), _jsx(Text, { color: "cyan", children: "Data source" }), _jsx(DetailRow, { label: "Files", value: formatInteger(summary.filesScanned) }), _jsx(DetailRow, { label: "Lines", value: formatInteger(summary.linesRead) }), _jsx(DetailRow, { label: "Path", value: summary.rootPath })] })] })] }));
331
331
  }
332
332
  function ContentPanel(props) {
333
333
  if (props.providerState.status === "loading") {
@@ -499,7 +499,7 @@ function buildLimitWindowTableRow(window, isActive) {
499
499
  formatUsageUsd(window.totals),
500
500
  // Extrapolated full value of the limit, rounded to a single figure here;
501
501
  // the details panel shows the unrounded ±1% range.
502
- formatLimitFullValueCompact(window.totals, window.maxUsedPercent)
502
+ formatLimitFullValueCompact(window.totals, resolveMeasuredUsedPercent(window))
503
503
  ]
504
504
  };
505
505
  }
@@ -543,7 +543,7 @@ function SelectionDetailsPanel(props) {
543
543
  }
544
544
  if (props.viewId === "limit-windows" && props.selectedLimitRow) {
545
545
  const row = props.selectedLimitRow;
546
- return (_jsxs(DetailsPanelFrame, { children: [_jsxs(Box, { children: [_jsxs(Box, { flexDirection: "column", width: 25, children: [_jsx(DetailRow, { label: "Plan", value: row.planType }), _jsx(DetailRow, { label: "Models", value: formatLimitWindowModels(row) }), _jsx(DetailRow, { label: "Window", value: formatCompactWindowMinutes(row.windowMinutes) }), _jsx(DetailRow, { label: "Usage", value: formatUsedPercentRange(row.minUsedPercent, row.maxUsedPercent) }), _jsx(DetailRow, { label: "Events", value: formatInteger(row.eventCount) })] }), _jsxs(Box, { flexDirection: "column", children: [_jsx(DetailRow, { label: "Period", value: `${formatCompactLocalDateTime(row.startTimeUtcIso)} → ${formatCompactLocalDateTime(row.endTimeUtcIso)}` }), _jsx(DetailRow, { label: "Input", value: formatInteger(row.totals.inputTokens) }), _jsx(DetailRow, { label: "Cache read", value: formatCacheTokens(row.totals.cacheReadStatus, row.totals.cacheReadInputTokens) }), _jsx(DetailRow, { label: "Cache write", value: formatCacheTokens(row.totals.cacheWriteStatus, row.totals.cacheWriteInputTokens) }), _jsx(DetailRow, { label: "Output", value: formatInteger(row.totals.outputTokens) }), _jsx(DetailRow, { label: "Total", value: formatInteger(row.totals.totalTokens) })] })] }), _jsx(DetailRow, { label: "API eq.", value: formatUsageUsd(row.totals), note: "API equivalent cost" }), _jsx(DetailRow, { label: "Full value", value: formatLimitFullValueRange(row.totals, row.maxUsedPercent), note: "API equivalent cost if 100% will be used" })] }));
546
+ return (_jsxs(DetailsPanelFrame, { children: [_jsxs(Box, { children: [_jsxs(Box, { flexDirection: "column", width: 25, children: [_jsx(DetailRow, { label: "Plan", value: row.planType }), _jsx(DetailRow, { label: "Models", value: formatLimitWindowModels(row) }), _jsx(DetailRow, { label: "Window", value: formatCompactWindowMinutes(row.windowMinutes) }), _jsx(DetailRow, { label: "Usage", value: formatUsedPercentRange(row.minUsedPercent, row.maxUsedPercent) }), _jsx(DetailRow, { label: "Measured", value: formatMeasuredUsedPercent(row) }), _jsx(DetailRow, { label: "Events", value: formatInteger(row.eventCount) })] }), _jsxs(Box, { flexDirection: "column", children: [_jsx(DetailRow, { label: "Period", value: `${formatCompactLocalDateTime(row.startTimeUtcIso)} → ${formatCompactLocalDateTime(row.endTimeUtcIso)}` }), _jsx(DetailRow, { label: "Input", value: formatInteger(row.totals.inputTokens) }), _jsx(DetailRow, { label: "Cache read", value: formatCacheTokens(row.totals.cacheReadStatus, row.totals.cacheReadInputTokens) }), _jsx(DetailRow, { label: "Cache write", value: formatCacheTokens(row.totals.cacheWriteStatus, row.totals.cacheWriteInputTokens) }), _jsx(DetailRow, { label: "Output", value: formatInteger(row.totals.outputTokens) }), _jsx(DetailRow, { label: "Total", value: formatInteger(row.totals.totalTokens) })] })] }), _jsx(DetailRow, { label: "API eq.", value: formatUsageUsd(row.totals), note: "API equivalent cost" }), _jsx(DetailRow, { label: "Full value", value: formatLimitFullValueRange(row.totals, resolveMeasuredUsedPercent(row)), note: "API equivalent cost extrapolated from the paired token/usage interval" })] }));
547
547
  }
548
548
  if (props.viewId === "day-to-day-analyses" && props.selectedDayRow) {
549
549
  const row = props.selectedDayRow;
@@ -621,7 +621,7 @@ function formatUsageUsd(totals, modelId) {
621
621
  : formatUsd(totals.estimatedCredits * CODEX_CREDIT_COST_USD);
622
622
  }
623
623
  function isInternalUsageModel(modelId) {
624
- return modelId === "codex-auto-review" || modelId === "<synthetic>";
624
+ return modelId === "<synthetic>";
625
625
  }
626
626
  function formatUsd(value) {
627
627
  const roundedUpValue = value > 0 ? Math.ceil(value * 100) / 100 : value;
@@ -641,11 +641,10 @@ function formatUsdWhole(value) {
641
641
  });
642
642
  }
643
643
  // Extrapolate the limit's full USD value from the observed API-equivalent cost
644
- // and how much of the limit it represents. Uses the highest reported percent
645
- // (the latest cumulative usage) and returns "-" when the cost is unknown or the
646
- // percent is missing.
644
+ // and how much of the limit it represents. The percentage must cover the same
645
+ // paired interval as the token totals; returns "-" when either value is unknown.
647
646
  function limitFullValueUsd(totals, usedPercent) {
648
- if (totals.estimatedCreditsStatus === "unavailable") {
647
+ if (totals.estimatedCreditsStatus === "unavailable" || usedPercent === null) {
649
648
  return null;
650
649
  }
651
650
  const usedUsd = totals.estimatedCredits * CODEX_CREDIT_COST_USD;
@@ -727,6 +726,10 @@ function formatUsedPercentRange(minUsedPercent, maxUsedPercent) {
727
726
  ? fmt(minUsedPercent)
728
727
  : `${fmt(minUsedPercent)}–${fmt(maxUsedPercent)}`;
729
728
  }
729
+ function formatMeasuredUsedPercent(window) {
730
+ const measuredUsedPercent = resolveMeasuredUsedPercent(window);
731
+ return measuredUsedPercent === null ? "-" : formatPercent(measuredUsedPercent);
732
+ }
730
733
  function formatCompactWindowMinutes(value) {
731
734
  const hours = value / 60;
732
735
  if (hours >= 24) {
@@ -9,27 +9,19 @@ import { UsageProviderBase, addUsageTotals, createEmptyUsageTotals, sumUsageTota
9
9
  import { applyRateLimits, asRecord, buildWindowLists, createLimitWindowAggregates, numberOrZero } from "./limits.js";
10
10
  import { addDailyUsage, buildDailyUsageRows, createDailyUsageAggregates } from "./daily.js";
11
11
  import { resolveUsageRate } from "./pricing.js";
12
+ // Source: https://platform.claude.com/docs/en/about-claude/pricing
13
+ // (checked 2026-09-05).
12
14
  const RATE_CARD = {
15
+ "claude-fable-5-1": { input: 10, cacheRead: 0.25, cacheWrite: 12.5, cacheWrite5m: 12.5, cacheWrite1h: 20, output: 50 },
13
16
  "claude-fable-5": { input: 10, cacheRead: 1, cacheWrite: 12.5, cacheWrite5m: 12.5, cacheWrite1h: 20, output: 50 },
17
+ "claude-opus-5": { input: 5, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite5m: 6.25, cacheWrite1h: 10, output: 25 },
14
18
  "claude-opus-4-8": { input: 5, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite5m: 6.25, cacheWrite1h: 10, output: 25 },
15
19
  "claude-opus-4-7": { input: 5, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite5m: 6.25, cacheWrite1h: 10, output: 25 },
16
20
  "claude-opus-4-6": { input: 5, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite5m: 6.25, cacheWrite1h: 10, output: 25 },
17
21
  "claude-opus-4-5": { input: 5, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite5m: 6.25, cacheWrite1h: 10, output: 25 },
18
22
  "claude-opus-4-1": { input: 15, cacheRead: 1.5, cacheWrite: 18.75, cacheWrite5m: 18.75, cacheWrite1h: 30, output: 75 },
19
23
  "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
- },
24
+ "claude-sonnet-5": { input: 2, cacheRead: 0.2, cacheWrite: 2.5, cacheWrite5m: 2.5, cacheWrite1h: 4, output: 10 },
33
25
  "claude-sonnet-4-6": { input: 3, cacheRead: 0.3, cacheWrite: 3.75, cacheWrite5m: 3.75, cacheWrite1h: 6, output: 15 },
34
26
  "claude-sonnet-4-5": { input: 3, cacheRead: 0.3, cacheWrite: 3.75, cacheWrite5m: 3.75, cacheWrite1h: 6, output: 15 },
35
27
  "claude-sonnet-4": { input: 3, cacheRead: 0.3, cacheWrite: 3.75, cacheWrite5m: 3.75, cacheWrite1h: 6, output: 15 },
@@ -112,15 +104,13 @@ export class ClaudeUsageProvider extends UsageProviderBase {
112
104
  }
113
105
  }
114
106
  const selectedEvents = [
115
- ...new Set(parsedEvents.keyedEvents.values()),
116
- ...parsedEvents.unkeyedEvents
107
+ ...new Set(parsedEvents.keyedEvents.values())
117
108
  ];
118
109
  traceClaude(options.traceLogger, [
119
110
  `Transcript selection summary: filesWithMatches=${parseTotals.filesScanned}/${parsedSessionFiles.length}`,
120
111
  `selectedEvents=${selectedEvents.length}`,
121
112
  `duplicateUsageKeys=${parsedEvents.duplicateUsageKeys}`,
122
- `duplicateUsageKeyCollisions=${parsedEvents.duplicateUsageKeyCollisions}`,
123
- `duplicateUnkeyedEvents=${parsedEvents.duplicateUnkeyedEvents}`
113
+ `duplicateUsageKeyCollisions=${parsedEvents.duplicateUsageKeyCollisions}`
124
114
  ].join(" "));
125
115
  if (selectedEvents.length === 0 && parsedSessionFiles.length > 0) {
126
116
  traceClaude(options.traceLogger, "No assistant usage events were found in the parsed Claude session files.");
@@ -142,9 +132,6 @@ export class ClaudeUsageProvider extends UsageProviderBase {
142
132
  if (options.verbose && parsedEvents.duplicateUsageKeyCollisions > 0) {
143
133
  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.`);
144
134
  }
145
- if (options.verbose && parsedEvents.duplicateUnkeyedEvents > 0) {
146
- warnings.push(`Collapsed ${parsedEvents.duplicateUnkeyedEvents} adjacent duplicate unkeyed Claude usage event(s) by usage signature.`);
147
- }
148
135
  const modelUsage = [...byModel.entries()]
149
136
  .map(([modelId, totals]) => ({ modelId, totals }))
150
137
  .sort((left, right) => right.totals.estimatedCredits - left.totals.estimatedCredits);
@@ -152,7 +139,7 @@ export class ClaudeUsageProvider extends UsageProviderBase {
152
139
  .map((row) => row.modelId)
153
140
  .filter((modelId) => !resolveRate(modelId) && !isInternalClaudeModel(modelId));
154
141
  if (unknownPricedModels.length > 0) {
155
- warnings.push(`No credit rate configured for: ${unknownPricedModels.join(", ")}.`);
142
+ warnings.push(`No API-equivalent rate configured for: ${unknownPricedModels.join(", ")}.`);
156
143
  }
157
144
  if (parsedSessionFiles.length === 0) {
158
145
  warnings.push(`No Claude session files found under ${sessionsRoot}.`);
@@ -215,17 +202,18 @@ export class ClaudeUsageProvider extends UsageProviderBase {
215
202
  function normalizeUsage(value) {
216
203
  const usage = asRecord(value) ?? {};
217
204
  const cacheCreation = asRecord(usage.cache_creation);
205
+ const serverToolUse = asRecord(usage.server_tool_use);
218
206
  const cacheCreation5mInputTokens = numberOrZero(cacheCreation?.ephemeral_5m_input_tokens);
219
207
  const cacheCreation1hInputTokens = numberOrZero(cacheCreation?.ephemeral_1h_input_tokens);
220
- const cacheCreationInputTokens = Math.max(numberOrZero(usage.cache_creation_input_tokens), cacheCreation5mInputTokens + cacheCreation1hInputTokens);
221
208
  return {
222
209
  inputTokens: numberOrZero(usage.input_tokens),
223
210
  cacheReadInputTokens: numberOrZero(usage.cache_read_input_tokens),
224
- cacheCreationInputTokens,
225
211
  cacheCreation5mInputTokens,
226
212
  cacheCreation1hInputTokens,
227
213
  outputTokens: numberOrZero(usage.output_tokens),
228
- inferenceGeo: String(usage.inference_geo ?? "")
214
+ inferenceGeo: String(usage.inference_geo ?? ""),
215
+ speed: String(usage.speed ?? ""),
216
+ webSearchRequests: numberOrZero(serverToolUse?.web_search_requests)
229
217
  };
230
218
  }
231
219
  function resolveRate(modelId, timestampMs) {
@@ -243,18 +231,25 @@ function creditsFor(modelId, usage, timestampMs) {
243
231
  // The US inference surcharge must match regardless of the casing the source
244
232
  // reports (e.g. "us", "US"), so compare case-insensitively.
245
233
  const inferenceMultiplier = usage.inferenceGeo.trim().toLowerCase() === "us" ? 1.1 : 1;
246
- return (((usage.inputTokens / 1000000) * rate.input +
234
+ const speedMultiplier = usage.speed.trim().toLowerCase() === "fast" && isClaudeFastPricedModel(modelId)
235
+ ? 2
236
+ : 1;
237
+ const tokenCostUsd = (usage.inputTokens / 1000000) * rate.input +
247
238
  (usage.cacheReadInputTokens / 1000000) * rate.cacheRead +
248
239
  (cacheWriteBreakdown.cacheWrite5mInputTokens / 1000000) * rate.cacheWrite5m +
249
240
  (cacheWriteBreakdown.cacheWrite1hInputTokens / 1000000) * rate.cacheWrite1h +
250
- (usage.outputTokens / 1000000) * rate.output) *
251
- inferenceMultiplier *
252
- USD_TO_CREDITS);
241
+ (usage.outputTokens / 1000000) * rate.output;
242
+ const webSearchCostUsd = usage.webSearchRequests * 0.01;
243
+ return (tokenCostUsd * speedMultiplier * inferenceMultiplier + webSearchCostUsd) * USD_TO_CREDITS;
244
+ }
245
+ function isClaudeFastPricedModel(modelId) {
246
+ return ["claude-opus-5", "claude-opus-4-8"].some((candidate) => modelId === candidate || modelId.startsWith(`${candidate}-`));
253
247
  }
254
248
  function usageToTotals(modelId, usage, timestampMs) {
255
249
  const cacheWriteBreakdown = resolveClaudeCacheWriteBreakdown(usage);
256
250
  const cacheWriteInputTokens = cacheWriteBreakdown.cacheWrite5mInputTokens +
257
251
  cacheWriteBreakdown.cacheWrite1hInputTokens;
252
+ const rateKnown = Boolean(resolveRate(modelId, timestampMs)) || isInternalClaudeModel(modelId);
258
253
  return {
259
254
  inputTokens: usage.inputTokens,
260
255
  outputTokens: usage.outputTokens,
@@ -268,7 +263,8 @@ function usageToTotals(modelId, usage, timestampMs) {
268
263
  cacheWriteInputTokens +
269
264
  usage.outputTokens,
270
265
  estimatedCredits: creditsFor(modelId, usage, timestampMs),
271
- eventCount: 1
266
+ eventCount: 1,
267
+ estimatedCreditsStatus: rateKnown ? "known" : "unavailable"
272
268
  };
273
269
  }
274
270
  function addModelUsage(byModel, modelId, deltaTotals) {
@@ -278,10 +274,8 @@ function addModelUsage(byModel, modelId, deltaTotals) {
278
274
  byModel.set(resolvedModelId, totals);
279
275
  }
280
276
  function resolveClaudeCacheWriteBreakdown(usage) {
281
- const cacheWriteKnownTokens = usage.cacheCreation5mInputTokens + usage.cacheCreation1hInputTokens;
282
- const cacheWriteFallbackTokens = Math.max(0, usage.cacheCreationInputTokens - cacheWriteKnownTokens);
283
277
  return {
284
- cacheWrite5mInputTokens: usage.cacheCreation5mInputTokens + cacheWriteFallbackTokens,
278
+ cacheWrite5mInputTokens: usage.cacheCreation5mInputTokens,
285
279
  cacheWrite1hInputTokens: usage.cacheCreation1hInputTokens
286
280
  };
287
281
  }
@@ -436,7 +430,7 @@ async function parseSessionFile(filePath) {
436
430
  }
437
431
  const message = asRecord(payloadObject.message);
438
432
  const usage = asRecord(message?.usage);
439
- if (!usage) {
433
+ if (!usage || !isCurrentClaudeUsageEvent(payloadObject, message, usage)) {
440
434
  continue;
441
435
  }
442
436
  const modelId = String(message?.model ?? "unknown");
@@ -466,6 +460,21 @@ async function parseSessionFile(filePath) {
466
460
  events
467
461
  };
468
462
  }
463
+ function isCurrentClaudeUsageEvent(payloadObject, message, usage) {
464
+ return (typeof payloadObject.version === "string" &&
465
+ payloadObject.version.length > 0 &&
466
+ typeof payloadObject.sessionId === "string" &&
467
+ payloadObject.sessionId.length > 0 &&
468
+ typeof payloadObject.requestId === "string" &&
469
+ payloadObject.requestId.length > 0 &&
470
+ typeof message?.id === "string" &&
471
+ message.id.length > 0 &&
472
+ typeof usage.service_tier === "string" &&
473
+ typeof usage.speed === "string" &&
474
+ typeof usage.inference_geo === "string" &&
475
+ asRecord(usage.cache_creation) !== null &&
476
+ asRecord(usage.server_tool_use) !== null);
477
+ }
469
478
  function buildUsageEventKeys(payloadObject, message) {
470
479
  const sessionId = String(payloadObject.sessionId ?? "");
471
480
  const requestId = typeof payloadObject.requestId === "string" ? payloadObject.requestId : "";
@@ -483,22 +492,20 @@ function buildUsageSignatureFromParts(sessionId, modelId, usage) {
483
492
  sessionId,
484
493
  modelId,
485
494
  usage.inputTokens,
486
- usage.cacheCreationInputTokens,
487
495
  usage.cacheCreation5mInputTokens,
488
496
  usage.cacheCreation1hInputTokens,
489
497
  usage.cacheReadInputTokens,
490
498
  usage.outputTokens,
491
- usage.inferenceGeo
499
+ usage.inferenceGeo,
500
+ usage.speed,
501
+ usage.webSearchRequests
492
502
  ].join("|");
493
503
  }
494
504
  function createParsedUsageEventAccumulator() {
495
505
  return {
496
506
  keyedEvents: new Map(),
497
- unkeyedEvents: [],
498
- lastUnkeyedEventsBySignature: new Map(),
499
507
  duplicateUsageKeys: 0,
500
- duplicateUsageKeyCollisions: 0,
501
- duplicateUnkeyedEvents: 0
508
+ duplicateUsageKeyCollisions: 0
502
509
  };
503
510
  }
504
511
  function recordParsedUsageEvent(parsedEvents, event) {
@@ -526,23 +533,6 @@ function recordParsedUsageEvent(parsedEvents, event) {
526
533
  }
527
534
  return;
528
535
  }
529
- const previousRecord = parsedEvents.lastUnkeyedEventsBySignature.get(event.usageSignature);
530
- if (!previousRecord || !canCollapseAdjacentUnkeyedUsageEvents(previousRecord.event, event)) {
531
- parsedEvents.unkeyedEvents.push(event);
532
- parsedEvents.lastUnkeyedEventsBySignature.set(event.usageSignature, {
533
- event,
534
- index: parsedEvents.unkeyedEvents.length - 1
535
- });
536
- return;
537
- }
538
- parsedEvents.duplicateUnkeyedEvents += 1;
539
- if (normalizeTimestamp(event.timestampMs) > normalizeTimestamp(previousRecord.event.timestampMs)) {
540
- parsedEvents.unkeyedEvents[previousRecord.index] = event;
541
- parsedEvents.lastUnkeyedEventsBySignature.set(event.usageSignature, {
542
- event,
543
- index: previousRecord.index
544
- });
545
- }
546
536
  }
547
537
  function mergeParsedUsageEvents(previous, next) {
548
538
  // Same-key events are repeated/streamed snapshots of one logical request. Rather than
@@ -594,9 +584,6 @@ function selectMergedEventModelId(primary, other) {
594
584
  }
595
585
  return primary.modelId;
596
586
  }
597
- function canCollapseAdjacentUnkeyedUsageEvents(previous, next) {
598
- return previous.filePath === next.filePath && next.lineNumber === previous.lineNumber + 1;
599
- }
600
587
  function extractUsageKeySessionId(usageKeys) {
601
588
  const usageKey = usageKeys[0];
602
589
  if (!usageKey) {
@@ -1068,6 +1055,19 @@ function parseLiveUsageWindowSnapshots(usageOutput, now) {
1068
1055
  resetMsByLabel.set(parsed.label, resetsAtMs);
1069
1056
  }
1070
1057
  }
1058
+ // Claude omits reset timestamps while an entire bucket is displayed as 0%.
1059
+ // Keep that authoritative zero instead of silently dropping the window. The
1060
+ // provisional range starts at the sample time, so no earlier transcript
1061
+ // events are falsely paired with the zero-percent measurement.
1062
+ for (const label of ["session", "week"]) {
1063
+ const sameLabelLines = parsedLines.filter((parsed) => parsed.label === label);
1064
+ if (!resetMsByLabel.has(label) &&
1065
+ sameLabelLines.length > 0 &&
1066
+ sameLabelLines.every((parsed) => parsed.usedPercent === 0)) {
1067
+ const windowMinutes = label === "session" ? CLAUDE_SESSION_WINDOW_MINUTES : CLAUDE_WEEK_WINDOW_MINUTES;
1068
+ resetMsByLabel.set(label, now.getTime() + windowMinutes * 60000);
1069
+ }
1070
+ }
1071
1071
  const snapshots = new Map();
1072
1072
  for (const parsed of parsedLines) {
1073
1073
  const resetsAtMs = resetMsByLabel.get(parsed.label) ?? null;
@@ -1162,6 +1162,9 @@ function buildLiveLimitWindowRow(snapshot, planType, selectedEvents, now) {
1162
1162
  lastSeenUtcIso: toUtcIso(Number.isFinite(lastSeenMs) ? lastSeenMs : fallbackLastSeenMs),
1163
1163
  minUsedPercent: snapshot.usedPercent,
1164
1164
  maxUsedPercent: snapshot.usedPercent,
1165
+ measuredUsedPercent: snapshot.usedPercent > 0 && totals.eventCount === 0
1166
+ ? null
1167
+ : snapshot.usedPercent,
1165
1168
  totals,
1166
1169
  modelUsage: buildModelUsageRowsForEvents(inWindowEvents),
1167
1170
  eventCount: totals.eventCount
@@ -10,50 +10,90 @@ import { resolveUsageRate } from "./pricing.js";
10
10
  // One credit equals $0.01 (see CODEX_CREDIT_COST_USD in index.tsx), so credits
11
11
  // equal USD * 100. Rate cards are expressed in the model's actual API price in
12
12
  // USD per 1M tokens and scaled to credits in creditsFor, matching the Claude
13
- // provider. These are the real gpt-5.* API prices, not the (4x cheaper) Codex
13
+ // provider. These are the real OpenAI API prices, not the (4x cheaper) Codex
14
14
  // subscription credit prices.
15
+ // Source: https://developers.openai.com/api/docs/pricing (checked 2026-09-05).
16
+ // GPT-5.6 Sol's current promotional rate is guaranteed only through at least
17
+ // 2026-11-21, so it must be rechecked after that date.
15
18
  const USD_TO_CREDITS = 100;
19
+ const GPT_6_ASTRA_RATE = {
20
+ input: 10,
21
+ cacheRead: 1,
22
+ cacheWrite: 12.5,
23
+ cacheWrite5m: 12.5,
24
+ cacheWrite1h: 12.5,
25
+ output: 50,
26
+ longContext: {
27
+ thresholdTokens: 272000,
28
+ rate: { input: 20, cacheRead: 2, cacheWrite: 25, cacheWrite5m: 25, cacheWrite1h: 25, output: 75 }
29
+ }
30
+ };
16
31
  const GPT_5_6_SOL_RATE = {
17
- input: 5,
18
- cacheRead: 0.5,
19
- cacheWrite: 6.25,
20
- cacheWrite5m: 6.25,
21
- cacheWrite1h: 6.25,
22
- output: 30,
32
+ input: 4,
33
+ cacheRead: 0.4,
34
+ cacheWrite: 5,
35
+ cacheWrite5m: 5,
36
+ cacheWrite1h: 5,
37
+ output: 20,
23
38
  longContext: {
24
39
  thresholdTokens: 272000,
25
- rate: { input: 10, cacheRead: 1, cacheWrite: 12.5, cacheWrite5m: 12.5, cacheWrite1h: 12.5, output: 45 }
40
+ rate: { input: 8, cacheRead: 0.8, cacheWrite: 10, cacheWrite5m: 10, cacheWrite1h: 10, output: 30 }
26
41
  }
27
42
  };
28
43
  const RATE_CARD = {
44
+ "gpt-6-astra": GPT_6_ASTRA_RATE,
29
45
  "gpt-5.6-sol": GPT_5_6_SOL_RATE,
30
46
  "gpt-5.6-terra": {
31
- input: 2.5,
32
- cacheRead: 0.25,
33
- cacheWrite: 3.125,
34
- cacheWrite5m: 3.125,
35
- cacheWrite1h: 3.125,
36
- output: 15,
47
+ input: 2,
48
+ cacheRead: 0.2,
49
+ cacheWrite: 2.5,
50
+ cacheWrite5m: 2.5,
51
+ cacheWrite1h: 2.5,
52
+ output: 12,
37
53
  longContext: {
38
54
  thresholdTokens: 272000,
39
- rate: { input: 5, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite5m: 6.25, cacheWrite1h: 6.25, output: 22.5 }
55
+ rate: { input: 4, cacheRead: 0.4, cacheWrite: 5, cacheWrite5m: 5, cacheWrite1h: 5, output: 18 }
40
56
  }
41
57
  },
42
58
  "gpt-5.6-luna": {
43
- input: 1,
44
- cacheRead: 0.1,
45
- cacheWrite: 1.25,
46
- cacheWrite5m: 1.25,
47
- cacheWrite1h: 1.25,
48
- output: 6,
59
+ input: 0.2,
60
+ cacheRead: 0.02,
61
+ cacheWrite: 0.25,
62
+ cacheWrite5m: 0.25,
63
+ cacheWrite1h: 0.25,
64
+ output: 1.2,
65
+ longContext: {
66
+ thresholdTokens: 272000,
67
+ rate: { input: 0.4, cacheRead: 0.04, cacheWrite: 0.5, cacheWrite5m: 0.5, cacheWrite1h: 0.5, output: 1.8 }
68
+ }
69
+ },
70
+ "gpt-5.5": {
71
+ input: 5,
72
+ cacheRead: 0.5,
73
+ cacheWrite: 0,
74
+ cacheWrite5m: 0,
75
+ cacheWrite1h: 0,
76
+ output: 30,
77
+ longContext: {
78
+ thresholdTokens: 272000,
79
+ rate: { input: 10, cacheRead: 1, cacheWrite: 0, cacheWrite5m: 0, cacheWrite1h: 0, output: 45 }
80
+ }
81
+ },
82
+ "gpt-5.4": {
83
+ input: 2.5,
84
+ cacheRead: 0.25,
85
+ cacheWrite: 0,
86
+ cacheWrite5m: 0,
87
+ cacheWrite1h: 0,
88
+ output: 15,
49
89
  longContext: {
50
90
  thresholdTokens: 272000,
51
- rate: { input: 2, cacheRead: 0.2, cacheWrite: 2.5, cacheWrite5m: 2.5, cacheWrite1h: 2.5, output: 9 }
91
+ rate: { input: 5, cacheRead: 0.5, cacheWrite: 0, cacheWrite5m: 0, cacheWrite1h: 0, output: 22.5 }
52
92
  }
53
93
  },
54
- "gpt-5.5": { input: 5, cacheRead: 0.5, cacheWrite: 5, cacheWrite5m: 5, cacheWrite1h: 5, output: 30 },
55
- "gpt-5.4": { input: 2.5, cacheRead: 0.25, cacheWrite: 2.5, cacheWrite5m: 2.5, cacheWrite1h: 2.5, output: 15 },
56
- "gpt-5.4-mini": { input: 0.75, cacheRead: 0.075, cacheWrite: 0.75, cacheWrite5m: 0.75, cacheWrite1h: 0.75, output: 4.5 }
94
+ "gpt-5.4-mini": { input: 0.75, cacheRead: 0.075, cacheWrite: 0, cacheWrite5m: 0, cacheWrite1h: 0, output: 4.5 },
95
+ "gpt-5.3-codex": { input: 1.75, cacheRead: 0.175, cacheWrite: 0, cacheWrite5m: 0, cacheWrite1h: 0, output: 14 },
96
+ "gpt-5.2": { input: 1.75, cacheRead: 0.175, cacheWrite: 0, cacheWrite5m: 0, cacheWrite1h: 0, output: 14 }
57
97
  };
58
98
  export class CodexUsageProvider extends UsageProviderBase {
59
99
  constructor(options = {}) {
@@ -61,7 +101,11 @@ export class CodexUsageProvider extends UsageProviderBase {
61
101
  this.root = path.resolve(options.root ?? os.homedir());
62
102
  }
63
103
  async getStats(_options = {}) {
64
- const sessionsRoot = path.join(this.root, ".codex", "sessions");
104
+ const codexRoot = path.join(this.root, ".codex");
105
+ const sessionRoots = [
106
+ path.join(codexRoot, "sessions"),
107
+ path.join(codexRoot, "archived_sessions")
108
+ ];
65
109
  const knownModels = await readCodexModelMetadata(this.root);
66
110
  const userIdHash = await readCodexUserIdHash(this.root, this.label);
67
111
  const byModel = new Map();
@@ -75,12 +119,22 @@ export class CodexUsageProvider extends UsageProviderBase {
75
119
  tokenEvents: 0,
76
120
  malformedLines: 0
77
121
  };
78
- for await (const file of walkSessionFiles(sessionsRoot)) {
79
- parseTotals.filesScanned += 1;
80
- const fileStats = await parseSessionFile(file, byModel, byDay, windows, planTypes, knownModels);
81
- parseTotals.linesRead += fileStats.linesRead;
82
- parseTotals.tokenEvents += fileStats.tokenEvents;
83
- parseTotals.malformedLines += fileStats.malformedLines;
122
+ const seenSessionFiles = new Set();
123
+ for (const sessionRoot of sessionRoots) {
124
+ for await (const file of walkSessionFiles(sessionRoot)) {
125
+ // Archiving normally moves a rollout, but guard against a transient copy
126
+ // existing in both locations while Codex updates its session index.
127
+ const sessionFileId = path.basename(file);
128
+ if (seenSessionFiles.has(sessionFileId)) {
129
+ continue;
130
+ }
131
+ seenSessionFiles.add(sessionFileId);
132
+ parseTotals.filesScanned += 1;
133
+ const fileStats = await parseSessionFile(file, byModel, byDay, windows, planTypes, knownModels);
134
+ parseTotals.linesRead += fileStats.linesRead;
135
+ parseTotals.tokenEvents += fileStats.tokenEvents;
136
+ parseTotals.malformedLines += fileStats.malformedLines;
137
+ }
84
138
  }
85
139
  if (parseTotals.malformedLines > 0) {
86
140
  warnings.push(`Skipped ${parseTotals.malformedLines} malformed JSONL line(s).`);
@@ -89,13 +143,14 @@ export class CodexUsageProvider extends UsageProviderBase {
89
143
  .map(([modelId, totals]) => ({ modelId, totals }))
90
144
  .sort((left, right) => right.totals.estimatedCredits - left.totals.estimatedCredits);
91
145
  const unknownPricedModels = modelUsage
146
+ .filter((row) => row.totals.totalTokens > 0)
92
147
  .map((row) => row.modelId)
93
148
  .filter((modelId) => !rateForCodexModel(modelId) && !isAssumedZeroRatedCodexModel(modelId, knownModels));
94
149
  if (unknownPricedModels.length > 0) {
95
- warnings.push(`No credit rate configured for: ${unknownPricedModels.join(", ")}.`);
150
+ warnings.push(`No API-equivalent rate configured for: ${unknownPricedModels.join(", ")}.`);
96
151
  }
97
152
  if (parseTotals.filesScanned === 0) {
98
- warnings.push(`No Codex session files found under ${sessionsRoot}.`);
153
+ warnings.push(`No Codex session files found under ${codexRoot}.`);
99
154
  }
100
155
  const summaryTotals = sumUsageTotals(modelUsage.map((row) => row.totals));
101
156
  const dayUsage = buildDailyUsageRows(byDay);
@@ -110,8 +165,8 @@ export class CodexUsageProvider extends UsageProviderBase {
110
165
  totals: summaryTotals,
111
166
  distinctModels: modelUsage.map((row) => row.modelId),
112
167
  distinctPlanTypes: [...planTypes].sort(),
113
- rootLabel: "~/.codex/sessions",
114
- rootPath: sessionsRoot
168
+ rootLabel: "~/.codex",
169
+ rootPath: codexRoot
115
170
  },
116
171
  modelUsage,
117
172
  dayUsage,
@@ -247,6 +302,7 @@ function createEmptyRawUsage() {
247
302
  return {
248
303
  inputTokens: 0,
249
304
  cachedInputTokens: 0,
305
+ cacheWriteInputTokens: 0,
250
306
  outputTokens: 0,
251
307
  reasoningOutputTokens: 0,
252
308
  totalTokens: 0
@@ -257,58 +313,78 @@ function normalizeRawUsage(value) {
257
313
  return {
258
314
  inputTokens: numberOrZero(usage.input_tokens),
259
315
  cachedInputTokens: numberOrZero(usage.cached_input_tokens),
316
+ cacheWriteInputTokens: numberOrZero(usage.cache_write_input_tokens),
260
317
  outputTokens: numberOrZero(usage.output_tokens),
261
318
  reasoningOutputTokens: numberOrZero(usage.reasoning_output_tokens),
262
319
  totalTokens: numberOrZero(usage.total_tokens)
263
320
  };
264
321
  }
265
- function subtractRawUsage(current, previous) {
266
- return {
267
- inputTokens: Math.max(0, current.inputTokens - previous.inputTokens),
268
- cachedInputTokens: Math.max(0, current.cachedInputTokens - previous.cachedInputTokens),
269
- outputTokens: Math.max(0, current.outputTokens - previous.outputTokens),
270
- reasoningOutputTokens: Math.max(0, current.reasoningOutputTokens - previous.reasoningOutputTokens),
271
- totalTokens: Math.max(0, current.totalTokens - previous.totalTokens)
272
- };
273
- }
274
- function creditsFor(modelId, usage) {
322
+ function creditsFor(modelId, usage, serviceTier) {
275
323
  const rate = rateForCodexModel(modelId, usage.inputTokens);
276
324
  if (!rate) {
277
325
  return 0;
278
326
  }
279
- const cachedInputTokens = Math.min(usage.cachedInputTokens, usage.inputTokens);
280
- const nonCachedInputTokens = Math.max(0, usage.inputTokens - cachedInputTokens);
281
- return (((nonCachedInputTokens / 1000000) * rate.input +
282
- (cachedInputTokens / 1000000) * rate.cacheRead +
327
+ const { inputTokens, cacheReadInputTokens, cacheWriteInputTokens } = resolveCodexInputBreakdown(usage);
328
+ return (((inputTokens / 1000000) * rate.input +
329
+ (cacheReadInputTokens / 1000000) * rate.cacheRead +
330
+ (cacheWriteInputTokens / 1000000) * rate.cacheWrite +
283
331
  (usage.outputTokens / 1000000) * rate.output) *
332
+ serviceTierPriceMultiplier(serviceTier) *
284
333
  USD_TO_CREDITS);
285
334
  }
335
+ function serviceTierPriceMultiplier(serviceTier) {
336
+ switch (serviceTier?.trim().toLowerCase()) {
337
+ case "fast":
338
+ case "priority":
339
+ return 2;
340
+ case "flex":
341
+ return 0.5;
342
+ default:
343
+ return 1;
344
+ }
345
+ }
346
+ function isSupportedServiceTier(serviceTier) {
347
+ return ["default", "priority", "fast", "flex"].includes(serviceTier?.trim().toLowerCase() ?? "");
348
+ }
286
349
  function rateForCodexModel(modelId, inputTokens = 0) {
287
350
  // The unsuffixed API alias routes to Sol. Normalize only the exact alias so
288
351
  // an unknown future gpt-5.6-* tier is not accidentally charged at Sol rates.
289
- const pricedModelId = modelId === "gpt-5.6" ? "gpt-5.6-sol" : modelId;
352
+ const pricedModelId = modelId === "gpt-5.6"
353
+ ? "gpt-5.6-sol"
354
+ : modelId === "codex-auto-review"
355
+ ? "gpt-5.4"
356
+ : modelId;
290
357
  return resolveUsageRate(RATE_CARD, pricedModelId, inputTokens, { prefixMatch: true });
291
358
  }
292
359
  function rawUsageToTotals(usage) {
293
- const cacheReadInputTokens = Math.min(usage.cachedInputTokens, usage.inputTokens);
294
- const inputTokens = Math.max(0, usage.inputTokens - cacheReadInputTokens);
360
+ const { inputTokens, cacheReadInputTokens, cacheWriteInputTokens } = resolveCodexInputBreakdown(usage);
295
361
  return {
296
362
  inputTokens,
297
363
  outputTokens: usage.outputTokens,
298
364
  cacheReadInputTokens,
299
- cacheWriteInputTokens: 0,
300
- cacheWrite5mInputTokens: 0,
365
+ cacheWriteInputTokens,
366
+ cacheWrite5mInputTokens: cacheWriteInputTokens,
301
367
  cacheWrite1hInputTokens: 0,
302
368
  reasoningOutputTokens: usage.reasoningOutputTokens,
303
- totalTokens: inputTokens + cacheReadInputTokens + usage.outputTokens,
369
+ totalTokens: inputTokens + cacheReadInputTokens + cacheWriteInputTokens + usage.outputTokens,
304
370
  estimatedCredits: 0,
305
371
  eventCount: 0
306
372
  };
307
373
  }
308
- function createUsageTotalsForModel(modelId, usage, knownModels) {
374
+ function resolveCodexInputBreakdown(usage) {
375
+ const cacheReadInputTokens = Math.min(Math.max(0, usage.cachedInputTokens), usage.inputTokens);
376
+ const inputAfterCacheRead = Math.max(0, usage.inputTokens - cacheReadInputTokens);
377
+ const cacheWriteInputTokens = Math.min(Math.max(0, usage.cacheWriteInputTokens), inputAfterCacheRead);
378
+ return {
379
+ inputTokens: Math.max(0, inputAfterCacheRead - cacheWriteInputTokens),
380
+ cacheReadInputTokens,
381
+ cacheWriteInputTokens
382
+ };
383
+ }
384
+ function createUsageTotalsForModel(modelId, usage, knownModels, serviceTier) {
309
385
  const resolvedModelId = modelId || "unknown";
310
386
  const deltaTotals = rawUsageToTotals(usage);
311
- deltaTotals.estimatedCredits = creditsFor(resolvedModelId, usage);
387
+ deltaTotals.estimatedCredits = creditsFor(resolvedModelId, usage, serviceTier);
312
388
  deltaTotals.eventCount = 1;
313
389
  if (!rateForCodexModel(resolvedModelId, usage.inputTokens) && !isAssumedZeroRatedCodexModel(resolvedModelId, knownModels)) {
314
390
  deltaTotals.estimatedCreditsStatus = "unavailable";
@@ -325,12 +401,13 @@ function isHiddenCodexModel(modelId, knownModels) {
325
401
  return knownModels.get(modelId)?.visibility === "hide";
326
402
  }
327
403
  function isAssumedZeroRatedCodexModel(modelId, knownModels) {
328
- // Hidden internal Codex models do not have a public rate card entry. For dashboard
329
- // rollups we treat them as zero-rated so they do not turn aggregate totals unknown.
330
- return isHiddenCodexModel(modelId, knownModels);
404
+ // Synthetic bookkeeping rows carry no billable model call. Other hidden
405
+ // models are not assumed free: Codex auto-review is mapped to GPT-5.4 above,
406
+ // and an unknown hidden model must make the estimate explicitly unavailable.
407
+ return modelId === "<synthetic>" && isHiddenCodexModel(modelId, knownModels);
331
408
  }
332
409
  function isSessionFile(filePath) {
333
- return filePath.endsWith(".jsonl") && filePath.includes(`${path.sep}.codex${path.sep}sessions${path.sep}`);
410
+ return filePath.endsWith(".jsonl");
334
411
  }
335
412
  async function* walkSessionFiles(directory) {
336
413
  let entries;
@@ -354,10 +431,51 @@ async function parseSessionFile(filePath, byModel, byDay, windows, planTypes, kn
354
431
  const stream = fs.createReadStream(filePath, { encoding: "utf8" });
355
432
  const lineReader = readline.createInterface({ input: stream, crlfDelay: Infinity });
356
433
  let currentModel = "unknown";
357
- let previousTotal;
434
+ let currentServiceTier;
435
+ let pendingUsageRecord;
436
+ let firstSessionMetadataSeen = false;
437
+ let forkStartedAtMs = null;
438
+ let isOwnForkHistory = true;
358
439
  let linesRead = 0;
359
440
  let tokenEvents = 0;
360
441
  let malformedLines = 0;
442
+ let pendingWebSearchCalls = 0;
443
+ let lastSeenTimestampMs = 0;
444
+ const recordUsage = (modelId, usage, eventTimeMs, rateLimits = null, serviceTier = currentServiceTier) => {
445
+ const webSearchCostCredits = pendingWebSearchCalls;
446
+ pendingWebSearchCalls = 0;
447
+ // Current-format-only policy: without the applied tier the exact cost is
448
+ // unknowable. Silently ignore the event instead of guessing Standard or
449
+ // carrying compatibility warnings for legacy rollout formats.
450
+ if (!isSupportedServiceTier(serviceTier)) {
451
+ return;
452
+ }
453
+ if (!hasCountedRawUsage(usage) && webSearchCostCredits === 0) {
454
+ return;
455
+ }
456
+ const resolvedModelId = modelId || "unknown";
457
+ const deltaTotals = createUsageTotalsForModel(resolvedModelId, usage, knownModels, serviceTier);
458
+ // OpenAI API web search is $10 / 1k calls: $0.01, or one dashboard
459
+ // credit, per completed call. Search-content tokens are already present in
460
+ // the model usage record and are priced normally above.
461
+ deltaTotals.estimatedCredits += webSearchCostCredits;
462
+ if (!hasCountedRawUsage(usage) && webSearchCostCredits > 0) {
463
+ deltaTotals.estimatedCreditsStatus = "known";
464
+ }
465
+ const planType = typeof rateLimits?.plan_type === "string" ? rateLimits.plan_type : undefined;
466
+ const safeEventTimeMs = Number.isFinite(eventTimeMs) ? eventTimeMs : 0;
467
+ tokenEvents += 1;
468
+ addModelUsage(byModel, resolvedModelId, deltaTotals);
469
+ addDailyUsage(byDay, eventTimeMs, resolvedModelId, planType, deltaTotals);
470
+ applyRateLimits(windows, rateLimits, safeEventTimeMs, resolvedModelId, deltaTotals, planTypes);
471
+ };
472
+ const flushPendingUsageRecord = () => {
473
+ if (!pendingUsageRecord) {
474
+ return;
475
+ }
476
+ recordUsage(pendingUsageRecord.modelId, pendingUsageRecord.usage, pendingUsageRecord.eventTimeMs, null, pendingUsageRecord.serviceTier);
477
+ pendingUsageRecord = undefined;
478
+ };
361
479
  for await (const line of lineReader) {
362
480
  linesRead += 1;
363
481
  if (!line.trim()) {
@@ -371,35 +489,111 @@ async function parseSessionFile(filePath, byModel, byDay, windows, planTypes, kn
371
489
  malformedLines += 1;
372
490
  continue;
373
491
  }
492
+ const parsedTimestampMs = Date.parse(String(payloadObject.timestamp ?? ""));
493
+ if (Number.isFinite(parsedTimestampMs)) {
494
+ lastSeenTimestampMs = parsedTimestampMs;
495
+ }
496
+ if (payloadObject.type === "session_meta" && !firstSessionMetadataSeen) {
497
+ firstSessionMetadataSeen = true;
498
+ const payload = asRecord(payloadObject.payload);
499
+ const sessionStartedAtMs = parseSortableCodexIdTimestamp(payload?.id);
500
+ if (typeof payload?.forked_from_id === "string" && sessionStartedAtMs !== null) {
501
+ forkStartedAtMs = sessionStartedAtMs;
502
+ isOwnForkHistory = false;
503
+ }
504
+ continue;
505
+ }
374
506
  if (payloadObject.type === "turn_context") {
375
- const payload = payloadObject.payload;
376
- const collaborationMode = payload?.collaboration_mode;
377
- const settings = collaborationMode?.settings;
507
+ const payload = asRecord(payloadObject.payload);
508
+ const turnStartedAtMs = parseSortableCodexIdTimestamp(payload?.turn_id);
509
+ if (forkStartedAtMs !== null && turnStartedAtMs !== null && turnStartedAtMs < forkStartedAtMs) {
510
+ continue;
511
+ }
512
+ if (!isOwnForkHistory) {
513
+ if (forkStartedAtMs !== null && turnStartedAtMs === null) {
514
+ continue;
515
+ }
516
+ isOwnForkHistory = true;
517
+ }
518
+ flushPendingUsageRecord();
519
+ const collaborationMode = asRecord(payload?.collaboration_mode);
520
+ const settings = asRecord(collaborationMode?.settings);
378
521
  currentModel = String(payload?.model ?? settings?.model ?? currentModel);
379
522
  continue;
380
523
  }
524
+ // A fork inherits the latest thread settings from its parent history. Keep
525
+ // tracking those settings while skipping the inherited usage itself, so a
526
+ // child request is priced with the tier that was actually in force.
527
+ if (payloadObject.type === "event_msg") {
528
+ const payload = asRecord(payloadObject.payload);
529
+ if (payload?.type === "thread_settings_applied") {
530
+ const threadSettings = asRecord(payload.thread_settings);
531
+ if (typeof threadSettings?.model === "string" && threadSettings.model.trim()) {
532
+ currentModel = threadSettings.model;
533
+ }
534
+ if (typeof threadSettings?.service_tier === "string" && threadSettings.service_tier.trim()) {
535
+ currentServiceTier = threadSettings.service_tier;
536
+ }
537
+ continue;
538
+ }
539
+ }
540
+ if (!isOwnForkHistory) {
541
+ continue;
542
+ }
543
+ if (payloadObject.type === "token_usage_record") {
544
+ flushPendingUsageRecord();
545
+ const payload = asRecord(payloadObject.payload);
546
+ const usage = normalizeRawUsage(payload?.usage);
547
+ if (hasCountedRawUsage(usage)) {
548
+ pendingUsageRecord = {
549
+ modelId: currentModel || "unknown",
550
+ usage,
551
+ eventTimeMs: Date.parse(String(payloadObject.timestamp ?? "")),
552
+ serviceTier: currentServiceTier
553
+ };
554
+ }
555
+ continue;
556
+ }
381
557
  if (payloadObject.type !== "event_msg") {
382
558
  continue;
383
559
  }
384
- const payload = payloadObject.payload;
560
+ const payload = asRecord(payloadObject.payload);
561
+ if (payload?.type === "web_search_end") {
562
+ pendingWebSearchCalls += 1;
563
+ continue;
564
+ }
385
565
  if (payload?.type !== "token_count") {
386
566
  continue;
387
567
  }
388
- const info = payload.info;
389
- const totalUsage = normalizeRawUsage(info?.total_token_usage);
390
- const lastUsage = info?.last_token_usage;
391
- const usage = lastUsage ? normalizeRawUsage(lastUsage) : previousTotal ? subtractRawUsage(totalUsage, previousTotal) : totalUsage;
392
- previousTotal = totalUsage;
393
- const resolvedModelId = currentModel || "unknown";
394
- const deltaTotals = createUsageTotalsForModel(resolvedModelId, usage, knownModels);
395
- tokenEvents += 1;
396
- addModelUsage(byModel, resolvedModelId, deltaTotals);
397
568
  const eventTimeMs = Date.parse(String(payloadObject.timestamp ?? ""));
398
- const safeEventTimeMs = Number.isFinite(eventTimeMs) ? eventTimeMs : 0;
399
569
  const rateLimits = asRecord(payload.rate_limits);
400
- const planType = typeof rateLimits?.plan_type === "string" ? rateLimits.plan_type : undefined;
401
- addDailyUsage(byDay, eventTimeMs, resolvedModelId, planType, deltaTotals);
402
- applyRateLimits(windows, rateLimits, safeEventTimeMs, resolvedModelId, deltaTotals, planTypes);
570
+ if (pendingUsageRecord) {
571
+ const pending = pendingUsageRecord;
572
+ pendingUsageRecord = undefined;
573
+ recordUsage(pending.modelId, pending.usage, pending.eventTimeMs, rateLimits, pending.serviceTier);
574
+ }
575
+ }
576
+ flushPendingUsageRecord();
577
+ if (pendingWebSearchCalls > 0) {
578
+ recordUsage(currentModel, createEmptyRawUsage(), lastSeenTimestampMs);
403
579
  }
404
580
  return { linesRead, tokenEvents, malformedLines };
405
581
  }
582
+ function hasCountedRawUsage(usage) {
583
+ return (usage.inputTokens > 0 ||
584
+ usage.cachedInputTokens > 0 ||
585
+ usage.cacheWriteInputTokens > 0 ||
586
+ usage.outputTokens > 0 ||
587
+ usage.reasoningOutputTokens > 0);
588
+ }
589
+ function parseSortableCodexIdTimestamp(value) {
590
+ if (typeof value !== "string") {
591
+ return null;
592
+ }
593
+ const timestampHex = value.replace(/-/g, "").slice(0, 12);
594
+ if (!/^[0-9a-f]{12}$/i.test(timestampHex)) {
595
+ return null;
596
+ }
597
+ const timestamp = Number.parseInt(timestampHex, 16);
598
+ return Number.isFinite(timestamp) ? timestamp : null;
599
+ }
@@ -19,6 +19,7 @@ export const RATE_CARD = {
19
19
  "claude-opus-4-6": { input: 500, cacheRead: 50, cacheWrite: 625, cacheWrite5m: 625, cacheWrite1h: 1000, output: 2500 },
20
20
  "claude-opus-4-7": { input: 500, cacheRead: 50, cacheWrite: 625, cacheWrite5m: 625, cacheWrite1h: 1000, output: 2500 },
21
21
  "claude-opus-4-8": { input: 500, cacheRead: 50, cacheWrite: 625, cacheWrite5m: 625, cacheWrite1h: 1000, output: 2500 },
22
+ "claude-opus-5": { input: 500, cacheRead: 50, cacheWrite: 625, cacheWrite5m: 625, cacheWrite1h: 1000, output: 2500 },
22
23
  "claude-fable-5": { input: 1000, cacheRead: 100, cacheWrite: 1250, cacheWrite5m: 1250, cacheWrite1h: 2000, output: 5000 },
23
24
  "gemini-2.5-pro": { input: 125, cacheRead: 12.5, cacheWrite: 125, cacheWrite5m: 125, cacheWrite1h: 125, output: 1000 },
24
25
  "gemini-3-flash": { input: 50, cacheRead: 5, cacheWrite: 50, cacheWrite5m: 50, cacheWrite1h: 50, output: 300 },
@@ -88,6 +88,7 @@ export function buildWindowLists(windows) {
88
88
  lastSeenUtcIso: formatIsoFromMilliseconds(window.lastSeenMs),
89
89
  minUsedPercent: window.minUsedPercent,
90
90
  maxUsedPercent: window.maxUsedPercent,
91
+ measuredUsedPercent: usage.measuredUsedPercent,
91
92
  totals: usage.totals,
92
93
  modelUsage: usage.modelUsage,
93
94
  eventCount: 0
@@ -178,6 +179,10 @@ function collapseNearbyWindows(rows) {
178
179
  existing.lastSeenUtcIso > row.lastSeenUtcIso ? existing.lastSeenUtcIso : row.lastSeenUtcIso;
179
180
  existing.minUsedPercent = Math.min(existing.minUsedPercent, row.minUsedPercent);
180
181
  existing.maxUsedPercent = Math.max(existing.maxUsedPercent, row.maxUsedPercent);
182
+ existing.measuredUsedPercent =
183
+ existing.minUsedPercent === existing.maxUsedPercent
184
+ ? 0
185
+ : clampUsedPercent(existing.maxUsedPercent - existing.minUsedPercent);
181
186
  addUsageTotals(existing.totals, row.totals);
182
187
  existing.modelUsage = mergeModelUsageRows(existing.modelUsage, row.modelUsage);
183
188
  existing.eventCount = existing.totals.eventCount;
@@ -189,13 +194,22 @@ function computeWindowUsage(events) {
189
194
  // saturation has to be applied after we sort the captured window events.
190
195
  const totals = createEmptyUsageTotals();
191
196
  const byModel = new Map();
192
- let sawBelowCap = false;
193
- let isExhausted = false;
194
- for (const event of [...events].sort((left, right) => left.eventTimeMs - right.eventTimeMs)) {
197
+ const sortedEvents = [...events].sort((left, right) => left.eventTimeMs - right.eventTimeMs);
198
+ const baselinePercent = clampUsedPercent(sortedEvents[0]?.usedPercent ?? 0);
199
+ // Each rate-limit snapshot is recorded after the usage event on the same
200
+ // line. The first event therefore has no matching "before" percentage, even
201
+ // when its post-event value rounds to zero. Keep its percentage only as the
202
+ // baseline and aggregate requests from the next snapshot onward.
203
+ const firstIncludedIndex = sortedEvents.length > 0 ? 1 : 0;
204
+ let highestIncludedPercent = baselinePercent;
205
+ let sawBelowCap = baselinePercent < 100;
206
+ let isExhausted = baselinePercent >= 100;
207
+ for (const [index, event] of sortedEvents.entries()) {
195
208
  sawBelowCap || (sawBelowCap = event.usedPercent < 100);
196
- if (!isExhausted) {
209
+ if (index >= firstIncludedIndex && !isExhausted) {
197
210
  addUsageTotals(totals, event.totals);
198
211
  addWindowModelUsage(byModel, event.modelId, event.totals);
212
+ highestIncludedPercent = Math.max(highestIncludedPercent, clampUsedPercent(event.usedPercent));
199
213
  if (sawBelowCap && event.usedPercent >= 100) {
200
214
  isExhausted = true;
201
215
  }
@@ -203,9 +217,32 @@ function computeWindowUsage(events) {
203
217
  }
204
218
  return {
205
219
  totals,
206
- modelUsage: buildModelUsageRows(byModel)
220
+ modelUsage: buildModelUsageRows(byModel),
221
+ measuredUsedPercent: clampUsedPercent(highestIncludedPercent - baselinePercent)
207
222
  };
208
223
  }
224
+ /**
225
+ * Return the provider percentage represented by a window's token totals.
226
+ * Older/injected rows without the explicit field retain the legacy derivation.
227
+ */
228
+ export function resolveMeasuredUsedPercent(window) {
229
+ if (window.measuredUsedPercent === null) {
230
+ return null;
231
+ }
232
+ if (typeof window.measuredUsedPercent === "number" && Number.isFinite(window.measuredUsedPercent)) {
233
+ return clampUsedPercent(window.measuredUsedPercent);
234
+ }
235
+ if (window.minUsedPercent === window.maxUsedPercent) {
236
+ return clampUsedPercent(window.maxUsedPercent);
237
+ }
238
+ return clampUsedPercent(window.maxUsedPercent - window.minUsedPercent);
239
+ }
240
+ function clampUsedPercent(value) {
241
+ if (!Number.isFinite(value)) {
242
+ return 0;
243
+ }
244
+ return Math.max(0, Math.min(100, value));
245
+ }
209
246
  function upsertWindow(windows, scope, rateLimits, window, eventTimeMs, modelId, deltaTotals) {
210
247
  if (!window) {
211
248
  return;
@@ -2,6 +2,7 @@ import { request } from "node:https";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { resolveMeasuredUsedPercent } from "./providers/limits.js";
5
6
  const REPORTING_ENDPOINT = "https://devforth.io/admin/api/report_ussage_anonymous";
6
7
  const CREDIT_TO_DOLLARS = 0.01;
7
8
  // Limit windows at or below this used-percent carry too little signal to be
@@ -99,13 +100,11 @@ function buildUsageRaw(modelUsage) {
99
100
  return usageRaw;
100
101
  }
101
102
  function resolveReportedUsedPercents(window) {
102
- if (window.minUsedPercent === window.maxUsedPercent) {
103
- return clampPercent(window.maxUsedPercent);
104
- }
105
- return clampPercent(window.maxUsedPercent - window.minUsedPercent);
103
+ return clampPercent(resolveMeasuredUsedPercent(window) ?? 0);
106
104
  }
107
105
  function shouldReportUsageWindow(window) {
108
- return resolveReportedUsedPercents(window) > SKIP_REPORT_USED_PERCENTS;
106
+ return (window.totals.estimatedCreditsStatus !== "unavailable" &&
107
+ resolveReportedUsedPercents(window) > SKIP_REPORT_USED_PERCENTS);
109
108
  }
110
109
  function clampPercent(value) {
111
110
  if (!Number.isFinite(value)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "letmecode",
3
- "version": "0.1.26",
3
+ "version": "0.1.29",
4
4
  "description": "Terminal AI usage dashboard for Codex, Claude, Copilot, and Antigravity.",
5
5
  "author": "Devforth (https://devforth.io)",
6
6
  "license": "MIT",