letmecode 0.1.28 → 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,28 +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-opus-5": { input: 5, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite5m: 6.25, cacheWrite1h: 10, output: 25 },
21
- "claude-sonnet-5": {
22
- input: 3,
23
- cacheRead: 0.3,
24
- cacheWrite: 3.75,
25
- cacheWrite5m: 3.75,
26
- cacheWrite1h: 6,
27
- output: 15,
28
- // Intro pricing (~0.66x) through 2026-08-31; reverts to the standard rate above on 2026-09-01.
29
- introOffer: {
30
- effectiveUntilMs: Date.UTC(2026, 8, 1),
31
- rate: { input: 2, cacheRead: 0.2, cacheWrite: 2.5, cacheWrite5m: 2.5, cacheWrite1h: 4, output: 10 }
32
- }
33
- },
24
+ "claude-sonnet-5": { input: 2, cacheRead: 0.2, cacheWrite: 2.5, cacheWrite5m: 2.5, cacheWrite1h: 4, output: 10 },
34
25
  "claude-sonnet-4-6": { input: 3, cacheRead: 0.3, cacheWrite: 3.75, cacheWrite5m: 3.75, cacheWrite1h: 6, output: 15 },
35
26
  "claude-sonnet-4-5": { input: 3, cacheRead: 0.3, cacheWrite: 3.75, cacheWrite5m: 3.75, cacheWrite1h: 6, output: 15 },
36
27
  "claude-sonnet-4": { input: 3, cacheRead: 0.3, cacheWrite: 3.75, cacheWrite5m: 3.75, cacheWrite1h: 6, output: 15 },
@@ -113,15 +104,13 @@ export class ClaudeUsageProvider extends UsageProviderBase {
113
104
  }
114
105
  }
115
106
  const selectedEvents = [
116
- ...new Set(parsedEvents.keyedEvents.values()),
117
- ...parsedEvents.unkeyedEvents
107
+ ...new Set(parsedEvents.keyedEvents.values())
118
108
  ];
119
109
  traceClaude(options.traceLogger, [
120
110
  `Transcript selection summary: filesWithMatches=${parseTotals.filesScanned}/${parsedSessionFiles.length}`,
121
111
  `selectedEvents=${selectedEvents.length}`,
122
112
  `duplicateUsageKeys=${parsedEvents.duplicateUsageKeys}`,
123
- `duplicateUsageKeyCollisions=${parsedEvents.duplicateUsageKeyCollisions}`,
124
- `duplicateUnkeyedEvents=${parsedEvents.duplicateUnkeyedEvents}`
113
+ `duplicateUsageKeyCollisions=${parsedEvents.duplicateUsageKeyCollisions}`
125
114
  ].join(" "));
