tokentracker-cli 0.6.0 → 0.6.1
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/package.json +1 -1
- package/src/lib/codex-token-refresh.js +112 -0
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/rollout.js +59 -16
- package/src/lib/subscriptions.js +41 -0
- package/src/lib/usage-limits.js +119 -14
package/src/lib/rollout.js
CHANGED
|
@@ -2952,10 +2952,13 @@ function resolveHermesDbPath() {
|
|
|
2952
2952
|
return path.join(home, ".hermes", "state.db");
|
|
2953
2953
|
}
|
|
2954
2954
|
|
|
2955
|
-
function readHermesSessions(dbPath,
|
|
2955
|
+
function readHermesSessions(dbPath, lastCompletedEpoch) {
|
|
2956
2956
|
if (!dbPath || !fssync.existsSync(dbPath)) return [];
|
|
2957
|
-
const since = Number.isFinite(
|
|
2958
|
-
|
|
2957
|
+
const since = Number.isFinite(lastCompletedEpoch) && lastCompletedEpoch > 0 ? lastCompletedEpoch : 0;
|
|
2958
|
+
// Fetch sessions that started after the cursor, OR sessions that are still
|
|
2959
|
+
// in-progress (ended_at IS NULL). Hermes updates token counts in real-time,
|
|
2960
|
+
// so an active session keeps growing and must be re-read on every sync.
|
|
2961
|
+
const sql = `SELECT id, model, started_at, ended_at, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens, message_count FROM sessions WHERE (started_at > ${since} OR ended_at IS NULL) AND (input_tokens > 0 OR output_tokens > 0 OR cache_read_tokens > 0 OR reasoning_tokens > 0) ORDER BY started_at ASC`;
|
|
2959
2962
|
let raw;
|
|
2960
2963
|
try {
|
|
2961
2964
|
raw = cp.execFileSync("sqlite3", ["-json", dbPath, sql], {
|
|
@@ -2979,17 +2982,25 @@ function readHermesSessions(dbPath, sinceEpoch) {
|
|
|
2979
2982
|
async function parseHermesIncremental({ dbPath, cursors, queuePath, onProgress }) {
|
|
2980
2983
|
await ensureDir(path.dirname(queuePath));
|
|
2981
2984
|
const hermesState = cursors.hermes && typeof cursors.hermes === "object" ? cursors.hermes : {};
|
|
2982
|
-
|
|
2983
|
-
|
|
2985
|
+
|
|
2986
|
+
// Only advance past sessions that have fully ended. Active sessions
|
|
2987
|
+
// (ended_at IS NULL) must be re-read every sync because Hermes updates
|
|
2988
|
+
// their token counts in real-time after each turn.
|
|
2989
|
+
const lastCompletedStartedAt =
|
|
2990
|
+
typeof hermesState.lastCompletedStartedAt === "number" ? hermesState.lastCompletedStartedAt : 0;
|
|
2991
|
+
|
|
2992
|
+
// Per-session snapshot from the previous sync: { [sessionId]: { in, out, cacheRead, cacheWrite, reasoning } }
|
|
2993
|
+
const prevSnapshots = (hermesState.snapshots && typeof hermesState.snapshots === "object")
|
|
2994
|
+
? hermesState.snapshots : {};
|
|
2984
2995
|
|
|
2985
2996
|
const resolvedDbPath = dbPath || resolveHermesDbPath();
|
|
2986
2997
|
if (!fssync.existsSync(resolvedDbPath)) {
|
|
2987
2998
|
return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
|
|
2988
2999
|
}
|
|
2989
3000
|
|
|
2990
|
-
const rows = readHermesSessions(resolvedDbPath,
|
|
3001
|
+
const rows = readHermesSessions(resolvedDbPath, lastCompletedStartedAt);
|
|
2991
3002
|
if (rows.length === 0) {
|
|
2992
|
-
cursors.hermes = { ...hermesState,
|
|
3003
|
+
cursors.hermes = { ...hermesState, lastCompletedStartedAt, updatedAt: new Date().toISOString() };
|
|
2993
3004
|
return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
|
|
2994
3005
|
}
|
|
2995
3006
|
|
|
@@ -2997,7 +3008,8 @@ async function parseHermesIncremental({ dbPath, cursors, queuePath, onProgress }
|
|
|
2997
3008
|
const touchedBuckets = new Set();
|
|
2998
3009
|
const cb = typeof onProgress === "function" ? onProgress : null;
|
|
2999
3010
|
let eventsAggregated = 0;
|
|
3000
|
-
let
|
|
3011
|
+
let maxCompletedStartedAt = lastCompletedStartedAt;
|
|
3012
|
+
const nextSnapshots = {};
|
|
3001
3013
|
|
|
3002
3014
|
for (let i = 0; i < rows.length; i++) {
|
|
3003
3015
|
const row = rows[i];
|
|
@@ -3008,6 +3020,28 @@ async function parseHermesIncremental({ dbPath, cursors, queuePath, onProgress }
|
|
|
3008
3020
|
const reasoning = toNonNegativeInt(row.reasoning_tokens);
|
|
3009
3021
|
if (inputTokens === 0 && outputTokens === 0 && cacheRead === 0 && reasoning === 0) continue;
|
|
3010
3022
|
|
|
3023
|
+
// Save current snapshot for next sync
|
|
3024
|
+
nextSnapshots[row.id] = { in: inputTokens, out: outputTokens, cacheRead, cacheWrite, reasoning };
|
|
3025
|
+
|
|
3026
|
+
// Compute delta from previous snapshot (if any) so that we only count
|
|
3027
|
+
// new tokens since the last sync. First time we see a session the
|
|
3028
|
+
// previous snapshot is absent, so the full amount is the delta.
|
|
3029
|
+
const prev = prevSnapshots[row.id];
|
|
3030
|
+
let dInput = inputTokens;
|
|
3031
|
+
let dOutput = outputTokens;
|
|
3032
|
+
let dCacheRead = cacheRead;
|
|
3033
|
+
let dCacheWrite = cacheWrite;
|
|
3034
|
+
let dReasoning = reasoning;
|
|
3035
|
+
if (prev) {
|
|
3036
|
+
dInput = Math.max(0, inputTokens - (prev.in || 0));
|
|
3037
|
+
dOutput = Math.max(0, outputTokens - (prev.out || 0));
|
|
3038
|
+
dCacheRead = Math.max(0, cacheRead - (prev.cacheRead || 0));
|
|
3039
|
+
dCacheWrite = Math.max(0, cacheWrite - (prev.cacheWrite || 0));
|
|
3040
|
+
dReasoning = Math.max(0, reasoning - (prev.reasoning || 0));
|
|
3041
|
+
}
|
|
3042
|
+
// Skip if delta is zero (session unchanged since last sync)
|
|
3043
|
+
if (dInput === 0 && dOutput === 0 && dCacheRead === 0 && dCacheWrite === 0 && dReasoning === 0) continue;
|
|
3044
|
+
|
|
3011
3045
|
// Prefer ended_at for bucket placement; fall back to started_at
|
|
3012
3046
|
const epochSec = row.ended_at || row.started_at;
|
|
3013
3047
|
if (!epochSec || !Number.isFinite(epochSec)) continue;
|
|
@@ -3018,12 +3052,12 @@ async function parseHermesIncremental({ dbPath, cursors, queuePath, onProgress }
|
|
|
3018
3052
|
const model = normalizeModelInput(row.model) || "hermes-agent";
|
|
3019
3053
|
|
|
3020
3054
|
const delta = {
|
|
3021
|
-
input_tokens:
|
|
3022
|
-
cached_input_tokens:
|
|
3023
|
-
cache_creation_input_tokens:
|
|
3024
|
-
output_tokens:
|
|
3025
|
-
reasoning_output_tokens:
|
|
3026
|
-
total_tokens:
|
|
3055
|
+
input_tokens: dInput,
|
|
3056
|
+
cached_input_tokens: dCacheRead,
|
|
3057
|
+
cache_creation_input_tokens: dCacheWrite,
|
|
3058
|
+
output_tokens: dOutput,
|
|
3059
|
+
reasoning_output_tokens: dReasoning,
|
|
3060
|
+
total_tokens: dInput + dOutput + dCacheRead + dCacheWrite + dReasoning,
|
|
3027
3061
|
conversation_count: toNonNegativeInt(row.message_count) || 1,
|
|
3028
3062
|
};
|
|
3029
3063
|
|
|
@@ -3032,7 +3066,10 @@ async function parseHermesIncremental({ dbPath, cursors, queuePath, onProgress }
|
|
|
3032
3066
|
touchedBuckets.add(bucketKey("hermes", model, bucketStart));
|
|
3033
3067
|
eventsAggregated++;
|
|
3034
3068
|
|
|
3035
|
-
|
|
3069
|
+
// Only advance cursor past sessions that have ended
|
|
3070
|
+
if (row.ended_at && row.started_at > maxCompletedStartedAt) {
|
|
3071
|
+
maxCompletedStartedAt = row.started_at;
|
|
3072
|
+
}
|
|
3036
3073
|
|
|
3037
3074
|
if (cb) {
|
|
3038
3075
|
cb({
|
|
@@ -3049,7 +3086,13 @@ async function parseHermesIncremental({ dbPath, cursors, queuePath, onProgress }
|
|
|
3049
3086
|
const updatedAt = new Date().toISOString();
|
|
3050
3087
|
hourlyState.updatedAt = updatedAt;
|
|
3051
3088
|
cursors.hourly = hourlyState;
|
|
3052
|
-
cursors.hermes = {
|
|
3089
|
+
cursors.hermes = {
|
|
3090
|
+
...hermesState,
|
|
3091
|
+
lastStartedAt: maxCompletedStartedAt, // keep for backward compat
|
|
3092
|
+
lastCompletedStartedAt: maxCompletedStartedAt,
|
|
3093
|
+
snapshots: nextSnapshots,
|
|
3094
|
+
updatedAt,
|
|
3095
|
+
};
|
|
3053
3096
|
|
|
3054
3097
|
return { recordsProcessed: rows.length, eventsAggregated, bucketsQueued };
|
|
3055
3098
|
}
|
package/src/lib/subscriptions.js
CHANGED
|
@@ -341,8 +341,49 @@ async function readCodexAccessToken({ home, env } = {}) {
|
|
|
341
341
|
}
|
|
342
342
|
}
|
|
343
343
|
|
|
344
|
+
// Returns the access token + ChatGPT account id + plan type + auth.json path so callers can
|
|
345
|
+
// also trigger a refresh if the tokens are stale (issue #52: stale tokens → wham 401 →
|
|
346
|
+
// "Fetch failed" error after ~7-8 days of not running `codex`).
|
|
347
|
+
async function readCodexAuthBundle({ home, env } = {}) {
|
|
348
|
+
try {
|
|
349
|
+
const codexHome = resolveCodexHome({ home, env });
|
|
350
|
+
const authPath = path.join(codexHome, "auth.json");
|
|
351
|
+
const auth = await readJson(authPath);
|
|
352
|
+
if (!auth || typeof auth !== "object") return null;
|
|
353
|
+
|
|
354
|
+
const accessToken = normalizeString(auth?.tokens?.access_token);
|
|
355
|
+
if (!accessToken) return null;
|
|
356
|
+
|
|
357
|
+
const accessPayload = decodeJwtPayload(auth?.tokens?.access_token);
|
|
358
|
+
const idPayload = decodeJwtPayload(auth?.tokens?.id_token);
|
|
359
|
+
const accountId =
|
|
360
|
+
normalizeString(auth?.tokens?.account_id) ||
|
|
361
|
+
normalizeString(extractOpenAiAuthNamespace(accessPayload)?.chatgpt_account_id) ||
|
|
362
|
+
normalizeString(extractOpenAiAuthNamespace(idPayload)?.chatgpt_account_id) ||
|
|
363
|
+
null;
|
|
364
|
+
|
|
365
|
+
const accessInfo = extractChatgptSubscriptionFromPayload(accessPayload);
|
|
366
|
+
const idInfo = extractChatgptSubscriptionFromPayload(idPayload);
|
|
367
|
+
const merged = mergeSubscription(accessInfo, idInfo);
|
|
368
|
+
const planType = merged?.planType ? merged.planType.toLowerCase() : null;
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
accessToken,
|
|
372
|
+
accountId,
|
|
373
|
+
planType,
|
|
374
|
+
refreshToken: normalizeString(auth?.tokens?.refresh_token) || null,
|
|
375
|
+
lastRefresh: normalizeString(auth?.last_refresh) || null,
|
|
376
|
+
authPath,
|
|
377
|
+
authJson: auth,
|
|
378
|
+
};
|
|
379
|
+
} catch (_e) {
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
344
384
|
module.exports = {
|
|
345
385
|
collectLocalSubscriptions,
|
|
346
386
|
readClaudeCodeAccessToken,
|
|
347
387
|
readCodexAccessToken,
|
|
388
|
+
readCodexAuthBundle,
|
|
348
389
|
};
|
package/src/lib/usage-limits.js
CHANGED
|
@@ -5,7 +5,16 @@ const path = require("node:path");
|
|
|
5
5
|
const http = require("node:http");
|
|
6
6
|
const https = require("node:https");
|
|
7
7
|
|
|
8
|
-
const {
|
|
8
|
+
const {
|
|
9
|
+
readClaudeCodeAccessToken,
|
|
10
|
+
readCodexAccessToken,
|
|
11
|
+
readCodexAuthBundle,
|
|
12
|
+
} = require("./subscriptions");
|
|
13
|
+
const {
|
|
14
|
+
isTokenStale,
|
|
15
|
+
refreshCodexTokens,
|
|
16
|
+
persistRefreshedAuth,
|
|
17
|
+
} = require("./codex-token-refresh");
|
|
9
18
|
const {
|
|
10
19
|
isCursorInstalled,
|
|
11
20
|
extractCursorSessionToken,
|
|
@@ -122,23 +131,72 @@ async function fetchClaudeUsageLimits(accessToken, { fetchImpl = fetch, maxAttem
|
|
|
122
131
|
}
|
|
123
132
|
}
|
|
124
133
|
|
|
125
|
-
|
|
134
|
+
// Classify a wham window by `limit_window_seconds` rather than its slot name.
|
|
135
|
+
// 18000s = 5h session window. 604800s = 7d weekly window. Free-tier accounts only get a
|
|
136
|
+
// weekly window, often delivered in the `primary_window` slot — naive position-based
|
|
137
|
+
// reading mislabels it as "5h". Aligned with steipete/CodexBar's rate-window normalizer.
|
|
138
|
+
const CODEX_SESSION_WINDOW_SECONDS = 18000;
|
|
139
|
+
const CODEX_WEEKLY_WINDOW_SECONDS = 604800;
|
|
140
|
+
|
|
141
|
+
function classifyCodexWindow(window) {
|
|
142
|
+
if (!window || typeof window !== "object") return null;
|
|
143
|
+
const seconds = Number(window.limit_window_seconds);
|
|
144
|
+
if (!Number.isFinite(seconds)) return null;
|
|
145
|
+
if (seconds === CODEX_SESSION_WINDOW_SECONDS) return "session";
|
|
146
|
+
if (seconds === CODEX_WEEKLY_WINDOW_SECONDS) return "weekly";
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function normalizeCodexRateWindows(rateLimit) {
|
|
151
|
+
const candidates = [rateLimit?.primary_window, rateLimit?.secondary_window].filter(
|
|
152
|
+
(w) => w && typeof w === "object",
|
|
153
|
+
);
|
|
154
|
+
let session = null;
|
|
155
|
+
let weekly = null;
|
|
156
|
+
for (const w of candidates) {
|
|
157
|
+
const kind = classifyCodexWindow(w);
|
|
158
|
+
if (kind === "session" && !session) session = w;
|
|
159
|
+
else if (kind === "weekly" && !weekly) weekly = w;
|
|
160
|
+
}
|
|
161
|
+
// Fall back to positional read only if classification failed for both — preserves data
|
|
162
|
+
// from unexpected window durations rather than dropping it silently.
|
|
163
|
+
if (!session && !weekly && candidates.length > 0) {
|
|
164
|
+
return {
|
|
165
|
+
primary_window: candidates[0] ?? null,
|
|
166
|
+
secondary_window: candidates[1] ?? null,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
return { primary_window: session, secondary_window: weekly };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function fetchCodexUsageLimits(
|
|
173
|
+
accessToken,
|
|
174
|
+
{ fetchImpl = fetch, accountId = null } = {},
|
|
175
|
+
) {
|
|
176
|
+
const headers = {
|
|
177
|
+
Authorization: `Bearer ${accessToken}`,
|
|
178
|
+
Accept: "application/json",
|
|
179
|
+
};
|
|
180
|
+
// The wham endpoint rejects some plan tiers without an explicit account id — match
|
|
181
|
+
// CodexBar's request shape so free / multi-account users don't see opaque 4xx.
|
|
182
|
+
if (accountId) {
|
|
183
|
+
headers["ChatGPT-Account-Id"] = accountId;
|
|
184
|
+
}
|
|
185
|
+
|
|
126
186
|
const res = await fetchImpl("https://chatgpt.com/backend-api/wham/usage", {
|
|
127
187
|
method: "GET",
|
|
128
|
-
headers
|
|
129
|
-
Authorization: `Bearer ${accessToken}`,
|
|
130
|
-
Accept: "application/json",
|
|
131
|
-
},
|
|
188
|
+
headers,
|
|
132
189
|
});
|
|
190
|
+
// 401/403/404 from wham means "no usage data available for this auth state" — render
|
|
191
|
+
// a neutral empty state instead of a red "Fetch failed" error.
|
|
192
|
+
if (res.status === 401 || res.status === 403 || res.status === 404) {
|
|
193
|
+
return { primary_window: null, secondary_window: null };
|
|
194
|
+
}
|
|
133
195
|
if (!res.ok) {
|
|
134
196
|
throw new Error(`Codex API returned ${res.status}`);
|
|
135
197
|
}
|
|
136
198
|
const body = await res.json();
|
|
137
|
-
|
|
138
|
-
return {
|
|
139
|
-
primary_window: rateLimit.primary_window ?? null,
|
|
140
|
-
secondary_window: rateLimit.secondary_window ?? null,
|
|
141
|
-
};
|
|
199
|
+
return normalizeCodexRateWindows(body.rate_limit || {});
|
|
142
200
|
}
|
|
143
201
|
|
|
144
202
|
function cursorPercentFromCentsUsedLimit(usedRaw, limitRaw) {
|
|
@@ -1471,11 +1529,45 @@ async function getUsageLimits({
|
|
|
1471
1529
|
return cache.data;
|
|
1472
1530
|
}
|
|
1473
1531
|
|
|
1474
|
-
const [claudeToken,
|
|
1532
|
+
const [claudeToken, codexAuth] = await Promise.all([
|
|
1475
1533
|
Promise.resolve().then(() => readClaudeCodeAccessToken({ platform, securityRunner })),
|
|
1476
|
-
|
|
1534
|
+
readCodexAuthBundle({ home, env }),
|
|
1477
1535
|
]);
|
|
1478
1536
|
|
|
1537
|
+
// Proactively refresh Codex tokens that are >8 days stale, mirroring CodexBar's
|
|
1538
|
+
// CodexTokenRefresher.swift. Without this, users who logged in once and didn't run
|
|
1539
|
+
// `codex` for >a week get wham 401 → "Fetch failed" (issue #52). Best-effort: any
|
|
1540
|
+
// refresh failure falls through to using the existing (possibly stale) token, then the
|
|
1541
|
+
// 4xx graceful path in fetchCodexUsageLimits surfaces a neutral state instead of red.
|
|
1542
|
+
let refreshError = null;
|
|
1543
|
+
let codexAuthRefreshed = codexAuth;
|
|
1544
|
+
if (codexAuth && isTokenStale(codexAuth.lastRefresh) && codexAuth.refreshToken) {
|
|
1545
|
+
try {
|
|
1546
|
+
const newTokens = await refreshCodexTokens({
|
|
1547
|
+
refreshToken: codexAuth.refreshToken,
|
|
1548
|
+
fetchImpl,
|
|
1549
|
+
});
|
|
1550
|
+
const updatedAuth = await persistRefreshedAuth(
|
|
1551
|
+
codexAuth.authPath,
|
|
1552
|
+
codexAuth.authJson,
|
|
1553
|
+
newTokens,
|
|
1554
|
+
);
|
|
1555
|
+
codexAuthRefreshed = {
|
|
1556
|
+
...codexAuth,
|
|
1557
|
+
accessToken: newTokens.access_token,
|
|
1558
|
+
refreshToken: newTokens.refresh_token,
|
|
1559
|
+
lastRefresh: updatedAuth.last_refresh,
|
|
1560
|
+
authJson: updatedAuth,
|
|
1561
|
+
};
|
|
1562
|
+
} catch (err) {
|
|
1563
|
+
refreshError = err;
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
const codexToken = codexAuthRefreshed?.accessToken || null;
|
|
1568
|
+
const codexAccountId = codexAuthRefreshed?.accountId || null;
|
|
1569
|
+
const codexPlanType = codexAuthRefreshed?.planType || null;
|
|
1570
|
+
|
|
1479
1571
|
const providerFetch = withFetchTimeout(fetchImpl, providerTimeoutMs);
|
|
1480
1572
|
const [claudeResult, codexResult, cursor, kimi, gemini, kiro, antigravity, copilot] = await Promise.all([
|
|
1481
1573
|
claudeToken
|
|
@@ -1485,7 +1577,11 @@ async function getUsageLimits({
|
|
|
1485
1577
|
)
|
|
1486
1578
|
: Promise.resolve(null),
|
|
1487
1579
|
codexToken
|
|
1488
|
-
? withProviderTimeout(
|
|
1580
|
+
? withProviderTimeout(
|
|
1581
|
+
fetchCodexUsageLimits(codexToken, { fetchImpl: providerFetch, accountId: codexAccountId }),
|
|
1582
|
+
"Codex",
|
|
1583
|
+
providerTimeoutMs,
|
|
1584
|
+
).then(
|
|
1489
1585
|
(value) => ({ status: "fulfilled", value }),
|
|
1490
1586
|
(reason) => ({ status: "rejected", reason }),
|
|
1491
1587
|
)
|
|
@@ -1521,12 +1617,21 @@ async function getUsageLimits({
|
|
|
1521
1617
|
let codex;
|
|
1522
1618
|
if (!codexToken) {
|
|
1523
1619
|
codex = { configured: false };
|
|
1620
|
+
} else if (refreshError && refreshError.code === "REFRESH_TOKEN_EXPIRED") {
|
|
1621
|
+
// Refresh token is dead — the user must re-run `codex` to log in again. Surface a
|
|
1622
|
+
// specific, actionable message rather than the generic "Fetch failed".
|
|
1623
|
+
codex = {
|
|
1624
|
+
configured: true,
|
|
1625
|
+
error: refreshError.message,
|
|
1626
|
+
auth_action_required: "reauth",
|
|
1627
|
+
};
|
|
1524
1628
|
} else if (!codexResult || codexResult.status === "rejected") {
|
|
1525
1629
|
codex = { configured: true, error: codexResult?.reason?.message || "Unknown error" };
|
|
1526
1630
|
} else {
|
|
1527
1631
|
codex = {
|
|
1528
1632
|
configured: true,
|
|
1529
1633
|
error: null,
|
|
1634
|
+
plan_type: codexPlanType || null,
|
|
1530
1635
|
primary_window: codexResult.value.primary_window,
|
|
1531
1636
|
secondary_window: codexResult.value.secondary_window,
|
|
1532
1637
|
};
|