@ipv9/tokentracker-cli 0.39.40 → 0.39.41
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 +5 -5
- package/dashboard/dist/assets/{Card-JS4gjUZf.js → Card-BwY_Qv6N.js} +1 -1
- package/dashboard/dist/assets/DashboardPage-C7b_GjA-.js +60 -0
- package/dashboard/dist/assets/{FadeIn-DvLfvwys.js → FadeIn-DiAafUDN.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-Dcfc0Uts.js → IpCheckPage-CYgWVZJr.js} +1 -1
- package/dashboard/dist/assets/{LimitsPage-Vz-GVdi9.js → LimitsPage-C1y75ftN.js} +1 -1
- package/dashboard/dist/assets/{LocalOnlyNotice-8xF5couh.js → LocalOnlyNotice-B5_-ydeu.js} +1 -1
- package/dashboard/dist/assets/{PopoverPopup-7HrUzeRx.js → PopoverPopup-DDjjQ5CG.js} +1 -1
- package/dashboard/dist/assets/{Select-9XDsaO_C.js → Select-BPspkl9p.js} +1 -1
- package/dashboard/dist/assets/{SelectItemText-DE5Fs9p6.js → SelectItemText-CX0dWYB1.js} +1 -1
- package/dashboard/dist/assets/{SettingsPage-CMnfAptE.js → SettingsPage-9K5ST4D_.js} +1 -1
- package/dashboard/dist/assets/{SkillsPage-CbvLHN0L.js → SkillsPage-BpCSS7ls.js} +1 -1
- package/dashboard/dist/assets/{WidgetsPage-CeGQlyt7.js → WidgetsPage-aBisLWrM.js} +1 -1
- package/dashboard/dist/assets/{WrappedPage-Bux8qLUC.js → WrappedPage-BgkuBG_j.js} +1 -1
- package/dashboard/dist/assets/{arrow-up-right-B7rCoZdW.js → arrow-up-right-DPb2FRSP.js} +1 -1
- package/dashboard/dist/assets/{download-CtTYpbcx.js → download-CbZ8YL8m.js} +1 -1
- package/dashboard/dist/assets/{format-BuQKK6kQ.js → format-aRaCvnht.js} +1 -1
- package/dashboard/dist/assets/limitDisplay-1O9-AdgI.js +1 -0
- package/dashboard/dist/assets/{main-Ck4SUH4m.js → main-DRcFJLLD.js} +3 -3
- package/dashboard/dist/assets/mock-data-sZ3-GZV0.js +1 -0
- package/dashboard/dist/assets/{use-limits-display-prefs-BKWYPgeA.js → use-limits-display-prefs-2-qYvffB.js} +1 -1
- package/dashboard/dist/assets/{use-native-settings-D7Nj1dXR.js → use-native-settings-C1uhDKdL.js} +1 -1
- package/dashboard/dist/assets/{useCurrency-BX3pvyhh.js → useCurrency-BPCF4zUv.js} +1 -1
- package/dashboard/dist/index.html +1 -1
- package/package.json +3 -2
- package/src/commands/serve.js +121 -38
- package/src/lib/fs.js +149 -25
- package/src/lib/local-api.js +20 -2
- package/src/lib/pricing/curated-overrides.json +10 -3
- package/src/lib/pricing/index.js +197 -18
- package/src/lib/pricing/litellm-fetcher.js +6 -1
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/dashboard/dist/assets/DashboardPage-BoXhzJb1.js +0 -60
- package/dashboard/dist/assets/limitDisplay-WwuTdS_a.js +0 -1
- package/dashboard/dist/assets/mock-data-Bvbe8tYK.js +0 -1
package/src/lib/pricing/index.js
CHANGED
|
@@ -34,34 +34,105 @@ function loadSeedSync() {
|
|
|
34
34
|
|
|
35
35
|
const seedRaw = loadSeedSync();
|
|
36
36
|
|
|
37
|
+
// How long a loaded snapshot is trusted before a lookup is allowed to trigger a
|
|
38
|
+
// background refresh. Mirrors the fetcher's disk-cache TTL: before this change
|
|
39
|
+
// that TTL only chose which snapshot to load *at startup*, so a dashboard that
|
|
40
|
+
// stayed up (the LaunchAgent stays up for days) never saw a new model or a
|
|
41
|
+
// price change — claude-opus-5 billed $0 for 21 hours that way. Issue #90.
|
|
42
|
+
const RELOAD_AFTER_MS = 24 * 60 * 60 * 1000;
|
|
43
|
+
|
|
44
|
+
// Floor between background refreshes, so a permanently-unknown model cannot
|
|
45
|
+
// turn every request into an upstream fetch.
|
|
46
|
+
const RELOAD_COOLDOWN_MS = 5 * 60 * 1000;
|
|
47
|
+
|
|
48
|
+
// Resolution tiers that mean "we guessed": the model matched a substring or a
|
|
49
|
+
// curated fuzzy rule rather than an exact id, so the price is plausible but may
|
|
50
|
+
// belong to a different model. Worth surfacing — a wrong price never looks
|
|
51
|
+
// wrong, unlike a $0 one.
|
|
52
|
+
const FUZZY_SOURCES = new Set(["curated:fuzzy", "litellm:fuzzy", "litellm:prefix-strip"]);
|
|
53
|
+
|
|
54
|
+
// `last_refresh_error` is served over HTTP to the dashboard, so it is built
|
|
55
|
+
// from CLOSED sets, never from an arbitrary value. A previous version accepted
|
|
56
|
+
// anything symbol-shaped, which a QA pass broke immediately: a 32-character
|
|
57
|
+
// token like `sk_live_AAAA…` is symbol-shaped. There is no pattern that
|
|
58
|
+
// separates "a short error symbol" from "a short secret" — only an allowlist.
|
|
59
|
+
const KNOWN_ERROR_CODES = new Set([
|
|
60
|
+
// fs
|
|
61
|
+
"ENOENT", "EACCES", "EPERM", "EEXIST", "ENOSPC", "EROFS", "EISDIR", "ENOTDIR", "EMFILE", "EBUSY",
|
|
62
|
+
// network
|
|
63
|
+
"ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "ENOTFOUND", "EAI_AGAIN", "EPIPE",
|
|
64
|
+
"EHOSTUNREACH", "ENETUNREACH", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT",
|
|
65
|
+
// error classes
|
|
66
|
+
"AbortError", "TypeError", "SyntaxError", "RangeError", "FetchError", "Error",
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
// Whatever loadLitellmData can report as the origin of the data it returned.
|
|
70
|
+
const KNOWN_SOURCES = new Set(["upstream", "disk-cache", "stale-cache", "seed-snapshot"]);
|
|
71
|
+
|
|
72
|
+
function labelFrom(allowed, candidates) {
|
|
73
|
+
for (const candidate of candidates) {
|
|
74
|
+
if (typeof candidate === "string" && allowed.has(candidate)) return candidate;
|
|
75
|
+
}
|
|
76
|
+
return "unknown";
|
|
77
|
+
}
|
|
78
|
+
|
|
37
79
|
const state = {
|
|
38
80
|
loaded: false,
|
|
39
81
|
loadingPromise: null,
|
|
82
|
+
loadedAt: 0,
|
|
83
|
+
reloadPromise: null,
|
|
84
|
+
lastReloadAt: 0,
|
|
85
|
+
lastReloadError: null,
|
|
40
86
|
litellmRawMap: seedRaw, // raw per-token; field shape from LiteLLM JSON
|
|
41
87
|
litellmPerMillionMap: buildLitellmPerMillionMap(seedRaw), // USD/MTok
|
|
42
88
|
source: Object.keys(seedRaw).length ? "seed-snapshot:sync" : null,
|
|
43
89
|
// negativeCache prevents re-walking the LiteLLM map for models we've already
|
|
44
90
|
// determined are unknown. Cleared on every reload.
|
|
45
91
|
negativeCache: new Set(),
|
|
92
|
+
// model -> resolution tier, for the diagnostics surface. Cleared on reload.
|
|
93
|
+
tiers: new Map(),
|
|
94
|
+
// Models already warned about, so a hot path logs once, not once per row.
|
|
95
|
+
warned: new Set(),
|
|
96
|
+
reloadOptions: {},
|
|
46
97
|
};
|
|
47
98
|
|
|
48
99
|
function defaultCachePath() {
|
|
49
100
|
return path.join(os.homedir(), ".tokentracker", "cache", "pricing.json");
|
|
50
101
|
}
|
|
51
102
|
|
|
103
|
+
// `requireUpstream` guards the background path. loadLitellmData falls back on
|
|
104
|
+
// its own (upstream → stale disk cache → bundled seed), so a refresh that fails
|
|
105
|
+
// to reach upstream would otherwise REPLACE good in-memory data with the older
|
|
106
|
+
// seed — re-introducing exactly the "new model bills $0" bug this reload exists
|
|
107
|
+
// to fix. Verified: with the disk cache deleted and upstream down, a model
|
|
108
|
+
// priced at $5/$25 dropped to $0 after a failed refresh.
|
|
109
|
+
async function loadInto(opts, { requireUpstream = false } = {}) {
|
|
110
|
+
const cachePath = opts.cachePath || defaultCachePath();
|
|
111
|
+
const { data, source } = await loadLitellmData({ ...opts, cachePath });
|
|
112
|
+
if (requireUpstream && source !== "upstream") {
|
|
113
|
+
state.lastReloadError = `refresh-fell-back-to-${labelFrom(KNOWN_SOURCES, [source])}`;
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
state.litellmRawMap = data || {};
|
|
117
|
+
state.litellmPerMillionMap = buildLitellmPerMillionMap(state.litellmRawMap);
|
|
118
|
+
state.source = source;
|
|
119
|
+
state.loaded = true;
|
|
120
|
+
state.loadedAt = Date.now();
|
|
121
|
+
state.negativeCache.clear();
|
|
122
|
+
state.tiers.clear();
|
|
123
|
+
}
|
|
124
|
+
|
|
52
125
|
async function ensurePricingLoaded(opts = {}) {
|
|
53
126
|
if (state.loaded) return state;
|
|
54
127
|
if (state.loadingPromise) return state.loadingPromise;
|
|
55
128
|
|
|
129
|
+
// Remembered so a later background reload can reach the same cache path and
|
|
130
|
+
// fetch options without the caller having to plumb them through again.
|
|
131
|
+
state.reloadOptions = opts;
|
|
132
|
+
|
|
56
133
|
state.loadingPromise = (async () => {
|
|
57
134
|
try {
|
|
58
|
-
|
|
59
|
-
const { data, source } = await loadLitellmData({ ...opts, cachePath });
|
|
60
|
-
state.litellmRawMap = data || {};
|
|
61
|
-
state.litellmPerMillionMap = buildLitellmPerMillionMap(state.litellmRawMap);
|
|
62
|
-
state.source = source;
|
|
63
|
-
state.loaded = true;
|
|
64
|
-
state.negativeCache.clear();
|
|
135
|
+
await loadInto(opts);
|
|
65
136
|
return state;
|
|
66
137
|
} finally {
|
|
67
138
|
state.loadingPromise = null;
|
|
@@ -71,37 +142,141 @@ async function ensurePricingLoaded(opts = {}) {
|
|
|
71
142
|
return state.loadingPromise;
|
|
72
143
|
}
|
|
73
144
|
|
|
145
|
+
// Fire-and-forget refresh. Single-flight, never awaited by a lookup: the caller
|
|
146
|
+
// keeps whatever price it already has for this request and the next request
|
|
147
|
+
// benefits. A failed reload leaves the existing snapshot in place.
|
|
148
|
+
//
|
|
149
|
+
// The cooldown matters because a model that is genuinely absent upstream (a
|
|
150
|
+
// local or unlisted model) misses on every row it appears in. Without it, each
|
|
151
|
+
// of those rows would queue another upstream fetch the moment the previous one
|
|
152
|
+
// finished.
|
|
153
|
+
function scheduleReload(nowMs = Date.now()) {
|
|
154
|
+
if (!state.loaded || state.reloadPromise) return state.reloadPromise;
|
|
155
|
+
if (nowMs - state.lastReloadAt < RELOAD_COOLDOWN_MS) return null;
|
|
156
|
+
state.lastReloadAt = nowMs;
|
|
157
|
+
state.reloadPromise = (async () => {
|
|
158
|
+
try {
|
|
159
|
+
// forceRefresh skips the disk cache; without it a reload would just
|
|
160
|
+
// re-read the same stale snapshot we already hold.
|
|
161
|
+
state.lastReloadError = null;
|
|
162
|
+
// forceRefresh skips the disk cache; without it a reload would just
|
|
163
|
+
// re-read the same stale snapshot we already hold.
|
|
164
|
+
await loadInto({ ...state.reloadOptions, forceRefresh: true }, { requireUpstream: true });
|
|
165
|
+
} catch (e) {
|
|
166
|
+
// Reached when loadLitellmData itself throws rather than falling back —
|
|
167
|
+
// statSafe rethrows a non-ENOENT stat error, so an unusable cache path
|
|
168
|
+
// lands here (QA probe: refresh-failed:TypeError). Only
|
|
169
|
+
// the error CODE is kept: messages from fs/fetch carry absolute paths and
|
|
170
|
+
// this string is served over HTTP to the dashboard.
|
|
171
|
+
state.lastReloadError = `refresh-failed:${labelFrom(KNOWN_ERROR_CODES, [e?.code, e?.name])}`;
|
|
172
|
+
} finally {
|
|
173
|
+
state.reloadPromise = null;
|
|
174
|
+
}
|
|
175
|
+
})();
|
|
176
|
+
return state.reloadPromise;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function isSnapshotStale(nowMs = Date.now()) {
|
|
180
|
+
return state.loaded && nowMs - state.loadedAt > RELOAD_AFTER_MS;
|
|
181
|
+
}
|
|
182
|
+
|
|
74
183
|
// For tests: drop loaded state so a fresh call can re-load. Seeds with the
|
|
75
184
|
// bundled snapshot so getModelPricing() still works without ensurePricingLoaded.
|
|
76
185
|
function resetPricingForTests() {
|
|
77
186
|
state.loaded = false;
|
|
78
187
|
state.loadingPromise = null;
|
|
188
|
+
state.loadedAt = 0;
|
|
189
|
+
state.reloadPromise = null;
|
|
190
|
+
state.lastReloadAt = 0;
|
|
191
|
+
state.lastReloadError = null;
|
|
79
192
|
state.litellmRawMap = seedRaw;
|
|
80
193
|
state.litellmPerMillionMap = buildLitellmPerMillionMap(seedRaw);
|
|
81
194
|
state.source = Object.keys(seedRaw).length ? "seed-snapshot:sync" : null;
|
|
82
195
|
state.negativeCache.clear();
|
|
196
|
+
state.tiers.clear();
|
|
197
|
+
state.warned.clear();
|
|
198
|
+
state.reloadOptions = {};
|
|
83
199
|
}
|
|
84
200
|
|
|
85
|
-
function
|
|
86
|
-
if (
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
201
|
+
function resolveLookupSource(opts) {
|
|
202
|
+
if (typeof opts === "string") return opts.toLowerCase();
|
|
203
|
+
if (opts && typeof opts.source === "string") return opts.source.toLowerCase();
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Returns the price AND how it was resolved. getModelPricing keeps the old
|
|
208
|
+
// bare-numbers contract for the many existing callers; anything that wants to
|
|
209
|
+
// show the user how much to trust the number uses this.
|
|
210
|
+
function getModelPricingMeta(model, opts = {}) {
|
|
211
|
+
if (!model) return { pricing: ZERO_PRICING, tier: "empty" };
|
|
212
|
+
|
|
213
|
+
const lookupSource = resolveLookupSource(opts);
|
|
93
214
|
const cacheKey = lookupSource ? `${lookupSource}\0${model}` : model;
|
|
94
|
-
|
|
215
|
+
|
|
216
|
+
if (state.negativeCache.has(cacheKey)) {
|
|
217
|
+
// Still unknown as of the current snapshot. If that snapshot has aged out,
|
|
218
|
+
// a new model may have appeared upstream — refresh for the next caller.
|
|
219
|
+
if (isSnapshotStale()) scheduleReload();
|
|
220
|
+
return { pricing: ZERO_PRICING, tier: "miss" };
|
|
221
|
+
}
|
|
95
222
|
|
|
96
223
|
const result = lookupPricing(model, {
|
|
97
224
|
curated: curatedOverrides,
|
|
98
225
|
litellm: state.litellmPerMillionMap,
|
|
99
226
|
source: lookupSource,
|
|
100
227
|
});
|
|
101
|
-
|
|
228
|
+
|
|
229
|
+
if (result.hit) {
|
|
230
|
+
state.tiers.set(cacheKey, { model, source: lookupSource, tier: result.source });
|
|
231
|
+
return { pricing: result.value, tier: result.source };
|
|
232
|
+
}
|
|
102
233
|
|
|
103
234
|
state.negativeCache.add(cacheKey);
|
|
104
|
-
|
|
235
|
+
state.tiers.set(cacheKey, { model, source: lookupSource, tier: "miss" });
|
|
236
|
+
|
|
237
|
+
// A miss is the strongest signal that our snapshot predates a model launch —
|
|
238
|
+
// exactly the claude-opus-5 case. Refresh in the background so the next
|
|
239
|
+
// request prices it, instead of waiting for a process restart.
|
|
240
|
+
scheduleReload();
|
|
241
|
+
|
|
242
|
+
if (!state.warned.has(cacheKey)) {
|
|
243
|
+
state.warned.add(cacheKey);
|
|
244
|
+
console.warn(
|
|
245
|
+
`[pricing] no price for model "${model}"${lookupSource ? ` (source: ${lookupSource})` : ""}`
|
|
246
|
+
+ " — its cost is being counted as $0. Refreshing pricing data in the background;"
|
|
247
|
+
+ " if it stays unpriced, add it to src/lib/pricing/curated-overrides.json.",
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return { pricing: ZERO_PRICING, tier: "miss" };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function getModelPricing(model, opts = {}) {
|
|
255
|
+
return getModelPricingMeta(model, opts).pricing;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Snapshot of what the pricing layer knows it got wrong or guessed at, for the
|
|
259
|
+
// API to hand to the dashboard.
|
|
260
|
+
function getPricingDiagnostics() {
|
|
261
|
+
// Keyed by source+model, matching the lookup itself: the same model id can
|
|
262
|
+
// resolve differently per provider (Antigravity normalises names before the
|
|
263
|
+
// lookup), so a model-only key would let one provider's exact hit hide
|
|
264
|
+
// another's miss.
|
|
265
|
+
const unpriced = new Set();
|
|
266
|
+
const fuzzy = [];
|
|
267
|
+
for (const entry of state.tiers.values()) {
|
|
268
|
+
if (entry.tier === "miss") unpriced.add(entry.model);
|
|
269
|
+
else if (FUZZY_SOURCES.has(entry.tier)) fuzzy.push({ model: entry.model, tier: entry.tier });
|
|
270
|
+
}
|
|
271
|
+
return {
|
|
272
|
+
source: state.source,
|
|
273
|
+
loaded_at: state.loadedAt ? new Date(state.loadedAt).toISOString() : null,
|
|
274
|
+
stale: isSnapshotStale(),
|
|
275
|
+
refreshing: Boolean(state.reloadPromise),
|
|
276
|
+
last_refresh_error: state.lastReloadError,
|
|
277
|
+
unpriced_models: Array.from(unpriced).sort(),
|
|
278
|
+
fuzzy_priced_models: fuzzy.sort((a, b) => a.model.localeCompare(b.model)),
|
|
279
|
+
};
|
|
105
280
|
}
|
|
106
281
|
|
|
107
282
|
// Same formula and Codex/every-code reasoning-folding rule as the previous
|
|
@@ -134,10 +309,14 @@ const MODEL_PRICING = curatedOverrides.exact;
|
|
|
134
309
|
module.exports = {
|
|
135
310
|
ensurePricingLoaded,
|
|
136
311
|
getModelPricing,
|
|
312
|
+
getModelPricingMeta,
|
|
313
|
+
getPricingDiagnostics,
|
|
137
314
|
computeRowCost,
|
|
138
315
|
resetPricingForTests,
|
|
139
316
|
MODEL_PRICING,
|
|
140
317
|
ZERO_PRICING,
|
|
141
318
|
// Internal hooks for tests.
|
|
142
319
|
__getStateForTests: () => state,
|
|
320
|
+
__labelFromForTests: labelFrom,
|
|
321
|
+
__KNOWN_ERROR_CODES: KNOWN_ERROR_CODES,
|
|
143
322
|
};
|
|
@@ -112,6 +112,11 @@ async function writeCache(cachePath, data) {
|
|
|
112
112
|
async function loadLitellmData({
|
|
113
113
|
cachePath,
|
|
114
114
|
ttlMs = DEFAULT_TTL_MS,
|
|
115
|
+
// Skip the disk cache and go upstream. Needed because `ttlMs: 0` does NOT
|
|
116
|
+
// reliably force a refetch: mtime carries sub-millisecond precision while
|
|
117
|
+
// Date.now() does not, so a cache file written moments ago can compare as
|
|
118
|
+
// "written in the future" and still count as fresh.
|
|
119
|
+
forceRefresh = false,
|
|
115
120
|
fetchTimeoutMs = DEFAULT_FETCH_TIMEOUT_MS,
|
|
116
121
|
fetchImpl = fetchUpstream,
|
|
117
122
|
url = LITELLM_PRICING_URL,
|
|
@@ -126,7 +131,7 @@ async function loadLitellmData({
|
|
|
126
131
|
|
|
127
132
|
// 1. Fresh disk cache
|
|
128
133
|
const stat = await statSafe(cachePath);
|
|
129
|
-
if (isFresh(stat, ttlMs)) {
|
|
134
|
+
if (!forceRefresh && isFresh(stat, ttlMs)) {
|
|
130
135
|
try {
|
|
131
136
|
const data = await readJsonAsync(cachePath);
|
|
132
137
|
delete data._meta;
|