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 +4 -2
- package/ink-app/dist/index.js +12 -9
- package/ink-app/dist/providers/claude.js +62 -59
- package/ink-app/dist/providers/codex.js +274 -80
- package/ink-app/dist/providers/copilot/models.js +1 -0
- package/ink-app/dist/providers/limits.js +42 -5
- package/ink-app/dist/reporting.js +4 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# LetMeCode
|
|
2
2
|
|
|
3
|
-
Terminal AI
|
|
3
|
+
Terminal AI token-usage and API-equivalent value dashboard for Codex, Claude, Copilot, and Antigravity.
|
|
4
4
|
|
|
5
|
-
|
|
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
|
|
package/ink-app/dist/index.js
CHANGED
|
@@ -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: "
|
|
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
|
|
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
|
|
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 === "
|
|
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.
|
|
645
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
252
|
-
|
|
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
|
|
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
|
|
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:
|
|
18
|
-
cacheRead: 0.
|
|
19
|
-
cacheWrite:
|
|
20
|
-
cacheWrite5m:
|
|
21
|
-
cacheWrite1h:
|
|
22
|
-
output:
|
|
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:
|
|
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
|
|
32
|
-
cacheRead: 0.
|
|
33
|
-
cacheWrite:
|
|
34
|
-
cacheWrite5m:
|
|
35
|
-
cacheWrite1h:
|
|
36
|
-
output:
|
|
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:
|
|
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:
|
|
44
|
-
cacheRead: 0.
|
|
45
|
-
cacheWrite:
|
|
46
|
-
cacheWrite5m:
|
|
47
|
-
cacheWrite1h:
|
|
48
|
-
output:
|
|
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:
|
|
91
|
+
rate: { input: 5, cacheRead: 0.5, cacheWrite: 0, cacheWrite5m: 0, cacheWrite1h: 0, output: 22.5 }
|
|
52
92
|
}
|
|
53
93
|
},
|
|
54
|
-
"gpt-5.
|
|
55
|
-
"gpt-5.
|
|
56
|
-
"gpt-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
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
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 ${
|
|
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
|
|
114
|
-
rootPath:
|
|
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
|
|
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
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
(
|
|
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"
|
|
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 =
|
|
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
|
|
300
|
-
cacheWrite5mInputTokens:
|
|
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
|
|
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
|
-
//
|
|
329
|
-
//
|
|
330
|
-
|
|
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")
|
|
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
|
|
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
|
|
377
|
-
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
|
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)) {
|