letmecode 0.1.28 → 0.1.30

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) {
@@ -20,7 +20,19 @@ const MODEL_IDS = {
20
20
  "claude-opus-4-6-20251201": "claude-opus-4-6"
21
21
  };
22
22
  export function normalizeAntigravityModelId(modelId) {
23
- return MODEL_IDS[modelId] ?? (modelId || "unknown");
23
+ const exactModelId = MODEL_IDS[modelId];
24
+ if (exactModelId) {
25
+ return exactModelId;
26
+ }
27
+ const currentGeminiModel = [
28
+ "gemini-3.8-flash",
29
+ "gemini-3.7-flash",
30
+ "gemini-3.6-flash",
31
+ "gemini-3.1-pro"
32
+ ].find((candidate) => modelId === candidate ||
33
+ modelId === `${candidate}-preview` ||
34
+ ["low", "medium", "high"].some((level) => modelId === `${candidate}-${level}`));
35
+ return currentGeminiModel ?? (modelId || "unknown");
24
36
  }
25
37
  export function antigravityModelScope(rawModelId) {
26
38
  const modelId = normalizeAntigravityModelId(rawModelId);
@@ -1,68 +1,12 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { UsageProviderBase, addUsageTotals, createEmptyUsageTotals, sumUsageTotals } from "../contract.js";
3
3
  import { addDailyUsage, buildDailyUsageRows, createDailyUsageAggregates } from "../daily.js";
4
- import { resolveUsageRate } from "../pricing.js";
4
+ import { fetchModelPricing, modelCostCredits } from "../pricing.js";
5
5
  import { modelScopeLabel, modelScopeMatches, normalizeAntigravityModelId } from "./models.js";
6
6
  import { parseAntigravityQuotaEntries } from "./quota-parser.js";
7
7
  import { findAntigravityLocalServer } from "./rpc/discovery.js";
8
8
  import { extractQuotaGroups, fetchAntigravityUserStatus } from "./rpc/quota.js";
9
9
  import { collectUsageFromLocalRpc } from "./usage-parse.js";
10
- const RATE_CARD = {
11
- "gemini-3.5-flash": {
12
- input: 150,
13
- cacheRead: 15,
14
- cacheWrite: 150,
15
- cacheWrite5m: 150,
16
- cacheWrite1h: 150,
17
- output: 900
18
- },
19
- "gemini-3.1-pro": {
20
- input: 200,
21
- cacheRead: 20,
22
- cacheWrite: 200,
23
- cacheWrite5m: 200,
24
- cacheWrite1h: 200,
25
- output: 1200,
26
- longContext: {
27
- thresholdTokens: 200000,
28
- rate: {
29
- input: 400,
30
- cacheRead: 40,
31
- cacheWrite: 400,
32
- cacheWrite5m: 400,
33
- cacheWrite1h: 400,
34
- output: 1800
35
- }
36
- }
37
- },
38
- "gemini-3-flash": {
39
- input: 50,
40
- cacheRead: 5,
41
- cacheWrite: 50,
42
- cacheWrite5m: 50,
43
- cacheWrite1h: 50,
44
- output: 300
45
- },
46
- "claude-sonnet-4-6": {
47
- input: 300,
48
- cacheRead: 30,
49
- cacheWrite: 375,
50
- cacheWrite5m: 375,
51
- cacheWrite1h: 600,
52
- output: 1500
53
- },
54
- "claude-opus-4-6": {
55
- input: 500,
56
- cacheRead: 50,
57
- cacheWrite: 625,
58
- cacheWrite5m: 625,
59
- cacheWrite1h: 1000,
60
- output: 2500
61
- }
62
- };
63
- const UNPRICED_MODELS = new Set([
64
- "gpt-oss-120b"
65
- ]);
66
10
  export class AntigravityUsageProvider extends UsageProviderBase {
67
11
  constructor(options = {}) {
68
12
  super("antigravity", "Antigravity");
@@ -104,11 +48,18 @@ export class AntigravityUsageProvider extends UsageProviderBase {
104
48
  if (duplicateEvents > 0) {
105
49
  warnings.push(`Collapsed ${duplicateEvents} duplicate Antigravity usage response(s).`);
106
50
  }
51
+ let pricing = new Map();
52
+ try {
53
+ pricing = await fetchModelPricing(selectedRecords.map((record) => normalizeAntigravityModelId(record.modelId)), "antigravity");
54
+ }
55
+ catch {
56
+ warnings.push("Model pricing API is unavailable.");
57
+ }
107
58
  const byModel = new Map();
108
59
  const byDay = createDailyUsageAggregates();
109
60
  for (const record of selectedRecords) {
110
61
  const modelId = normalizeAntigravityModelId(record.modelId);
111
- const totals = usageRecordToTotals(modelId, record);
62
+ const totals = usageRecordToTotals(modelId, record, pricing);
112
63
  addModelUsage(byModel, modelId, totals);
113
64
  addDailyUsage(byDay, record.timestamp, modelId, undefined, totals);
114
65
  }
@@ -120,12 +71,12 @@ export class AntigravityUsageProvider extends UsageProviderBase {
120
71
  .sort((left, right) => right.totals.estimatedCredits -
121
72
  left.totals.estimatedCredits);
122
73
  const unknownPricedModels = modelUsage
123
- .filter((row) => !rateForModel(row.modelId, rowInputTokens(row)) && !UNPRICED_MODELS.has(row.modelId))
74
+ .filter((row) => row.totals.estimatedCreditsStatus === "unavailable")
124
75
  .map((row) => row.modelId);
125
76
  if (unknownPricedModels.length > 0) {
126
- warnings.push(`No Antigravity estimated API-equivalent rate configured for: ${unknownPricedModels.join(", ")}.`);
77
+ warnings.push(`No complete Antigravity API-equivalent pricing returned for: ${unknownPricedModels.join(", ")}.`);
127
78
  }
128
- const limitWindows = quotaSnapshot?.entries.map((quota) => buildAntigravityLimitWindow(quota, quotaSnapshot.planType, selectedRecords, quotaSnapshot.fetchedAt)) ?? [];
79
+ const limitWindows = quotaSnapshot?.entries.map((quota) => buildAntigravityLimitWindow(quota, quotaSnapshot.planType, selectedRecords, quotaSnapshot.fetchedAt, pricing)) ?? [];
129
80
  return {
130
81
  providerId: this.id,
131
82
  providerLabel: this.label,
@@ -177,7 +128,7 @@ async function collectQuotaFromConnection(connect) {
177
128
  : null
178
129
  };
179
130
  }
180
- function buildAntigravityLimitWindow(quota, planType, records, fetchedAt) {
131
+ function buildAntigravityLimitWindow(quota, planType, records, fetchedAt, pricing) {
181
132
  const startAt = quota.resetAt - quota.windowMinutes * 60000;
182
133
  const byModel = new Map();
183
134
  const matchingTimestamps = [];
@@ -189,7 +140,7 @@ function buildAntigravityLimitWindow(quota, planType, records, fetchedAt) {
189
140
  continue;
190
141
  }
191
142
  matchingTimestamps.push(record.timestamp);
192
- addModelUsage(byModel, modelId, usageRecordToTotals(modelId, record));
143
+ addModelUsage(byModel, modelId, usageRecordToTotals(modelId, record, pricing));
193
144
  }
194
145
  const modelUsage = [...byModel.entries()]
195
146
  .map(([modelId, totals]) => ({
@@ -250,7 +201,8 @@ function deduplicateRecords(records) {
250
201
  function recordTokenTotal(record) {
251
202
  return record.input + record.cacheRead + record.cacheWrite + record.output;
252
203
  }
253
- function usageRecordToTotals(modelId, record) {
204
+ function usageRecordToTotals(modelId, record, pricing) {
205
+ const estimatedCredits = creditsFor(modelId, record, pricing);
254
206
  return {
255
207
  inputTokens: record.input,
256
208
  outputTokens: record.output,
@@ -263,7 +215,7 @@ function usageRecordToTotals(modelId, record) {
263
215
  record.cacheRead +
264
216
  record.cacheWrite +
265
217
  record.output,
266
- estimatedCredits: creditsFor(modelId, record),
218
+ estimatedCredits: estimatedCredits ?? 0,
267
219
  eventCount: 1,
268
220
  // The local RPC reports cache reads but never cache writes, so a zero cache
269
221
  // write is genuinely unknown (not a confirmed zero) and is surfaced as "-".
@@ -271,26 +223,17 @@ function usageRecordToTotals(modelId, record) {
271
223
  // which case it is both billed (see creditsFor) and shown as known.
272
224
  cacheReadStatus: "known",
273
225
  cacheWriteStatus: record.cacheWrite > 0 ? "known" : "unavailable",
274
- estimatedCreditsStatus: rateForModel(modelId, record.input)
275
- ? "known"
276
- : "unavailable"
226
+ estimatedCreditsStatus: estimatedCredits === undefined ? "unavailable" : "known"
277
227
  };
278
228
  }
279
- function creditsFor(modelId, record) {
280
- const rate = rateForModel(modelId, record.input);
281
- if (!rate) {
282
- return 0;
283
- }
284
- return ((record.input / 1000000) * rate.input +
285
- (record.cacheRead / 1000000) * rate.cacheRead +
286
- (record.cacheWrite / 1000000) * rate.cacheWrite +
287
- (record.output / 1000000) * rate.output);
288
- }
289
- function rateForModel(modelId, inputTokens) {
290
- return resolveUsageRate(RATE_CARD, modelId, inputTokens);
291
- }
292
- function rowInputTokens(row) {
293
- return row.totals.inputTokens + row.totals.cacheReadInputTokens + row.totals.cacheWriteInputTokens;
229
+ function creditsFor(modelId, record, pricing) {
230
+ return modelCostCredits(pricing.get(modelId), {
231
+ inputTokens: record.input,
232
+ outputTokens: record.output,
233
+ cacheReadInputTokens: record.cacheRead,
234
+ cacheWrite5mInputTokens: record.cacheWrite,
235
+ cacheWrite1hInputTokens: 0
236
+ });
294
237
  }
295
238
  function addModelUsage(byModel, modelId, deltaTotals) {
296
239
  const totals = byModel.get(modelId) ?? createEmptyUsageTotals();
@@ -8,37 +8,21 @@ import { promisify } from "node:util";
8
8
  import { UsageProviderBase, addUsageTotals, createEmptyUsageTotals, sumUsageTotals } from "./contract.js";
9
9
  import { applyRateLimits, asRecord, buildWindowLists, createLimitWindowAggregates, numberOrZero } from "./limits.js";
10
10
  import { addDailyUsage, buildDailyUsageRows, createDailyUsageAggregates } from "./daily.js";
11
- import { resolveUsageRate } from "./pricing.js";
12
- const RATE_CARD = {
13
- "claude-fable-5": { input: 10, cacheRead: 1, cacheWrite: 12.5, cacheWrite5m: 12.5, cacheWrite1h: 20, output: 50 },
14
- "claude-opus-4-8": { input: 5, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite5m: 6.25, cacheWrite1h: 10, output: 25 },
15
- "claude-opus-4-7": { input: 5, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite5m: 6.25, cacheWrite1h: 10, output: 25 },
16
- "claude-opus-4-6": { input: 5, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite5m: 6.25, cacheWrite1h: 10, output: 25 },
17
- "claude-opus-4-5": { input: 5, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite5m: 6.25, cacheWrite1h: 10, output: 25 },
18
- "claude-opus-4-1": { input: 15, cacheRead: 1.5, cacheWrite: 18.75, cacheWrite5m: 18.75, cacheWrite1h: 30, output: 75 },
19
- "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
- },
34
- "claude-sonnet-4-6": { input: 3, cacheRead: 0.3, cacheWrite: 3.75, cacheWrite5m: 3.75, cacheWrite1h: 6, output: 15 },
35
- "claude-sonnet-4-5": { input: 3, cacheRead: 0.3, cacheWrite: 3.75, cacheWrite5m: 3.75, cacheWrite1h: 6, output: 15 },
36
- "claude-sonnet-4": { input: 3, cacheRead: 0.3, cacheWrite: 3.75, cacheWrite5m: 3.75, cacheWrite1h: 6, output: 15 },
37
- "claude-haiku-4-5": { input: 1, cacheRead: 0.1, cacheWrite: 1.25, cacheWrite5m: 1.25, cacheWrite1h: 2, output: 5 },
38
- "claude-haiku-3-5": { input: 0.8, cacheRead: 0.08, cacheWrite: 1, cacheWrite5m: 1, cacheWrite1h: 1.6, output: 4 }
39
- };
11
+ import { fetchModelPricing, modelCostCredits } from "./pricing.js";
12
+ /*
13
+ Previous local prices in USD per 1M tokens, kept temporarily as requested:
14
+ claude-fable-5-1 and claude-mythos-5-1 10 / 0.25 / 12.5 / 20 / 50
15
+ claude-fable-5 and claude-mythos-5 10 / 1 / 12.5 / 20 / 50
16
+ claude-opus-5, 4-8, 4-7, 4-6, 4-5 5 / 0.5 / 6.25 / 10 / 25
17
+ claude-opus-4-1 and 4 15 / 1.5 / 18.75 / 30 / 75
18
+ claude-sonnet-5 2 / 0.2 / 2.5 / 4 / 10
19
+ claude-sonnet-4-6, 4-5, 4 3 / 0.3 / 3.75 / 6 / 15
20
+ claude-haiku-4-5 1 / 0.1 / 1.25 / 2 / 5
21
+ claude-haiku-3-5 0.8 / 0.08 / 1 / 1.6 / 4
22
+ Columns: input / cache read / cache write 5m / cache write 1h / output.
23
+ */
40
24
  const execFileAsync = promisify(execFile);
41
- const USD_TO_CREDITS = 100;
25
+ const EMPTY_MODEL_PRICING = new Map();
42
26
  const VSCODE_CLAUDE_EXTENSION_PREFIX = "anthropic.claude-code-";
43
27
  const CLAUDE_SESSION_WINDOW_MINUTES = 5 * 60;
44
28
  const CLAUDE_WEEK_WINDOW_MINUTES = 7 * 24 * 60;
@@ -113,20 +97,28 @@ export class ClaudeUsageProvider extends UsageProviderBase {
113
97
  }
114
98
  }
115
99
  const selectedEvents = [
116
- ...new Set(parsedEvents.keyedEvents.values()),
117
- ...parsedEvents.unkeyedEvents
100
+ ...new Set(parsedEvents.keyedEvents.values())
118
101
  ];
119
102
  traceClaude(options.traceLogger, [
120
103
  `Transcript selection summary: filesWithMatches=${parseTotals.filesScanned}/${parsedSessionFiles.length}`,
121
104
  `selectedEvents=${selectedEvents.length}`,
122
105
  `duplicateUsageKeys=${parsedEvents.duplicateUsageKeys}`,
123
- `duplicateUsageKeyCollisions=${parsedEvents.duplicateUsageKeyCollisions}`,
124
- `duplicateUnkeyedEvents=${parsedEvents.duplicateUnkeyedEvents}`
106
+ `duplicateUsageKeyCollisions=${parsedEvents.duplicateUsageKeyCollisions}`
125
107
  ].join(" "));
126
108
  if (selectedEvents.length === 0 && parsedSessionFiles.length > 0) {
127
109
  traceClaude(options.traceLogger, "No assistant usage events were found in the parsed Claude session files.");
128
110
  }
111
+ let pricing = new Map();
112
+ try {
113
+ pricing = await fetchModelPricing(selectedEvents
114
+ .map((event) => event.modelId)
115
+ .filter((modelId) => !isInternalClaudeModel(modelId)), "claude_code");
116
+ }
117
+ catch {
118
+ warnings.push("Model pricing API is unavailable.");
119
+ }
129
120
  for (const event of selectedEvents) {
121
+ event.totals = usageToTotals(event.modelId, event.usage, event.timestampMs, pricing);
130
122
  addModelUsage(byModel, event.modelId, event.totals);
131
123
  const planType = typeof event.rateLimits?.plan_type === "string" ? event.rateLimits.plan_type : undefined;
132
124
  const safeEventTimeMs = Number.isFinite(event.timestampMs) ? event.timestampMs : 0;
@@ -143,17 +135,14 @@ export class ClaudeUsageProvider extends UsageProviderBase {
143
135
  if (options.verbose && parsedEvents.duplicateUsageKeyCollisions > 0) {
144
136
  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
137
  }
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
138
  const modelUsage = [...byModel.entries()]
150
139
  .map(([modelId, totals]) => ({ modelId, totals }))
151
140
  .sort((left, right) => right.totals.estimatedCredits - left.totals.estimatedCredits);
152
141
  const unknownPricedModels = modelUsage
153
- .map((row) => row.modelId)
154
- .filter((modelId) => !resolveRate(modelId) && !isInternalClaudeModel(modelId));
142
+ .filter((row) => row.totals.estimatedCreditsStatus === "unavailable")
143
+ .map((row) => row.modelId);
155
144
  if (unknownPricedModels.length > 0) {
156
- warnings.push(`No credit rate configured for: ${unknownPricedModels.join(", ")}.`);
145
+ warnings.push(`No complete API-equivalent pricing returned for: ${unknownPricedModels.join(", ")}.`);
157
146
  }
158
147
  if (parsedSessionFiles.length === 0) {
159
148
  warnings.push(`No Claude session files found under ${sessionsRoot}.`);
@@ -216,46 +205,53 @@ export class ClaudeUsageProvider extends UsageProviderBase {
216
205
  function normalizeUsage(value) {
217
206
  const usage = asRecord(value) ?? {};
218
207
  const cacheCreation = asRecord(usage.cache_creation);
208
+ const serverToolUse = asRecord(usage.server_tool_use);
219
209
  const cacheCreation5mInputTokens = numberOrZero(cacheCreation?.ephemeral_5m_input_tokens);
220
210
  const cacheCreation1hInputTokens = numberOrZero(cacheCreation?.ephemeral_1h_input_tokens);
221
- const cacheCreationInputTokens = Math.max(numberOrZero(usage.cache_creation_input_tokens), cacheCreation5mInputTokens + cacheCreation1hInputTokens);
222
211
  return {
223
212
  inputTokens: numberOrZero(usage.input_tokens),
224
213
  cacheReadInputTokens: numberOrZero(usage.cache_read_input_tokens),
225
- cacheCreationInputTokens,
226
214
  cacheCreation5mInputTokens,
227
215
  cacheCreation1hInputTokens,
228
216
  outputTokens: numberOrZero(usage.output_tokens),
229
- inferenceGeo: String(usage.inference_geo ?? "")
217
+ inferenceGeo: String(usage.inference_geo ?? ""),
218
+ speed: String(usage.speed ?? ""),
219
+ webSearchRequests: numberOrZero(serverToolUse?.web_search_requests)
230
220
  };
231
221
  }
232
- function resolveRate(modelId, timestampMs) {
233
- return resolveUsageRate(RATE_CARD, modelId, 0, { prefixMatch: true, timestampMs });
234
- }
235
222
  function isInternalClaudeModel(modelId) {
236
223
  return modelId === "<synthetic>";
237
224
  }
238
- function creditsFor(modelId, usage, timestampMs) {
239
- const rate = resolveRate(modelId, timestampMs);
240
- if (!rate) {
241
- return 0;
242
- }
225
+ function creditsFor(modelId, usage, pricing) {
243
226
  const cacheWriteBreakdown = resolveClaudeCacheWriteBreakdown(usage);
244
227
  // The US inference surcharge must match regardless of the casing the source
245
228
  // reports (e.g. "us", "US"), so compare case-insensitively.
246
229
  const inferenceMultiplier = usage.inferenceGeo.trim().toLowerCase() === "us" ? 1.1 : 1;
247
- return (((usage.inputTokens / 1000000) * rate.input +
248
- (usage.cacheReadInputTokens / 1000000) * rate.cacheRead +
249
- (cacheWriteBreakdown.cacheWrite5mInputTokens / 1000000) * rate.cacheWrite5m +
250
- (cacheWriteBreakdown.cacheWrite1hInputTokens / 1000000) * rate.cacheWrite1h +
251
- (usage.outputTokens / 1000000) * rate.output) *
252
- inferenceMultiplier *
253
- USD_TO_CREDITS);
230
+ const speedMultiplier = usage.speed.trim().toLowerCase() === "fast" && isClaudeFastPricedModel(modelId)
231
+ ? 2
232
+ : 1;
233
+ const tokenCostCredits = modelCostCredits(pricing.get(modelId), {
234
+ inputTokens: usage.inputTokens,
235
+ outputTokens: usage.outputTokens,
236
+ cacheReadInputTokens: usage.cacheReadInputTokens,
237
+ cacheWrite5mInputTokens: cacheWriteBreakdown.cacheWrite5mInputTokens,
238
+ cacheWrite1hInputTokens: cacheWriteBreakdown.cacheWrite1hInputTokens
239
+ });
240
+ if (tokenCostCredits === undefined) {
241
+ return undefined;
242
+ }
243
+ return (tokenCostCredits * speedMultiplier * inferenceMultiplier +
244
+ usage.webSearchRequests);
245
+ }
246
+ function isClaudeFastPricedModel(modelId) {
247
+ return ["claude-opus-5", "claude-opus-4-8"].some((candidate) => modelId === candidate || modelId.startsWith(`${candidate}-`));
254
248
  }
255
- function usageToTotals(modelId, usage, timestampMs) {
249
+ function usageToTotals(modelId, usage, _timestampMs, pricing = EMPTY_MODEL_PRICING) {
256
250
  const cacheWriteBreakdown = resolveClaudeCacheWriteBreakdown(usage);
257
251
  const cacheWriteInputTokens = cacheWriteBreakdown.cacheWrite5mInputTokens +
258
252
  cacheWriteBreakdown.cacheWrite1hInputTokens;
253
+ const estimatedCredits = creditsFor(modelId, usage, pricing);
254
+ const rateKnown = estimatedCredits !== undefined || isInternalClaudeModel(modelId);
259
255
  return {
260
256
  inputTokens: usage.inputTokens,
261
257
  outputTokens: usage.outputTokens,
@@ -268,8 +264,9 @@ function usageToTotals(modelId, usage, timestampMs) {
268
264
  usage.cacheReadInputTokens +
269
265
  cacheWriteInputTokens +
270
266
  usage.outputTokens,
271
- estimatedCredits: creditsFor(modelId, usage, timestampMs),
272
- eventCount: 1
267
+ estimatedCredits: estimatedCredits ?? 0,
268
+ eventCount: 1,
269
+ estimatedCreditsStatus: rateKnown ? "known" : "unavailable"
273
270
  };
274
271
  }
275
272
  function addModelUsage(byModel, modelId, deltaTotals) {
@@ -279,10 +276,8 @@ function addModelUsage(byModel, modelId, deltaTotals) {
279
276
  byModel.set(resolvedModelId, totals);
280
277
  }
281
278
  function resolveClaudeCacheWriteBreakdown(usage) {
282
- const cacheWriteKnownTokens = usage.cacheCreation5mInputTokens + usage.cacheCreation1hInputTokens;
283
- const cacheWriteFallbackTokens = Math.max(0, usage.cacheCreationInputTokens - cacheWriteKnownTokens);
284
279
  return {
285
- cacheWrite5mInputTokens: usage.cacheCreation5mInputTokens + cacheWriteFallbackTokens,
280
+ cacheWrite5mInputTokens: usage.cacheCreation5mInputTokens,
286
281
  cacheWrite1hInputTokens: usage.cacheCreation1hInputTokens
287
282
  };
288
283
  }
@@ -437,7 +432,7 @@ async function parseSessionFile(filePath) {
437
432
  }
438
433
  const message = asRecord(payloadObject.message);
439
434
  const usage = asRecord(message?.usage);
440
- if (!usage) {
435
+ if (!usage || !isCurrentClaudeUsageEvent(payloadObject, message, usage)) {
441
436
  continue;
442
437
  }
443
438
  const modelId = String(message?.model ?? "unknown");
@@ -467,6 +462,21 @@ async function parseSessionFile(filePath) {
467
462
  events
468
463
  };
469
464
  }
465
+ function isCurrentClaudeUsageEvent(payloadObject, message, usage) {
466
+ return (typeof payloadObject.version === "string" &&
467
+ payloadObject.version.length > 0 &&
468
+ typeof payloadObject.sessionId === "string" &&
469
+ payloadObject.sessionId.length > 0 &&
470
+ typeof payloadObject.requestId === "string" &&
471
+ payloadObject.requestId.length > 0 &&
472
+ typeof message?.id === "string" &&
473
+ message.id.length > 0 &&
474
+ typeof usage.service_tier === "string" &&
475
+ typeof usage.speed === "string" &&
476
+ typeof usage.inference_geo === "string" &&
477
+ asRecord(usage.cache_creation) !== null &&
478
+ asRecord(usage.server_tool_use) !== null);
479
+ }
470
480
  function buildUsageEventKeys(payloadObject, message) {
471
481
  const sessionId = String(payloadObject.sessionId ?? "");
472
482
  const requestId = typeof payloadObject.requestId === "string" ? payloadObject.requestId : "";
@@ -484,22 +494,20 @@ function buildUsageSignatureFromParts(sessionId, modelId, usage) {
484
494
  sessionId,
485
495
  modelId,
486
496
  usage.inputTokens,
487
- usage.cacheCreationInputTokens,
488
497
  usage.cacheCreation5mInputTokens,
489
498
  usage.cacheCreation1hInputTokens,
490
499
  usage.cacheReadInputTokens,
491
500
  usage.outputTokens,
492
- usage.inferenceGeo
501
+ usage.inferenceGeo,
502
+ usage.speed,
503
+ usage.webSearchRequests
493
504
  ].join("|");
494
505
  }
495
506
  function createParsedUsageEventAccumulator() {
496
507
  return {
497
508
  keyedEvents: new Map(),
498
- unkeyedEvents: [],
499
- lastUnkeyedEventsBySignature: new Map(),
500
509
  duplicateUsageKeys: 0,
501
- duplicateUsageKeyCollisions: 0,
502
- duplicateUnkeyedEvents: 0
510
+ duplicateUsageKeyCollisions: 0
503
511
  };
504
512
  }
505
513
  function recordParsedUsageEvent(parsedEvents, event) {
@@ -527,23 +535,6 @@ function recordParsedUsageEvent(parsedEvents, event) {
527
535
  }
528
536
  return;
529
537
  }
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
538
  }
548
539
  function mergeParsedUsageEvents(previous, next) {
549
540
  // Same-key events are repeated/streamed snapshots of one logical request. Rather than
@@ -595,9 +586,6 @@ function selectMergedEventModelId(primary, other) {
595
586
  }
596
587
  return primary.modelId;
597
588
  }
598
- function canCollapseAdjacentUnkeyedUsageEvents(previous, next) {
599
- return previous.filePath === next.filePath && next.lineNumber === previous.lineNumber + 1;
600
- }
601
589
  function extractUsageKeySessionId(usageKeys) {
602
590
  const usageKey = usageKeys[0];
603
591
  if (!usageKey) {
@@ -1069,6 +1057,19 @@ function parseLiveUsageWindowSnapshots(usageOutput, now) {
1069
1057
  resetMsByLabel.set(parsed.label, resetsAtMs);
1070
1058
  }
1071
1059
  }
1060
+ // Claude omits reset timestamps while an entire bucket is displayed as 0%.
1061
+ // Keep that authoritative zero instead of silently dropping the window. The
1062
+ // provisional range starts at the sample time, so no earlier transcript
1063
+ // events are falsely paired with the zero-percent measurement.
1064
+ for (const label of ["session", "week"]) {
1065
+ const sameLabelLines = parsedLines.filter((parsed) => parsed.label === label);
1066
+ if (!resetMsByLabel.has(label) &&
1067
+ sameLabelLines.length > 0 &&
1068
+ sameLabelLines.every((parsed) => parsed.usedPercent === 0)) {
1069
+ const windowMinutes = label === "session" ? CLAUDE_SESSION_WINDOW_MINUTES : CLAUDE_WEEK_WINDOW_MINUTES;
1070
+ resetMsByLabel.set(label, now.getTime() + windowMinutes * 60000);
1071
+ }
1072
+ }
1072
1073
  const snapshots = new Map();
1073
1074
  for (const parsed of parsedLines) {
1074
1075
  const resetsAtMs = resetMsByLabel.get(parsed.label) ?? null;
@@ -1163,6 +1164,9 @@ function buildLiveLimitWindowRow(snapshot, planType, selectedEvents, now) {
1163
1164
  lastSeenUtcIso: toUtcIso(Number.isFinite(lastSeenMs) ? lastSeenMs : fallbackLastSeenMs),
1164
1165
  minUsedPercent: snapshot.usedPercent,
1165
1166
  maxUsedPercent: snapshot.usedPercent,
1167
+ measuredUsedPercent: snapshot.usedPercent > 0 && totals.eventCount === 0
1168
+ ? null
1169
+ : snapshot.usedPercent,
1166
1170
  totals,
1167
1171
  modelUsage: buildModelUsageRowsForEvents(inWindowEvents),
1168
1172
  eventCount: totals.eventCount