letmecode 0.1.30 → 0.1.33
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/ink-app/dist/index.js +18 -9
- package/ink-app/dist/providers/claude-plan-state.js +140 -0
- package/ink-app/dist/providers/claude.js +122 -8
- package/ink-app/dist/providers/limits.js +18 -1
- package/ink-app/dist/providers/pricing.js +25 -8
- package/ink-app/dist/reporting.js +56 -1
- package/package.json +1 -1
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, resolveMeasuredUsedPercent, selectLatestActiveLimitWindows } from "./providers/limits.js";
|
|
7
|
+
import { estimateLimitFullValue, isPartialUsagePairing, 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
|
|
@@ -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
|
|
502
|
+
formatLimitFullValueCompact(window)
|
|
503
503
|
]
|
|
504
504
|
};
|
|
505
505
|
}
|
|
@@ -543,7 +543,9 @@ 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: "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
|
|
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)}` }), isPartialUsagePairing(row) ? (_jsx(DetailRow, { label: "Paired from", value: formatCompactLocalDateTime(row.pairedUsageStartUtcIso ?? row.startTimeUtcIso), note: "percentage restarted mid-window" })) : null, _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), note: isPartialUsagePairing(row)
|
|
547
|
+
? `approximate: this percentage restarted mid-window, usage paired from ${formatCompactLocalDateTime(row.pairedUsageStartUtcIso ?? row.startTimeUtcIso)}`
|
|
548
|
+
: "API equivalent cost extrapolated from the paired token/usage interval" })] }));
|
|
547
549
|
}
|
|
548
550
|
if (props.viewId === "day-to-day-analyses" && props.selectedDayRow) {
|
|
549
551
|
const row = props.selectedDayRow;
|
|
@@ -650,17 +652,24 @@ function limitFullValueUsd(totals, usedPercent) {
|
|
|
650
652
|
const usedUsd = totals.estimatedCredits * CODEX_CREDIT_COST_USD;
|
|
651
653
|
return estimateLimitFullValue(usedUsd, usedPercent);
|
|
652
654
|
}
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
655
|
+
// A window whose paired usage covers only part of its span is paired across an
|
|
656
|
+
// only approximately known interval, so the figures it yields carry a "~".
|
|
657
|
+
function formatLimitFullValueCompact(window) {
|
|
658
|
+
const estimate = limitFullValueUsd(window.totals, resolveMeasuredUsedPercent(window));
|
|
659
|
+
if (!estimate) {
|
|
660
|
+
return "-";
|
|
661
|
+
}
|
|
662
|
+
const value = formatUsdWhole(estimate.point);
|
|
663
|
+
return isPartialUsagePairing(window) ? `~${value}` : value;
|
|
656
664
|
}
|
|
657
|
-
function formatLimitFullValueRange(
|
|
658
|
-
const estimate = limitFullValueUsd(totals,
|
|
665
|
+
function formatLimitFullValueRange(window) {
|
|
666
|
+
const estimate = limitFullValueUsd(window.totals, resolveMeasuredUsedPercent(window));
|
|
659
667
|
if (!estimate) {
|
|
660
668
|
return "-";
|
|
661
669
|
}
|
|
662
670
|
const low = formatUsd(estimate.low);
|
|
663
|
-
|
|
671
|
+
const range = Number.isFinite(estimate.high) ? `${low} – ${formatUsd(estimate.high)}` : `≥ ${low}`;
|
|
672
|
+
return isPartialUsagePairing(window) ? `~ ${range}` : range;
|
|
664
673
|
}
|
|
665
674
|
function formatUnitUsd(value) {
|
|
666
675
|
if (!Number.isFinite(value)) {
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { asRecord } from "./limits.js";
|
|
4
|
+
const CLAUDE_PLAN_STATE_VERSION = 1;
|
|
5
|
+
const MAX_TRACKED_PLANS = 20;
|
|
6
|
+
// Refresh ticks re-resolve the same plan every few seconds; only persist the
|
|
7
|
+
// heartbeat once it is stale enough to matter for a switch boundary.
|
|
8
|
+
const LAST_SEEN_REFRESH_MS = 5 * 60000;
|
|
9
|
+
export function getClaudePlanStatePath(root) {
|
|
10
|
+
return path.join(path.resolve(root), ".letmecode", "claude-plan-state.json");
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Record that `planType` is the plan in effect now and return the moment the
|
|
14
|
+
* current plan first became visible, but only when letmecode had already seen a
|
|
15
|
+
* different plan before it. A first-ever observation returns null: without a
|
|
16
|
+
* previous plan on record there is no evidence that the provider's percentages
|
|
17
|
+
* restarted, so the caller must keep pairing the full window.
|
|
18
|
+
*/
|
|
19
|
+
export async function recordClaudePlanObservation(options) {
|
|
20
|
+
const statePath = getClaudePlanStatePath(options.root);
|
|
21
|
+
const nowMs = options.now.getTime();
|
|
22
|
+
const nowIso = new Date(nowMs).toISOString();
|
|
23
|
+
const state = await readClaudePlanState(statePath);
|
|
24
|
+
const observations = state.observations;
|
|
25
|
+
const previous = resolveLatestObservation(observations, options.planType);
|
|
26
|
+
const existing = observations.find((observation) => observation.planType === options.planType);
|
|
27
|
+
let entry;
|
|
28
|
+
let changed = false;
|
|
29
|
+
if (!existing) {
|
|
30
|
+
entry = {
|
|
31
|
+
planType: options.planType,
|
|
32
|
+
firstSeenUtcIso: nowIso,
|
|
33
|
+
lastSeenUtcIso: nowIso,
|
|
34
|
+
...(previous ? { switchedFromPlanType: previous.planType } : {})
|
|
35
|
+
};
|
|
36
|
+
observations.push(entry);
|
|
37
|
+
changed = true;
|
|
38
|
+
}
|
|
39
|
+
else if (previous) {
|
|
40
|
+
// The account came back to a plan letmecode saw earlier: its percentages
|
|
41
|
+
// restarted again, so the stale first-seen no longer marks the boundary.
|
|
42
|
+
existing.firstSeenUtcIso = nowIso;
|
|
43
|
+
existing.lastSeenUtcIso = nowIso;
|
|
44
|
+
existing.switchedFromPlanType = previous.planType;
|
|
45
|
+
entry = existing;
|
|
46
|
+
changed = true;
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
entry = existing;
|
|
50
|
+
changed = nowMs - Date.parse(existing.lastSeenUtcIso) >= LAST_SEEN_REFRESH_MS;
|
|
51
|
+
existing.lastSeenUtcIso = nowIso;
|
|
52
|
+
}
|
|
53
|
+
if (changed) {
|
|
54
|
+
await writeClaudePlanState(statePath, {
|
|
55
|
+
version: CLAUDE_PLAN_STATE_VERSION,
|
|
56
|
+
observations: observations.slice(-MAX_TRACKED_PLANS)
|
|
57
|
+
}, options.traceLogger);
|
|
58
|
+
}
|
|
59
|
+
if (!entry.switchedFromPlanType) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
const planStartMs = Date.parse(entry.firstSeenUtcIso);
|
|
63
|
+
return Number.isFinite(planStartMs) ? planStartMs : null;
|
|
64
|
+
}
|
|
65
|
+
/** Latest plan on record that is not `planType`, by the time it was last seen. */
|
|
66
|
+
function resolveLatestObservation(observations, planType) {
|
|
67
|
+
let latest = null;
|
|
68
|
+
let latestMs = Number.NEGATIVE_INFINITY;
|
|
69
|
+
for (const observation of observations) {
|
|
70
|
+
if (observation.planType === planType) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const lastSeenMs = Date.parse(observation.lastSeenUtcIso);
|
|
74
|
+
if (Number.isFinite(lastSeenMs) && lastSeenMs > latestMs) {
|
|
75
|
+
latest = observation;
|
|
76
|
+
latestMs = lastSeenMs;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return latest;
|
|
80
|
+
}
|
|
81
|
+
async function readClaudePlanState(statePath) {
|
|
82
|
+
let text;
|
|
83
|
+
try {
|
|
84
|
+
text = await fs.promises.readFile(statePath, { encoding: "utf8" });
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return { version: CLAUDE_PLAN_STATE_VERSION, observations: [] };
|
|
88
|
+
}
|
|
89
|
+
let payload;
|
|
90
|
+
try {
|
|
91
|
+
payload = JSON.parse(text);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return { version: CLAUDE_PLAN_STATE_VERSION, observations: [] };
|
|
95
|
+
}
|
|
96
|
+
const record = asRecord(payload);
|
|
97
|
+
if (!record || record.version !== CLAUDE_PLAN_STATE_VERSION || !Array.isArray(record.observations)) {
|
|
98
|
+
return { version: CLAUDE_PLAN_STATE_VERSION, observations: [] };
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
version: CLAUDE_PLAN_STATE_VERSION,
|
|
102
|
+
observations: record.observations
|
|
103
|
+
.map((value) => parseObservation(value))
|
|
104
|
+
.filter((observation) => observation !== null)
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function parseObservation(value) {
|
|
108
|
+
const record = asRecord(value);
|
|
109
|
+
if (!record) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
const planType = typeof record.planType === "string" ? record.planType.trim() : "";
|
|
113
|
+
const firstSeenUtcIso = typeof record.firstSeenUtcIso === "string" ? record.firstSeenUtcIso : "";
|
|
114
|
+
const lastSeenUtcIso = typeof record.lastSeenUtcIso === "string" ? record.lastSeenUtcIso : "";
|
|
115
|
+
if (!planType || !Number.isFinite(Date.parse(firstSeenUtcIso)) || !Number.isFinite(Date.parse(lastSeenUtcIso))) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
const switchedFromPlanType = typeof record.switchedFromPlanType === "string" && record.switchedFromPlanType.trim()
|
|
119
|
+
? record.switchedFromPlanType.trim()
|
|
120
|
+
: undefined;
|
|
121
|
+
return {
|
|
122
|
+
planType,
|
|
123
|
+
firstSeenUtcIso,
|
|
124
|
+
lastSeenUtcIso,
|
|
125
|
+
...(switchedFromPlanType ? { switchedFromPlanType } : {})
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/** Never let a read-only or full disk break the dashboard. */
|
|
129
|
+
async function writeClaudePlanState(statePath, state, traceLogger) {
|
|
130
|
+
const temporaryPath = `${statePath}.${process.pid}.tmp`;
|
|
131
|
+
try {
|
|
132
|
+
await fs.promises.mkdir(path.dirname(statePath), { recursive: true });
|
|
133
|
+
await fs.promises.writeFile(temporaryPath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
134
|
+
await fs.promises.rename(temporaryPath, statePath);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
traceLogger?.log(`[Claude] Could not persist plan state to ${statePath}: ${String(error)}`);
|
|
138
|
+
await fs.promises.rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -6,7 +6,8 @@ import path from "node:path";
|
|
|
6
6
|
import readline from "node:readline";
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
8
|
import { UsageProviderBase, addUsageTotals, createEmptyUsageTotals, sumUsageTotals } from "./contract.js";
|
|
9
|
-
import { applyRateLimits, asRecord, buildWindowLists, createLimitWindowAggregates, numberOrZero } from "./limits.js";
|
|
9
|
+
import { applyRateLimits, asRecord, buildWindowLists, createLimitWindowAggregates, isPartialUsagePairing, numberOrZero } from "./limits.js";
|
|
10
|
+
import { recordClaudePlanObservation } from "./claude-plan-state.js";
|
|
10
11
|
import { addDailyUsage, buildDailyUsageRows, createDailyUsageAggregates } from "./daily.js";
|
|
11
12
|
import { fetchModelPricing, modelCostCredits } from "./pricing.js";
|
|
12
13
|
/*
|
|
@@ -27,6 +28,14 @@ const VSCODE_CLAUDE_EXTENSION_PREFIX = "anthropic.claude-code-";
|
|
|
27
28
|
const CLAUDE_SESSION_WINDOW_MINUTES = 5 * 60;
|
|
28
29
|
const CLAUDE_WEEK_WINDOW_MINUTES = 7 * 24 * 60;
|
|
29
30
|
const ANSI_ESCAPE_SEQUENCE = /\u001B\[[0-9;]*[A-Za-z]/g;
|
|
31
|
+
// Transcript `quotaLimits` entries name the quota cycle that rejected a request.
|
|
32
|
+
// Their reset instant identifies the cycle, so a cycle that differs from the one
|
|
33
|
+
// /usage reports now proves the account's quota accounting changed mid-window.
|
|
34
|
+
const CLAUDE_QUOTA_WINDOW_MINUTES_BY_RATE_LIMIT_TYPE = {
|
|
35
|
+
five_hour: CLAUDE_SESSION_WINDOW_MINUTES,
|
|
36
|
+
seven_day: CLAUDE_WEEK_WINDOW_MINUTES
|
|
37
|
+
};
|
|
38
|
+
const CLAUDE_QUOTA_RESET_MATCH_TOLERANCE_MS = 60000;
|
|
30
39
|
const MONTH_INDEX_BY_LABEL = {
|
|
31
40
|
jan: 0,
|
|
32
41
|
feb: 1,
|
|
@@ -96,6 +105,7 @@ export class ClaudeUsageProvider extends UsageProviderBase {
|
|
|
96
105
|
recordParsedUsageEvent(parsedEvents, event);
|
|
97
106
|
}
|
|
98
107
|
}
|
|
108
|
+
const quotaObservations = parsedSessionFiles.flatMap((file) => file.quotaObservations);
|
|
99
109
|
const selectedEvents = [
|
|
100
110
|
...new Set(parsedEvents.keyedEvents.values())
|
|
101
111
|
];
|
|
@@ -156,7 +166,8 @@ export class ClaudeUsageProvider extends UsageProviderBase {
|
|
|
156
166
|
readOauthCredentials: this.readOauthCredentials,
|
|
157
167
|
traceLogger: options.traceLogger,
|
|
158
168
|
now: this.now(),
|
|
159
|
-
selectedEvents
|
|
169
|
+
selectedEvents,
|
|
170
|
+
quotaObservations
|
|
160
171
|
});
|
|
161
172
|
const primaryLimitWindows = liveLimitWindows.primaryLimitWindows.length > 0
|
|
162
173
|
? liveLimitWindows.primaryLimitWindows
|
|
@@ -164,6 +175,11 @@ export class ClaudeUsageProvider extends UsageProviderBase {
|
|
|
164
175
|
const secondaryLimitWindows = liveLimitWindows.secondaryLimitWindows.length > 0
|
|
165
176
|
? liveLimitWindows.secondaryLimitWindows
|
|
166
177
|
: fallbackSecondaryLimitWindows;
|
|
178
|
+
for (const window of [...primaryLimitWindows, ...secondaryLimitWindows]) {
|
|
179
|
+
if (isPartialUsagePairing(window) && window.pairedUsageStartUtcIso) {
|
|
180
|
+
warnings.push(`The ${formatWindowMinutesLabel(window.windowMinutes)} ${window.planType} limit restarted its percentage mid-window, so only usage from ${window.pairedUsageStartUtcIso} is paired with it and its full value is approximate.`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
167
183
|
traceClaude(options.traceLogger, [
|
|
168
184
|
`Finished stats collection:`,
|
|
169
185
|
`filesScanned=${parseTotals.filesScanned}`,
|
|
@@ -414,6 +430,7 @@ async function parseSessionFile(filePath) {
|
|
|
414
430
|
let linesRead = 0;
|
|
415
431
|
let malformedLines = 0;
|
|
416
432
|
const events = [];
|
|
433
|
+
const quotaObservations = [];
|
|
417
434
|
for await (const line of lineReader) {
|
|
418
435
|
linesRead += 1;
|
|
419
436
|
if (!line.trim()) {
|
|
@@ -430,6 +447,12 @@ async function parseSessionFile(filePath) {
|
|
|
430
447
|
if (payloadObject.type !== "assistant") {
|
|
431
448
|
continue;
|
|
432
449
|
}
|
|
450
|
+
// Rejected requests carry the quota cycle that turned them down but no usage,
|
|
451
|
+
// so this has to be read before the usage gate below drops the line.
|
|
452
|
+
const quotaObservation = extractQuotaObservation(payloadObject);
|
|
453
|
+
if (quotaObservation) {
|
|
454
|
+
quotaObservations.push(quotaObservation);
|
|
455
|
+
}
|
|
433
456
|
const message = asRecord(payloadObject.message);
|
|
434
457
|
const usage = asRecord(message?.usage);
|
|
435
458
|
if (!usage || !isCurrentClaudeUsageEvent(payloadObject, message, usage)) {
|
|
@@ -459,7 +482,25 @@ async function parseSessionFile(filePath) {
|
|
|
459
482
|
filePath,
|
|
460
483
|
linesRead,
|
|
461
484
|
malformedLines,
|
|
462
|
-
events
|
|
485
|
+
events,
|
|
486
|
+
quotaObservations
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
function extractQuotaObservation(payloadObject) {
|
|
490
|
+
const quotaLimits = asRecord(payloadObject.quotaLimits);
|
|
491
|
+
if (!quotaLimits) {
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
const rateLimitType = typeof quotaLimits.rateLimitType === "string" ? quotaLimits.rateLimitType : "";
|
|
495
|
+
const resetsAtSeconds = numberOrZero(quotaLimits.resetsAt);
|
|
496
|
+
const timestampMs = Date.parse(String(payloadObject.timestamp ?? ""));
|
|
497
|
+
if (!rateLimitType || !resetsAtSeconds || !Number.isFinite(timestampMs)) {
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
return {
|
|
501
|
+
timestampMs,
|
|
502
|
+
rateLimitType,
|
|
503
|
+
resetsAtMs: resetsAtSeconds * 1000
|
|
463
504
|
};
|
|
464
505
|
}
|
|
465
506
|
function isCurrentClaudeUsageEvent(payloadObject, message, usage) {
|
|
@@ -600,6 +641,15 @@ function normalizeTimestamp(value) {
|
|
|
600
641
|
function extractRateLimits(payloadObject, message) {
|
|
601
642
|
return asRecord(payloadObject.rate_limits) ?? asRecord(message?.rate_limits);
|
|
602
643
|
}
|
|
644
|
+
function formatWindowMinutesLabel(windowMinutes) {
|
|
645
|
+
if (windowMinutes % (24 * 60) === 0) {
|
|
646
|
+
return `${windowMinutes / (24 * 60)}d`;
|
|
647
|
+
}
|
|
648
|
+
if (windowMinutes % 60 === 0) {
|
|
649
|
+
return `${windowMinutes / 60}h`;
|
|
650
|
+
}
|
|
651
|
+
return `${windowMinutes}m`;
|
|
652
|
+
}
|
|
603
653
|
function traceClaude(traceLogger, message) {
|
|
604
654
|
if (!traceLogger) {
|
|
605
655
|
return;
|
|
@@ -671,12 +721,27 @@ async function buildLiveLimitWindows(options) {
|
|
|
671
721
|
}
|
|
672
722
|
const resolvedPlanType = resolveClaudeLivePlanType(credentials);
|
|
673
723
|
traceClaude(options.traceLogger, `Resolved live plan type ${resolvedPlanType}.`);
|
|
724
|
+
// A percentage reported for the current plan cannot describe usage produced
|
|
725
|
+
// under the plan the account had before it, so remember when the switch became
|
|
726
|
+
// visible and stop pairing anything older with that percentage.
|
|
727
|
+
const planStartMs = resolvedPlanType === "live"
|
|
728
|
+
? null
|
|
729
|
+
: await recordClaudePlanObservation({
|
|
730
|
+
root: options.root,
|
|
731
|
+
planType: resolvedPlanType,
|
|
732
|
+
now: options.now,
|
|
733
|
+
traceLogger: options.traceLogger
|
|
734
|
+
});
|
|
735
|
+
if (planStartMs !== null) {
|
|
736
|
+
traceClaude(options.traceLogger, `Plan ${resolvedPlanType} first seen at ${toUtcIso(planStartMs)}; older usage is not paired with its percentages.`);
|
|
737
|
+
}
|
|
738
|
+
const buildRow = (snapshot) => buildLiveLimitWindowRow(snapshot, resolvedPlanType, options.selectedEvents, options.now, resolvePairedUsageStartMs(snapshot, options.quotaObservations, planStartMs, options.traceLogger));
|
|
674
739
|
const primaryLimitWindows = snapshots
|
|
675
740
|
.filter((snapshot) => snapshot.scope === "primary")
|
|
676
|
-
.map(
|
|
741
|
+
.map(buildRow);
|
|
677
742
|
const secondaryLimitWindows = snapshots
|
|
678
743
|
.filter((snapshot) => snapshot.scope === "secondary")
|
|
679
|
-
.map(
|
|
744
|
+
.map(buildRow);
|
|
680
745
|
for (let index = 0; index < snapshots.length; index += 1) {
|
|
681
746
|
const snapshot = snapshots[index];
|
|
682
747
|
const row = snapshot.scope === "primary"
|
|
@@ -690,6 +755,7 @@ async function buildLiveLimitWindows(options) {
|
|
|
690
755
|
`used=${snapshot.usedPercent}%`,
|
|
691
756
|
`limit=${snapshot.limitId}`,
|
|
692
757
|
`range=${row.startTimeUtcIso}->${row.endTimeUtcIso}`,
|
|
758
|
+
`pairedFrom=${row.pairedUsageStartUtcIso ?? row.startTimeUtcIso}`,
|
|
693
759
|
`matchedEvents=${row.eventCount}`,
|
|
694
760
|
`input=${row.totals.inputTokens}`,
|
|
695
761
|
`output=${row.totals.outputTokens}`,
|
|
@@ -1139,10 +1205,56 @@ function resolveHour24(hour12, meridiem) {
|
|
|
1139
1205
|
}
|
|
1140
1206
|
return meridiem === "pm" ? hour12 + 12 : hour12;
|
|
1141
1207
|
}
|
|
1142
|
-
|
|
1208
|
+
/**
|
|
1209
|
+
* Start of the interval a snapshot's percentage can describe. Usage recorded
|
|
1210
|
+
* before the account's current quota accounting began - a plan switch, or a
|
|
1211
|
+
* rejection from a different quota cycle inside the same window - is no longer
|
|
1212
|
+
* represented by the percentage /usage reports now, so pairing it would inflate
|
|
1213
|
+
* everything extrapolated from the pair. Returns null when the whole window
|
|
1214
|
+
* belongs to the current accounting.
|
|
1215
|
+
*
|
|
1216
|
+
* Both signals only bracket the switch: a foreign-cycle rejection happened at or
|
|
1217
|
+
* before it, the first sighting of the current plan at or after it. The earliest
|
|
1218
|
+
* candidate wins, so no usage that the current percentage does cover is dropped,
|
|
1219
|
+
* and callers treat what they extrapolate as approximate rather than exact.
|
|
1220
|
+
*/
|
|
1221
|
+
function resolvePairedUsageStartMs(snapshot, quotaObservations, planStartMs, traceLogger) {
|
|
1222
|
+
const windowStartMs = snapshot.resetsAtMs - snapshot.windowMinutes * 60000;
|
|
1223
|
+
const candidates = [];
|
|
1224
|
+
if (planStartMs !== null && planStartMs > windowStartMs) {
|
|
1225
|
+
candidates.push(planStartMs);
|
|
1226
|
+
}
|
|
1227
|
+
const foreignCycleEndMs = resolveForeignQuotaCycleEndMs(snapshot, quotaObservations, windowStartMs);
|
|
1228
|
+
if (foreignCycleEndMs !== null) {
|
|
1229
|
+
traceClaude(traceLogger, `Window ${snapshot.limitId} contains a ${snapshot.windowMinutes}m quota rejection from another cycle at ${toUtcIso(foreignCycleEndMs)}.`);
|
|
1230
|
+
// The rejection itself belongs to the previous cycle, so pairing resumes
|
|
1231
|
+
// right after it.
|
|
1232
|
+
candidates.push(foreignCycleEndMs + 1);
|
|
1233
|
+
}
|
|
1234
|
+
return candidates.length > 0 ? Math.min(...candidates) : null;
|
|
1235
|
+
}
|
|
1236
|
+
/**
|
|
1237
|
+
* Latest moment inside the window at which a quota cycle other than the one the
|
|
1238
|
+
* snapshot reports was still in force.
|
|
1239
|
+
*/
|
|
1240
|
+
function resolveForeignQuotaCycleEndMs(snapshot, quotaObservations, windowStartMs) {
|
|
1241
|
+
let latestMs = null;
|
|
1242
|
+
for (const observation of quotaObservations) {
|
|
1243
|
+
if (CLAUDE_QUOTA_WINDOW_MINUTES_BY_RATE_LIMIT_TYPE[observation.rateLimitType] !== snapshot.windowMinutes ||
|
|
1244
|
+
observation.timestampMs < windowStartMs ||
|
|
1245
|
+
observation.timestampMs >= snapshot.resetsAtMs ||
|
|
1246
|
+
Math.abs(observation.resetsAtMs - snapshot.resetsAtMs) <= CLAUDE_QUOTA_RESET_MATCH_TOLERANCE_MS) {
|
|
1247
|
+
continue;
|
|
1248
|
+
}
|
|
1249
|
+
latestMs = latestMs === null ? observation.timestampMs : Math.max(latestMs, observation.timestampMs);
|
|
1250
|
+
}
|
|
1251
|
+
return latestMs;
|
|
1252
|
+
}
|
|
1253
|
+
function buildLiveLimitWindowRow(snapshot, planType, selectedEvents, now, pairedUsageStartMs = null) {
|
|
1143
1254
|
const startTimeMs = snapshot.resetsAtMs - snapshot.windowMinutes * 60000;
|
|
1255
|
+
const usageStartMs = Math.max(startTimeMs, pairedUsageStartMs ?? startTimeMs);
|
|
1144
1256
|
const inWindowEvents = selectedEvents.filter((event) => Number.isFinite(event.timestampMs) &&
|
|
1145
|
-
event.timestampMs >=
|
|
1257
|
+
event.timestampMs >= usageStartMs &&
|
|
1146
1258
|
event.timestampMs < snapshot.resetsAtMs &&
|
|
1147
1259
|
matchesClaudeLiveSnapshotModelScope(snapshot, event.modelId));
|
|
1148
1260
|
const totals = sumUsageTotals(inWindowEvents.map((event) => event.totals));
|
|
@@ -1150,6 +1262,7 @@ function buildLiveLimitWindowRow(snapshot, planType, selectedEvents, now) {
|
|
|
1150
1262
|
totals.estimatedCreditsStatus = "unavailable";
|
|
1151
1263
|
}
|
|
1152
1264
|
const fallbackLastSeenMs = Math.min(now.getTime(), snapshot.resetsAtMs);
|
|
1265
|
+
const hasPartialPairing = usageStartMs > startTimeMs;
|
|
1153
1266
|
const firstSeenMs = inWindowEvents.reduce((minimum, event) => Math.min(minimum, event.timestampMs), Number.POSITIVE_INFINITY);
|
|
1154
1267
|
const lastSeenMs = inWindowEvents.reduce((maximum, event) => Math.max(maximum, event.timestampMs), Number.NEGATIVE_INFINITY);
|
|
1155
1268
|
return {
|
|
@@ -1160,7 +1273,8 @@ function buildLiveLimitWindowRow(snapshot, planType, selectedEvents, now) {
|
|
|
1160
1273
|
windowMinutes: snapshot.windowMinutes,
|
|
1161
1274
|
startTimeUtcIso: toUtcIso(startTimeMs),
|
|
1162
1275
|
endTimeUtcIso: toUtcIso(snapshot.resetsAtMs),
|
|
1163
|
-
|
|
1276
|
+
...(hasPartialPairing ? { pairedUsageStartUtcIso: toUtcIso(usageStartMs) } : {}),
|
|
1277
|
+
firstSeenUtcIso: toUtcIso(Number.isFinite(firstSeenMs) ? firstSeenMs : usageStartMs),
|
|
1164
1278
|
lastSeenUtcIso: toUtcIso(Number.isFinite(lastSeenMs) ? lastSeenMs : fallbackLastSeenMs),
|
|
1165
1279
|
minUsedPercent: snapshot.usedPercent,
|
|
1166
1280
|
maxUsedPercent: snapshot.usedPercent,
|
|
@@ -19,7 +19,12 @@ export function selectLatestActiveLimitWindows(windows, nowMs = Date.now()) {
|
|
|
19
19
|
continue;
|
|
20
20
|
}
|
|
21
21
|
const startMs = Date.parse(window.startTimeUtcIso);
|
|
22
|
-
|
|
22
|
+
// `limitId` is part of the key because a plan can run several limits of the
|
|
23
|
+
// same duration side by side: Claude meters an all-models week and a
|
|
24
|
+
// model-family week that share a plan, a length and a reset instant. Keyed
|
|
25
|
+
// on plan and duration alone they collapse into one group and the family
|
|
26
|
+
// window loses the tie, so it never counts as active.
|
|
27
|
+
const groupKey = JSON.stringify([window.planType, window.limitId, window.windowMinutes]);
|
|
23
28
|
const current = latestByPlanAndWindow.get(groupKey);
|
|
24
29
|
if (!current || startMs > current.startMs) {
|
|
25
30
|
latestByPlanAndWindow.set(groupKey, { window, startMs });
|
|
@@ -237,6 +242,18 @@ export function resolveMeasuredUsedPercent(window) {
|
|
|
237
242
|
}
|
|
238
243
|
return clampUsedPercent(window.maxUsedPercent - window.minUsedPercent);
|
|
239
244
|
}
|
|
245
|
+
/**
|
|
246
|
+
* True when a window's token totals cover only part of the window, so anything
|
|
247
|
+
* extrapolated from them (e.g. the limit's full value) is approximate.
|
|
248
|
+
*/
|
|
249
|
+
export function isPartialUsagePairing(window) {
|
|
250
|
+
if (!window.pairedUsageStartUtcIso) {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
const pairedStartMs = Date.parse(window.pairedUsageStartUtcIso);
|
|
254
|
+
const startMs = Date.parse(window.startTimeUtcIso);
|
|
255
|
+
return Number.isFinite(pairedStartMs) && Number.isFinite(startMs) && pairedStartMs > startMs;
|
|
256
|
+
}
|
|
240
257
|
function clampUsedPercent(value) {
|
|
241
258
|
if (!Number.isFinite(value)) {
|
|
242
259
|
return 0;
|
|
@@ -44,7 +44,17 @@ export async function fetchModelPricing(modelIds, source) {
|
|
|
44
44
|
output: model.output,
|
|
45
45
|
inputCacheRead: model.input_cache_read,
|
|
46
46
|
inputCacheWrite5m: model.input_cache_w5m,
|
|
47
|
-
inputCacheWrite1h: model.input_cache_w1h
|
|
47
|
+
inputCacheWrite1h: model.input_cache_w1h,
|
|
48
|
+
longContext: model.long_context
|
|
49
|
+
? {
|
|
50
|
+
inputTokensThreshold: model.long_context.input_tokens_threshold,
|
|
51
|
+
input: model.long_context.input,
|
|
52
|
+
output: model.long_context.output,
|
|
53
|
+
inputCacheRead: model.long_context.input_cache_read,
|
|
54
|
+
inputCacheWrite5m: model.long_context.input_cache_w5m,
|
|
55
|
+
inputCacheWrite1h: model.long_context.input_cache_w1h
|
|
56
|
+
}
|
|
57
|
+
: null
|
|
48
58
|
}
|
|
49
59
|
]));
|
|
50
60
|
return new Map([...slugByModelId.entries()].flatMap(([modelId, slug]) => {
|
|
@@ -56,17 +66,24 @@ export function modelCostCredits(pricing, usage) {
|
|
|
56
66
|
if (!pricing) {
|
|
57
67
|
return undefined;
|
|
58
68
|
}
|
|
59
|
-
|
|
69
|
+
const totalInputTokens = usage.inputTokens +
|
|
70
|
+
usage.cacheReadInputTokens +
|
|
71
|
+
usage.cacheWrite5mInputTokens +
|
|
72
|
+
usage.cacheWrite1hInputTokens;
|
|
73
|
+
const rates = pricing.longContext && totalInputTokens > pricing.longContext.inputTokensThreshold
|
|
74
|
+
? pricing.longContext
|
|
75
|
+
: pricing;
|
|
76
|
+
if (usage.cacheWrite5mInputTokens > 0 && rates.inputCacheWrite5m === null) {
|
|
60
77
|
return undefined;
|
|
61
78
|
}
|
|
62
|
-
if (usage.cacheWrite1hInputTokens > 0 &&
|
|
79
|
+
if (usage.cacheWrite1hInputTokens > 0 && rates.inputCacheWrite1h === null) {
|
|
63
80
|
return undefined;
|
|
64
81
|
}
|
|
65
|
-
return ((usage.inputTokens / 1000000) *
|
|
66
|
-
(usage.cacheReadInputTokens / 1000000) *
|
|
67
|
-
(usage.cacheWrite5mInputTokens / 1000000) * (
|
|
68
|
-
(usage.cacheWrite1hInputTokens / 1000000) * (
|
|
69
|
-
(usage.outputTokens / 1000000) *
|
|
82
|
+
return ((usage.inputTokens / 1000000) * rates.input +
|
|
83
|
+
(usage.cacheReadInputTokens / 1000000) * rates.inputCacheRead +
|
|
84
|
+
(usage.cacheWrite5mInputTokens / 1000000) * (rates.inputCacheWrite5m ?? 0) +
|
|
85
|
+
(usage.cacheWrite1hInputTokens / 1000000) * (rates.inputCacheWrite1h ?? 0) +
|
|
86
|
+
(usage.outputTokens / 1000000) * rates.output) * USD_TO_CREDITS;
|
|
70
87
|
}
|
|
71
88
|
async function fetchCached(request) {
|
|
72
89
|
const key = JSON.stringify(request);
|
|
@@ -2,12 +2,17 @@ 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
|
+
import { isPartialUsagePairing, resolveMeasuredUsedPercent } from "./providers/limits.js";
|
|
6
6
|
const REPORTING_ENDPOINT = "https://devforth.io/admin/api/report_ussage_anonymous";
|
|
7
7
|
const CREDIT_TO_DOLLARS = 0.01;
|
|
8
8
|
// Limit windows at or below this used-percent carry too little signal to be
|
|
9
9
|
// worth reporting, so they are dropped from the anonymous usage payload.
|
|
10
10
|
const SKIP_REPORT_USED_PERCENTS = 3;
|
|
11
|
+
// Name of the catch-all limit class: the window meters every model the plan
|
|
12
|
+
// offers, rather than one carved-out family.
|
|
13
|
+
const ALL_LIMIT_CLASS = "All";
|
|
14
|
+
// Mirrors the reporting endpoint's schema, which caps `limit_class` at 64.
|
|
15
|
+
const LIMIT_CLASS_MAX_LENGTH = 64;
|
|
11
16
|
let versionCache = null;
|
|
12
17
|
export async function reportAnonymousUsage(statsList) {
|
|
13
18
|
const payload = await buildAnonymousUsagePayload(statsList);
|
|
@@ -36,6 +41,7 @@ function buildAnonymousUsageReport(stats, window, letmecodeVersion) {
|
|
|
36
41
|
return {
|
|
37
42
|
agent: stats.analytics?.agentName ?? stats.providerLabel.replace(/\s+/g, ""),
|
|
38
43
|
model_type: resolveReportModelType(stats, window),
|
|
44
|
+
limit_class: resolveReportLimitClass(stats, window),
|
|
39
45
|
userid_hash: stats.analytics?.userIdHash ?? "",
|
|
40
46
|
plan_id: window.planType,
|
|
41
47
|
window_duration_seconds: window.windowMinutes * 60,
|
|
@@ -48,6 +54,52 @@ function buildAnonymousUsageReport(stats, window, letmecodeVersion) {
|
|
|
48
54
|
letmecode_version: letmecodeVersion
|
|
49
55
|
};
|
|
50
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Canonical, display-ready name of the limit class a window belongs to.
|
|
59
|
+
* Every provider words the same idea differently - Antigravity separates Gemini
|
|
60
|
+
* from third-party models, Claude carves a model family out of the weekly
|
|
61
|
+
* window - so letmecode folds them onto one vocabulary and consumers never have
|
|
62
|
+
* to learn the provider-specific spellings in `model_type`.
|
|
63
|
+
*
|
|
64
|
+
* The vocabulary is open: a class nobody has seen before is reported under its
|
|
65
|
+
* own name rather than forced into an existing bucket.
|
|
66
|
+
*/
|
|
67
|
+
function resolveReportLimitClass(stats, window) {
|
|
68
|
+
if (stats.providerId === "claude") {
|
|
69
|
+
const modelFamily = window.modelType?.toLowerCase().replace(/\s+only$/, "").trim();
|
|
70
|
+
return modelFamily ? toLimitClassName(modelFamily) : ALL_LIMIT_CLASS;
|
|
71
|
+
}
|
|
72
|
+
if (stats.providerId === "antigravity") {
|
|
73
|
+
const limitId = window.limitId.toLowerCase();
|
|
74
|
+
if (limitId.includes("gemini")) {
|
|
75
|
+
return "Main";
|
|
76
|
+
}
|
|
77
|
+
if (limitId.startsWith("3p") || limitId.includes("third-party")) {
|
|
78
|
+
return "Other";
|
|
79
|
+
}
|
|
80
|
+
return toLimitClassName(window.limitId);
|
|
81
|
+
}
|
|
82
|
+
if (stats.providerId === "copilot") {
|
|
83
|
+
// Copilot quota labels ("AI Credits", "Premium requests") are already the
|
|
84
|
+
// name of the bucket they meter.
|
|
85
|
+
return window.modelType ? toLimitClassName(window.modelType) : ALL_LIMIT_CLASS;
|
|
86
|
+
}
|
|
87
|
+
// Codex, and anything else, meters every model against one shared allowance.
|
|
88
|
+
return ALL_LIMIT_CLASS;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Title-case a provider string into a class name, leaving words that already
|
|
92
|
+
* carry capitals alone so acronyms such as "AI Credits" survive intact.
|
|
93
|
+
*/
|
|
94
|
+
function toLimitClassName(value) {
|
|
95
|
+
const name = value
|
|
96
|
+
.trim()
|
|
97
|
+
.split(/[\s_-]+/)
|
|
98
|
+
.filter(Boolean)
|
|
99
|
+
.map((word) => (/[A-Z]/.test(word) ? word : word.charAt(0).toUpperCase() + word.slice(1)))
|
|
100
|
+
.join(" ");
|
|
101
|
+
return name ? truncateSchemaString(name, LIMIT_CLASS_MAX_LENGTH) : ALL_LIMIT_CLASS;
|
|
102
|
+
}
|
|
51
103
|
function resolveReportModelType(stats, window) {
|
|
52
104
|
if (stats.providerId === "antigravity") {
|
|
53
105
|
return resolveAntigravityReportModelType(stats, window);
|
|
@@ -104,6 +156,9 @@ function resolveReportedUsedPercents(window) {
|
|
|
104
156
|
}
|
|
105
157
|
function shouldReportUsageWindow(window) {
|
|
106
158
|
return (window.totals.estimatedCreditsStatus !== "unavailable" &&
|
|
159
|
+
// A window whose percentage restarted mid-window pairs a full percentage
|
|
160
|
+
// with partial dollars, which would skew every aggregate built from it.
|
|
161
|
+
!isPartialUsagePairing(window) &&
|
|
107
162
|
resolveReportedUsedPercents(window) > SKIP_REPORT_USED_PERCENTS);
|
|
108
163
|
}
|
|
109
164
|
function clampPercent(value) {
|