126
115
  if (selectedEvents.length === 0 && parsedSessionFiles.length > 0) {
127
116
  traceClaude(options.traceLogger, "No assistant usage events were found in the parsed Claude session files.");
@@ -143,9 +132,6 @@ export class ClaudeUsageProvider extends UsageProviderBase {
143
132
  if (options.verbose && parsedEvents.duplicateUsageKeyCollisions > 0) {
144
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.`);
145
134
  }
146
- if (options.verbose && parsedEvents.duplicateUnkeyedEvents > 0) {
147
- warnings.push(`Collapsed ${parsedEvents.duplicateUnkeyedEvents} adjacent duplicate unkeyed Claude usage event(s) by usage signature.`);
148
- }
149
135
  const modelUsage = [...byModel.entries()]
150
136
  .map(([modelId, totals]) => ({ modelId, totals }))
151
137
  .sort((left, right) => right.totals.estimatedCredits - left.totals.estimatedCredits);
@@ -153,7 +139,7 @@ export class ClaudeUsageProvider extends UsageProviderBase {
153
139
  .map((row) => row.modelId)
154
140
  .filter((modelId) => !resolveRate(modelId) && !isInternalClaudeModel(modelId));
155
141
  if (unknownPricedModels.length > 0) {
156
- warnings.push(`No credit rate configured for: ${unknownPricedModels.join(", ")}.`);
142
+ warnings.push(`No API-equivalent rate configured for: ${unknownPricedModels.join(", ")}.`);
157
143
  }
158
144
  if (parsedSessionFiles.length === 0) {
159
145
  warnings.push(`No Claude session files found under ${sessionsRoot}.`);
@@ -216,17 +202,18 @@ export class ClaudeUsageProvider extends UsageProviderBase {
216
202
  function normalizeUsage(value) {
217
203
  const usage = asRecord(value) ?? {};
218
204
  const cacheCreation = asRecord(usage.cache_creation);
205
+ const serverToolUse = asRecord(usage.server_tool_use);
219
206
  const cacheCreation5mInputTokens = numberOrZero(cacheCreation?.ephemeral_5m_input_tokens);
220
207
  const cacheCreation1hInputTokens = numberOrZero(cacheCreation?.ephemeral_1h_input_tokens);
221
- const cacheCreationInputTokens = Math.max(numberOrZero(usage.cache_creation_input_tokens), cacheCreation5mInputTokens + cacheCreation1hInputTokens);
222
208
  return {
223
209
  inputTokens: numberOrZero(usage.input_tokens),
224
210
  cacheReadInputTokens: numberOrZero(usage.cache_read_input_tokens),
225
- cacheCreationInputTokens,
226
211
  cacheCreation5mInputTokens,
227
212
  cacheCreation1hInputTokens,
228
213
  outputTokens: numberOrZero(usage.output_tokens),
229
- inferenceGeo: String(usage.inference_geo ?? "")
214
+ inferenceGeo: String(usage.inference_geo ?? ""),
215
+ speed: String(usage.speed ?? ""),
216
+ webSearchRequests: numberOrZero(serverToolUse?.web_search_requests)
230
217
  };
231
218
  }
232
219
  function resolveRate(modelId, timestampMs) {
@@ -244,18 +231,25 @@ function creditsFor(modelId, usage, timestampMs) {
244
231
  // The US inference surcharge must match regardless of the casing the source
245
232
  // reports (e.g. "us", "US"), so compare case-insensitively.
246
233
  const inferenceMultiplier = usage.inferenceGeo.trim().toLowerCase() === "us" ? 1.1 : 1;
247
- 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 +
248
238
  (usage.cacheReadInputTokens / 1000000) * rate.cacheRead +
249
239
  (cacheWriteBreakdown.cacheWrite5mInputTokens / 1000000) * rate.cacheWrite5m +
250
240
  (cacheWriteBreakdown.cacheWrite1hInputTokens / 1000000) * rate.cacheWrite1h +
251
- (usage.outputTokens / 1000000) * rate.output) *
252
- inferenceMultiplier *
253
- 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}-`));
254
247
  }
