letmecode 0.1.28 → 0.1.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/ink-app/dist/index.js +12 -9
- package/ink-app/dist/providers/claude.js +62 -60
- package/ink-app/dist/providers/codex.js +274 -80
- 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,28 +9,19 @@ import { UsageProviderBase, addUsageTotals, createEmptyUsageTotals, sumUsageTota
|
|
|
9
9
|
import { applyRateLimits, asRecord, buildWindowLists, createLimitWindowAggregates, numberOrZero } from "./limits.js";
|
|
10
10
|
import { addDailyUsage, buildDailyUsageRows, createDailyUsageAggregates } from "./daily.js";
|
|
11
11
|
import { resolveUsageRate } from "./pricing.js";
|
|
12
|
+
// Source: https://platform.claude.com/docs/en/about-claude/pricing
|
|
13
|
+
// (checked 2026-09-05).
|
|
12
14
|
const RATE_CARD = {
|
|
15
|
+
"claude-fable-5-1": { input: 10, cacheRead: 0.25, cacheWrite: 12.5, cacheWrite5m: 12.5, cacheWrite1h: 20, output: 50 },
|
|
13
16
|
"claude-fable-5": { input: 10, cacheRead: 1, cacheWrite: 12.5, cacheWrite5m: 12.5, cacheWrite1h: 20, output: 50 },
|
|
17
|
+
"claude-opus-5": { input: 5, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite5m: 6.25, cacheWrite1h: 10, output: 25 },
|
|
14
18
|
"claude-opus-4-8": { input: 5, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite5m: 6.25, cacheWrite1h: 10, output: 25 },
|
|
15
19
|
"claude-opus-4-7": { input: 5, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite5m: 6.25, cacheWrite1h: 10, output: 25 },
|
|
16
20
|
"claude-opus-4-6": { input: 5, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite5m: 6.25, cacheWrite1h: 10, output: 25 },
|
|
17
21
|
"claude-opus-4-5": { input: 5, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite5m: 6.25, cacheWrite1h: 10, output: 25 },
|
|
18
22
|
"claude-opus-4-1": { input: 15, cacheRead: 1.5, cacheWrite: 18.75, cacheWrite5m: 18.75, cacheWrite1h: 30, output: 75 },
|
|
19
23
|
"claude-opus-4": { input: 15, cacheRead: 1.5, cacheWrite: 18.75, cacheWrite5m: 18.75, cacheWrite1h: 30, output: 75 },
|
|
20
|
-
"claude-
|
|
21
|
-
"claude-sonnet-5": {
|
|
22
|
-
input: 3,
|
|
23
|
-
cacheRead: 0.3,
|
|
24
|
-
cacheWrite: 3.75,
|
|
25
|
-
cacheWrite5m: 3.75,
|
|
26
|
-
cacheWrite1h: 6,
|
|
27
|
-
output: 15,
|
|
28
|
-
// Intro pricing (~0.66x) through 2026-08-31; reverts to the standard rate above on 2026-09-01.
|
|
29
|
-
introOffer: {
|
|
30
|
-
effectiveUntilMs: Date.UTC(2026, 8, 1),
|
|
31
|
-
rate: { input: 2, cacheRead: 0.2, cacheWrite: 2.5, cacheWrite5m: 2.5, cacheWrite1h: 4, output: 10 }
|
|
32
|
-
}
|
|
33
|
-
},
|
|
24
|
+
"claude-sonnet-5": { input: 2, cacheRead: 0.2, cacheWrite: 2.5, cacheWrite5m: 2.5, cacheWrite1h: 4, output: 10 },
|
|
34
25
|
"claude-sonnet-4-6": { input: 3, cacheRead: 0.3, cacheWrite: 3.75, cacheWrite5m: 3.75, cacheWrite1h: 6, output: 15 },
|
|
35
26
|
"claude-sonnet-4-5": { input: 3, cacheRead: 0.3, cacheWrite: 3.75, cacheWrite5m: 3.75, cacheWrite1h: 6, output: 15 },
|
|
36
27
|
"claude-sonnet-4": { input: 3, cacheRead: 0.3, cacheWrite: 3.75, cacheWrite5m: 3.75, cacheWrite1h: 6, output: 15 },
|
|
@@ -113,15 +104,13 @@ export class ClaudeUsageProvider extends UsageProviderBase {
|
|
|
113
104
|
}
|
|
114
105
|
}
|
|
115
106
|
const selectedEvents = [
|
|
116
|
-
...new Set(parsedEvents.keyedEvents.values())
|
|
117
|
-
...parsedEvents.unkeyedEvents
|
|
107
|
+
...new Set(parsedEvents.keyedEvents.values())
|
|
118
108
|
];
|
|
119
109
|
traceClaude(options.traceLogger, [
|
|
120
110
|
`Transcript selection summary: filesWithMatches=${parseTotals.filesScanned}/${parsedSessionFiles.length}`,
|
|
121
111
|
`selectedEvents=${selectedEvents.length}`,
|
|
122
112
|
`duplicateUsageKeys=${parsedEvents.duplicateUsageKeys}`,
|
|
123
|
-
`duplicateUsageKeyCollisions=${parsedEvents.duplicateUsageKeyCollisions}
|
|
124
|
-
`duplicateUnkeyedEvents=${parsedEvents.duplicateUnkeyedEvents}`
|
|
113
|
+
`duplicateUsageKeyCollisions=${parsedEvents.duplicateUsageKeyCollisions}`
|
|
125
114
|
].join(" "));
|
|
126
115
|
if (selectedEvents.length === 0 && parsedSessionFiles.length > 0) {
|
|
127
116
|
traceClaude(options.traceLogger, "No assistant usage events were found in the parsed Claude session files.");
|
|
@@ -143,9 +132,6 @@ export class ClaudeUsageProvider extends UsageProviderBase {
|
|
|
143
132
|
if (options.verbose && parsedEvents.duplicateUsageKeyCollisions > 0) {
|
|
144
133
|
warnings.push(`Detected ${parsedEvents.duplicateUsageKeyCollisions} Claude usage key collision(s) with different token usage; kept the most complete same-key snapshot to avoid double-counting cumulative snapshots.`);
|
|
145
134
|
}
|
|
146
|
-
if (options.verbose && parsedEvents.duplicateUnkeyedEvents > 0) {
|
|
147
|
-
warnings.push(`Collapsed ${parsedEvents.duplicateUnkeyedEvents} adjacent duplicate unkeyed Claude usage event(s) by usage signature.`);
|
|
148
|
-
}
|
|
149
135
|
const modelUsage = [...byModel.entries()]
|
|
150
136
|
.map(([modelId, totals]) => ({ modelId, totals }))
|
|
151
137
|
.sort((left, right) => right.totals.estimatedCredits - left.totals.estimatedCredits);
|
|
@@ -153,7 +139,7 @@ export class ClaudeUsageProvider extends UsageProviderBase {
|
|
|
153
139
|
.map((row) => row.modelId)
|
|
154
140
|
.filter((modelId) => !resolveRate(modelId) && !isInternalClaudeModel(modelId));
|
|
155
141
|
if (unknownPricedModels.length > 0) {
|
|
156
|
-
warnings.push(`No
|
|
142
|
+
warnings.push(`No API-equivalent rate configured for: ${unknownPricedModels.join(", ")}.`);
|
|
157
143
|
}
|
|
158
144
|
if (parsedSessionFiles.length === 0) {
|
|
159
145
|
warnings.push(`No Claude session files found under ${sessionsRoot}.`);
|
|
@@ -216,17 +202,18 @@ export class ClaudeUsageProvider extends UsageProviderBase {
|
|
|
216
202
|
function normalizeUsage(value) {
|
|
217
203
|
const usage = asRecord(value) ?? {};
|
|
218
204
|
const cacheCreation = asRecord(usage.cache_creation);
|
|
205
|
+
const serverToolUse = asRecord(usage.server_tool_use);
|
|
219
206
|
const cacheCreation5mInputTokens = numberOrZero(cacheCreation?.ephemeral_5m_input_tokens);
|
|
220
207
|
const cacheCreation1hInputTokens = numberOrZero(cacheCreation?.ephemeral_1h_input_tokens);
|
|
221
|
-
const cacheCreationInputTokens = Math.max(numberOrZero(usage.cache_creation_input_tokens), cacheCreation5mInputTokens + cacheCreation1hInputTokens);
|
|
222
208
|
return {
|
|
223
209
|
inputTokens: numberOrZero(usage.input_tokens),
|
|
224
210
|
cacheReadInputTokens: numberOrZero(usage.cache_read_input_tokens),
|
|
225
|
-
cacheCreationInputTokens,
|
|
226
211
|
cacheCreation5mInputTokens,
|
|
227
212
|
cacheCreation1hInputTokens,
|
|
228
213
|
outputTokens: numberOrZero(usage.output_tokens),
|
|
229
|
-
inferenceGeo: String(usage.inference_geo ?? "")
|
|
214
|
+
inferenceGeo: String(usage.inference_geo ?? ""),
|
|
215
|
+
speed: String(usage.speed ?? ""),
|
|
216
|
+
webSearchRequests: numberOrZero(serverToolUse?.web_search_requests)
|
|
230
217
|
};
|
|
231
218
|
}
|
|
232
219
|
function resolveRate(modelId, timestampMs) {
|
|
@@ -244,18 +231,25 @@ function creditsFor(modelId, usage, timestampMs) {
|
|
|
244
231
|
// The US inference surcharge must match regardless of the casing the source
|
|
245
232
|
// reports (e.g. "us", "US"), so compare case-insensitively.
|
|
246
233
|
const inferenceMultiplier = usage.inferenceGeo.trim().toLowerCase() === "us" ? 1.1 : 1;
|
|
247
|
-
|
|
234
|
+
const speedMultiplier = usage.speed.trim().toLowerCase() === "fast" && isClaudeFastPricedModel(modelId)
|
|
235
|
+
? 2
|
|
236
|
+
: 1;
|
|
237
|
+
const tokenCostUsd = (usage.inputTokens / 1000000) * rate.input +
|
|
248
238
|
(usage.cacheReadInputTokens / 1000000) * rate.cacheRead +
|
|
249
239
|
(cacheWriteBreakdown.cacheWrite5mInputTokens / 1000000) * rate.cacheWrite5m +
|
|
250
240
|
(cacheWriteBreakdown.cacheWrite1hInputTokens / 1000000) * rate.cacheWrite1h +
|
|
251
|
-
(usage.outputTokens / 1000000) * rate.output
|
|
252
|
-
|
|
253
|
-
|
|
241
|
+
(usage.outputTokens / 1000000) * rate.output;
|
|
242
|
+
const webSearchCostUsd = usage.webSearchRequests * 0.01;
|
|
243
|
+
return (tokenCostUsd * speedMultiplier * inferenceMultiplier + webSearchCostUsd) * USD_TO_CREDITS;
|
|
244
|
+
}
|
|
245
|
+
function isClaudeFastPricedModel(modelId) {
|
|
246
|
+
return ["claude-opus-5", "claude-opus-4-8"].some((candidate) => modelId === candidate || modelId.startsWith(`${candidate}-`));
|
|
254
247
|
}
|
|
255
248
|
function usageToTotals(modelId, usage, timestampMs) {
|
|
256
249
|
const cacheWriteBreakdown = resolveClaudeCacheWriteBreakdown(usage);
|
|
257
250
|
const cacheWriteInputTokens = cacheWriteBreakdown.cacheWrite5mInputTokens +
|
|
258
251
|
cacheWriteBreakdown.cacheWrite1hInputTokens;
|
|
252
|
+
const rateKnown = Boolean(resolveRate(modelId, timestampMs)) || isInternalClaudeModel(modelId);
|
|
259
253
|
return {
|
|
260
254
|
inputTokens: usage.inputTokens,
|
|
261
255
|
outputTokens: usage.outputTokens,
|
|
@@ -269,7 +263,8 @@ function usageToTotals(modelId, usage, timestampMs) {
|
|
|
269
263
|
cacheWriteInputTokens +
|
|
270
264
|
usage.outputTokens,
|
|
271
265
|
estimatedCredits: creditsFor(modelId, usage, timestampMs),
|
|
272
|
-
eventCount: 1
|
|
266
|
+
eventCount: 1,
|
|
267
|
+
estimatedCreditsStatus: rateKnown ? "known" : "unavailable"
|
|
273
268
|
};
|
|
274
269
|
}
|
|
275
270
|
function addModelUsage(byModel, modelId, deltaTotals) {
|
|
@@ -279,10 +274,8 @@ function addModelUsage(byModel, modelId, deltaTotals) {
|
|
|
279
274
|
byModel.set(resolvedModelId, totals);
|
|
280
275
|
}
|
|
281
276
|
function resolveClaudeCacheWriteBreakdown(usage) {
|
|
282
|
-
const cacheWriteKnownTokens = usage.cacheCreation5mInputTokens + usage.cacheCreation1hInputTokens;
|
|
283
|
-
const cacheWriteFallbackTokens = Math.max(0, usage.cacheCreationInputTokens - cacheWriteKnownTokens);
|
|
284
277
|
return {
|
|
285
|
-
cacheWrite5mInputTokens: usage.cacheCreation5mInputTokens
|
|
278
|
+
cacheWrite5mInputTokens: usage.cacheCreation5mInputTokens,
|
|
286
279
|
cacheWrite1hInputTokens: usage.cacheCreation1hInputTokens
|
|
287
280
|
};
|
|
288
281
|
}
|
|
@@ -437,7 +430,7 @@ async function parseSessionFile(filePath) {
|
|
|
437
430
|
}
|
|
438
431
|
const message = asRecord(payloadObject.message);
|
|
439
432
|
const usage = asRecord(message?.usage);
|
|
440
|
-
if (!usage) {
|
|
433
|
+
if (!usage || !isCurrentClaudeUsageEvent(payloadObject, message, usage)) {
|
|
441
434
|
continue;
|
|
442
435
|
}
|
|
443
436
|
const modelId = String(message?.model ?? "unknown");
|
|
@@ -467,6 +460,21 @@ async function parseSessionFile(filePath) {
|
|
|
467
460
|
events
|
|
468
461
|
};
|
|
469
462
|
}
|
|
463
|
+
function isCurrentClaudeUsageEvent(payloadObject, message, usage) {
|
|
464
|
+
return (typeof payloadObject.version === "string" &&
|
|
465
|
+
payloadObject.version.length > 0 &&
|
|
466
|
+
typeof payloadObject.sessionId === "string" &&
|
|
467
|
+
payloadObject.sessionId.length > 0 &&
|
|
468
|
+
typeof payloadObject.requestId === "string" &&
|
|
469
|
+
payloadObject.requestId.length > 0 &&
|
|
470
|
+
typeof message?.id === "string" &&
|
|
471
|
+
message.id.length > 0 &&
|
|
472
|
+
typeof usage.service_tier === "string" &&
|
|
473
|
+
typeof usage.speed === "string" &&
|
|
474
|
+
typeof usage.inference_geo === "string" &&
|
|
475
|
+
asRecord(usage.cache_creation) !== null &&
|
|
476
|
+
asRecord(usage.server_tool_use) !== null);
|
|
477
|
+
}
|
|
470
478
|
function buildUsageEventKeys(payloadObject, message) {
|
|
471
479
|
const sessionId = String(payloadObject.sessionId ?? "");
|
|
472
480
|
const requestId = typeof payloadObject.requestId === "string" ? payloadObject.requestId : "";
|
|
@@ -484,22 +492,20 @@ function buildUsageSignatureFromParts(sessionId, modelId, usage) {
|
|
|
484
492
|
sessionId,
|
|
485
493
|
modelId,
|
|
486
494
|
usage.inputTokens,
|
|
487
|
-
usage.cacheCreationInputTokens,
|
|
488
495
|
usage.cacheCreation5mInputTokens,
|
|
489
496
|
usage.cacheCreation1hInputTokens,
|
|
490
497
|
usage.cacheReadInputTokens,
|
|
491
498
|
usage.outputTokens,
|
|
492
|
-
usage.inferenceGeo
|
|
499
|
+
usage.inferenceGeo,
|
|
500
|
+
usage.speed,
|
|
501
|
+
usage.webSearchRequests
|
|
493
502
|
].join("|");
|
|
494
503
|
}
|
|
495
504
|
function createParsedUsageEventAccumulator() {
|
|
496
505
|
return {
|
|
497
506
|
keyedEvents: new Map(),
|
|
498
|
-
unkeyedEvents: [],
|
|
499
|
-
lastUnkeyedEventsBySignature: new Map(),
|
|
500
507
|
duplicateUsageKeys: 0,
|
|
501
|
-
duplicateUsageKeyCollisions: 0
|
|
502
|
-
duplicateUnkeyedEvents: 0
|
|
508
|
+
duplicateUsageKeyCollisions: 0
|
|
503
509
|
};
|
|
504
510
|
}
|
|
505
511
|
function recordParsedUsageEvent(parsedEvents, event) {
|
|
@@ -527,23 +533,6 @@ function recordParsedUsageEvent(parsedEvents, event) {
|
|
|
527
533
|
}
|
|
528
534
|
return;
|
|
529
535
|
}
|
|
530
|
-
const previousRecord = parsedEvents.lastUnkeyedEventsBySignature.get(event.usageSignature);
|
|
531
|
-
if (!previousRecord || !canCollapseAdjacentUnkeyedUsageEvents(previousRecord.event, event)) {
|
|
532
|
-
parsedEvents.unkeyedEvents.push(event);
|
|
533
|
-
parsedEvents.lastUnkeyedEventsBySignature.set(event.usageSignature, {
|
|
534
|
-
event,
|
|
535
|
-
index: parsedEvents.unkeyedEvents.length - 1
|
|
536
|
-
});
|
|
537
|
-
return;
|
|
538
|
-
}
|
|
539
|
-
parsedEvents.duplicateUnkeyedEvents += 1;
|
|
540
|
-
if (normalizeTimestamp(event.timestampMs) > normalizeTimestamp(previousRecord.event.timestampMs)) {
|
|
541
|
-
parsedEvents.unkeyedEvents[previousRecord.index] = event;
|
|
542
|
-
parsedEvents.lastUnkeyedEventsBySignature.set(event.usageSignature, {
|
|
543
|
-
event,
|
|
544
|
-
index: previousRecord.index
|
|
545
|
-
});
|
|
546
|
-
}
|
|
547
536
|
}
|
|
548
537
|
function mergeParsedUsageEvents(previous, next) {
|
|
549
538
|
// Same-key events are repeated/streamed snapshots of one logical request. Rather than
|
|
@@ -595,9 +584,6 @@ function selectMergedEventModelId(primary, other) {
|
|
|
595
584
|
}
|
|
596
585
|
return primary.modelId;
|
|
597
586
|
}
|
|
598
|
-
function canCollapseAdjacentUnkeyedUsageEvents(previous, next) {
|
|
599
|
-
return previous.filePath === next.filePath && next.lineNumber === previous.lineNumber + 1;
|
|
600
|
-
}
|
|
601
587
|
function extractUsageKeySessionId(usageKeys) {
|
|
602
588
|
const usageKey = usageKeys[0];
|
|
603
589
|
if (!usageKey) {
|
|
@@ -1069,6 +1055,19 @@ function parseLiveUsageWindowSnapshots(usageOutput, now) {
|
|
|
1069
1055
|
resetMsByLabel.set(parsed.label, resetsAtMs);
|
|
1070
1056
|
}
|
|
1071
1057
|
}
|
|
1058
|
+
// Claude omits reset timestamps while an entire bucket is displayed as 0%.
|
|
1059
|
+
// Keep that authoritative zero instead of silently dropping the window. The
|
|
1060
|
+
// provisional range starts at the sample time, so no earlier transcript
|
|
1061
|
+
// events are falsely paired with the zero-percent measurement.
|
|
1062
|
+
for (const label of ["session", "week"]) {
|
|
1063
|
+
const sameLabelLines = parsedLines.filter((parsed) => parsed.label === label);
|
|
1064
|
+
if (!resetMsByLabel.has(label) &&
|
|
1065
|
+
sameLabelLines.length > 0 &&
|
|
1066
|
+
sameLabelLines.every((parsed) => parsed.usedPercent === 0)) {
|
|
1067
|
+
const windowMinutes = label === "session" ? CLAUDE_SESSION_WINDOW_MINUTES : CLAUDE_WEEK_WINDOW_MINUTES;
|
|
1068
|
+
resetMsByLabel.set(label, now.getTime() + windowMinutes * 60000);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1072
1071
|
const snapshots = new Map();
|
|
1073
1072
|
for (const parsed of parsedLines) {
|
|
1074
1073
|
const resetsAtMs = resetMsByLabel.get(parsed.label) ?? null;
|
|
@@ -1163,6 +1162,9 @@ function buildLiveLimitWindowRow(snapshot, planType, selectedEvents, now) {
|
|
|
1163
1162
|
lastSeenUtcIso: toUtcIso(Number.isFinite(lastSeenMs) ? lastSeenMs : fallbackLastSeenMs),
|
|
1164
1163
|
minUsedPercent: snapshot.usedPercent,
|
|
1165
1164
|
maxUsedPercent: snapshot.usedPercent,
|
|
1165
|
+
measuredUsedPercent: snapshot.usedPercent > 0 && totals.eventCount === 0
|
|
1166
|
+
? null
|
|
1167
|
+
: snapshot.usedPercent,
|
|
1166
1168
|
totals,
|
|
1167
1169
|
modelUsage: buildModelUsageRowsForEvents(inWindowEvents),
|
|
1168
1170
|
eventCount: totals.eventCount
|
|
@@ -10,50 +10,90 @@ import { resolveUsageRate } from "./pricing.js";
|
|
|
10
10
|
// One credit equals $0.01 (see CODEX_CREDIT_COST_USD in index.tsx), so credits
|
|
11
11
|
// equal USD * 100. Rate cards are expressed in the model's actual API price in
|
|
12
12
|
// USD per 1M tokens and scaled to credits in creditsFor, matching the Claude
|
|
13
|
-
// provider. These are the real
|
|
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
|
+
}
|
|
@@ -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)) {
|