255
248
  function usageToTotals(modelId, usage, timestampMs) {
256
249
  const cacheWriteBreakdown = resolveClaudeCacheWriteBreakdown(usage);
257
250
  const cacheWriteInputTokens = cacheWriteBreakdown.cacheWrite5mInputTokens +
258
251
  cacheWriteBreakdown.cacheWrite1hInputTokens;
252
+ const rateKnown = Boolean(resolveRate(modelId, timestampMs)) || isInternalClaudeModel(modelId);
259
253
  return {
260
254
  inputTokens: usage.inputTokens,
261
255
  outputTokens: usage.outputTokens,
@@ -269,7 +263,8 @@ function usageToTotals(modelId, usage, timestampMs) {
269
263
  cacheWriteInputTokens +
270
264
  usage.outputTokens,
271
265
  estimatedCredits: creditsFor(modelId, usage, timestampMs),
272
- eventCount: 1
266
+ eventCount: 1,
267
+ estimatedCreditsStatus: rateKnown ? "known" : "unavailable"
273
268
  };
274
269
  }
275
270
  function addModelUsage(byModel, modelId, deltaTotals) {
@@ -279,10 +274,8 @@ function addModelUsage(byModel, modelId, deltaTotals) {
279
274
  byModel.set(resolvedModelId, totals);
280
275
  }
281
276
  function resolveClaudeCacheWriteBreakdown(usage) {
282
- const cacheWriteKnownTokens = usage.cacheCreation5mInputTokens + usage.cacheCreation1hInputTokens;
283
- const cacheWriteFallbackTokens = Math.max(0, usage.cacheCreationInputTokens - cacheWriteKnownTokens);
284
277
  return {
285
- cacheWrite5mInputTokens: usage.cacheCreation5mInputTokens + cacheWriteFallbackTokens,
278
+ cacheWrite5mInputTokens: usage.cacheCreation5mInputTokens,
286
279
  cacheWrite1hInputTokens: usage.cacheCreation1hInputTokens
287
280
  };
288
281
  }
@@ -437,7 +430,7 @@ async function parseSessionFile(filePath) {
437
430
  }
438
431
  const message = asRecord(payloadObject.message);
439
432
  const usage = asRecord(message?.usage);
440
- if (!usage) {
433
+ if (!usage || !isCurrentClaudeUsageEvent(payloadObject, message, usage)) {
441
434
  continue;
442
435
  }
443
436
  const modelId = String(message?.model ?? "unknown");
@@ -467,6 +460,21 @@ async function parseSessionFile(filePath) {
467
460
  events
468
461
  };
469
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
+ }
470
478
  function buildUsageEventKeys(payloadObject, message) {
471
479
  const sessionId = String(payloadObject.sessionId ?? "");
472
480
  const requestId = typeof payloadObject.requestId === "string" ? payloadObject.requestId : "";
@@ -484,22 +492,20 @@ function buildUsageSignatureFromParts(sessionId, modelId, usage) {
484
492
  sessionId,
485
493
  modelId,
486
494
  usage.inputTokens,
487
- usage.cacheCreationInputTokens,
488
495
  usage.cacheCreation5mInputTokens,
489
496
  usage.cacheCreation1hInputTokens,
490
497
  usage.cacheReadInputTokens,
491
498
  usage.outputTokens,
492
- usage.inferenceGeo
499
+ usage.inferenceGeo,
500
+ usage.speed,
501
+ usage.webSearchRequests
493
502
  ].join("|");
494
503
  }
495
504
  function createParsedUsageEventAccumulator() {
496
505
  return {
497
506
  keyedEvents: new Map(),
498
- unkeyedEvents: [],
499
- lastUnkeyedEventsBySignature: new Map(),
500
507
  duplicateUsageKeys: 0,
501
- duplicateUsageKeyCollisions: 0,
502
- duplicateUnkeyedEvents: 0
508
+ duplicateUsageKeyCollisions: 0
503
509
  };
504
510
  }
505
511
  function recordParsedUsageEvent(parsedEvents, event) {
@@ -527,23 +533,6 @@ function recordParsedUsageEvent(parsedEvents, event) {
527
533
  }
528
534
  return;
529
535
  }
530
- const previousRecord = parsedEvents.lastUnkeyedEventsBySignature.get(event.usageSignature);
531
- if (!previousRecord || !canCollapseAdjacentUnkeyedUsageEvents(previousRecord.event, event)) {
532
- parsedEvents.unkeyedEvents.push(event);
533
- parsedEvents.lastUnkeyedEventsBySignature.set(event.usageSignature, {
534
- event,
535
- index: parsedEvents.unkeyedEvents.length - 1
536
- });
537
- return;
538
- }
539
- parsedEvents.duplicateUnkeyedEvents += 1;
540
- if (normalizeTimestamp(event.timestampMs) > normalizeTimestamp(previousRecord.event.timestampMs)) {
541
- parsedEvents.unkeyedEvents[previousRecord.index] = event;
542
- parsedEvents.lastUnkeyedEventsBySignature.set(event.usageSignature, {
543
- event,
544
- index: previousRecord.index
545
- });
546
- }
547
536
  }
548
537
  function mergeParsedUsageEvents(previous, next) {
549
538
  // Same-key events are repeated/streamed snapshots of one logical request. Rather than
@@ -595,9 +584,6 @@ function selectMergedEventModelId(primary, other) {
595
584
  }
596
585
  return primary.modelId;
597
586
  }
598
- function canCollapseAdjacentUnkeyedUsageEvents(previous, next) {
599
- return previous.filePath === next.filePath && next.lineNumber === previous.lineNumber + 1;
600
- }
601
587
  function extractUsageKeySessionId(usageKeys) {
602
588
  const usageKey = usageKeys[0];
603
589
  if (!usageKey) {
@@ -1069,6 +1055,19 @@ function parseLiveUsageWindowSnapshots(usageOutput, now) {
1069
1055
  resetMsByLabel.set(parsed.label, resetsAtMs);
1070
1056
  }
1071
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
+ }
1072
1071
  const snapshots = new Map();
1073
1072
  for (const parsed of parsedLines) {
1074
1073
  const resetsAtMs = resetMsByLabel.get(parsed.label) ?? null;
@@ -1163,6 +1162,9 @@ function buildLiveLimitWindowRow(snapshot, planType, selectedEvents, now) {
1163
1162
  lastSeenUtcIso: toUtcIso(Number.isFinite(lastSeenMs) ? lastSeenMs : fallbackLastSeenMs),
1164
1163
  minUsedPercent: snapshot.usedPercent,
1165
1164
  maxUsedPercent: snapshot.usedPercent,
1165
+ measuredUsedPercent: snapshot.usedPercent > 0 && totals.eventCount === 0
1166
+ ? null
1167
+ : snapshot.usedPercent,
1166
1168
  totals,
1167
1169
  modelUsage: buildModelUsageRowsForEvents(inWindowEvents),
1168
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
+ }
@@ -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.28",
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",