@ipv9/tokentracker-cli 0.39.40 → 0.39.42

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.
Files changed (35) hide show
  1. package/README.md +83 -37
  2. package/dashboard/dist/assets/{Card-JS4gjUZf.js → Card-B8Y_I2mQ.js} +1 -1
  3. package/dashboard/dist/assets/DashboardPage-BDJHJCf-.js +60 -0
  4. package/dashboard/dist/assets/{FadeIn-DvLfvwys.js → FadeIn-ChNG3KP0.js} +1 -1
  5. package/dashboard/dist/assets/{IpCheckPage-Dcfc0Uts.js → IpCheckPage-Ca1uwFdQ.js} +1 -1
  6. package/dashboard/dist/assets/{LimitsPage-Vz-GVdi9.js → LimitsPage-CyDE5D02.js} +1 -1
  7. package/dashboard/dist/assets/{LocalOnlyNotice-8xF5couh.js → LocalOnlyNotice-yBRg7mqR.js} +1 -1
  8. package/dashboard/dist/assets/{PopoverPopup-7HrUzeRx.js → PopoverPopup-BGzAyIYN.js} +1 -1
  9. package/dashboard/dist/assets/{Select-9XDsaO_C.js → Select-DEntlQun.js} +1 -1
  10. package/dashboard/dist/assets/{SelectItemText-DE5Fs9p6.js → SelectItemText-Bn_0YJTr.js} +1 -1
  11. package/dashboard/dist/assets/{SettingsPage-CMnfAptE.js → SettingsPage-CGmzeeBt.js} +1 -1
  12. package/dashboard/dist/assets/{SkillsPage-CbvLHN0L.js → SkillsPage-vY8xnrco.js} +1 -1
  13. package/dashboard/dist/assets/{WidgetsPage-CeGQlyt7.js → WidgetsPage-BM16UPSy.js} +1 -1
  14. package/dashboard/dist/assets/{WrappedPage-Bux8qLUC.js → WrappedPage-dK7Mh5mP.js} +1 -1
  15. package/dashboard/dist/assets/{arrow-up-right-B7rCoZdW.js → arrow-up-right-UZisIdFP.js} +1 -1
  16. package/dashboard/dist/assets/{download-CtTYpbcx.js → download-C-J68Mev.js} +1 -1
  17. package/dashboard/dist/assets/{format-BuQKK6kQ.js → format-DIhJkiH4.js} +1 -1
  18. package/dashboard/dist/assets/limitDisplay-HQrH6Bc9.js +1 -0
  19. package/dashboard/dist/assets/{main-Ck4SUH4m.js → main-B7TdYIfs.js} +3 -3
  20. package/dashboard/dist/assets/mock-data-DjoQefEM.js +1 -0
  21. package/dashboard/dist/assets/{use-limits-display-prefs-BKWYPgeA.js → use-limits-display-prefs-g_zmpVGY.js} +1 -1
  22. package/dashboard/dist/assets/{use-native-settings-D7Nj1dXR.js → use-native-settings-DrNnC_U7.js} +1 -1
  23. package/dashboard/dist/assets/{useCurrency-BX3pvyhh.js → useCurrency-xmDhKr0q.js} +1 -1
  24. package/dashboard/dist/index.html +1 -1
  25. package/package.json +3 -2
  26. package/src/commands/serve.js +121 -38
  27. package/src/lib/fs.js +149 -25
  28. package/src/lib/local-api.js +20 -2
  29. package/src/lib/pricing/curated-overrides.json +10 -3
  30. package/src/lib/pricing/index.js +219 -18
  31. package/src/lib/pricing/litellm-fetcher.js +6 -1
  32. package/src/lib/pricing/seed-snapshot.json +1 -1
  33. package/dashboard/dist/assets/DashboardPage-BoXhzJb1.js +0 -60
  34. package/dashboard/dist/assets/limitDisplay-WwuTdS_a.js +0 -1
  35. package/dashboard/dist/assets/mock-data-Bvbe8tYK.js +0 -1
@@ -34,34 +34,110 @@ 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
+ // Placeholder ids that stand in for "this row has no model", so they resolve to
55
+ // the "unattributed" tier instead of being looked up and recorded as a miss.
56
+ // Closed set on purpose: a real model id must never be silently un-priced here.
57
+ const UNATTRIBUTED_MODEL_IDS = new Set(["unknown"]);
58
+
59
+ // `last_refresh_error` is served over HTTP to the dashboard, so it is built
60
+ // from CLOSED sets, never from an arbitrary value. A previous version accepted
61
+ // anything symbol-shaped, which a QA pass broke immediately: a 32-character
62
+ // token like `sk_live_AAAA…` is symbol-shaped. There is no pattern that
63
+ // separates "a short error symbol" from "a short secret" — only an allowlist.
64
+ const KNOWN_ERROR_CODES = new Set([
65
+ // fs
66
+ "ENOENT", "EACCES", "EPERM", "EEXIST", "ENOSPC", "EROFS", "EISDIR", "ENOTDIR", "EMFILE", "EBUSY",
67
+ // network
68
+ "ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "ENOTFOUND", "EAI_AGAIN", "EPIPE",
69
+ "EHOSTUNREACH", "ENETUNREACH", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT",
70
+ // error classes
71
+ "AbortError", "TypeError", "SyntaxError", "RangeError", "FetchError", "Error",
72
+ ]);
73
+
74
+ // Whatever loadLitellmData can report as the origin of the data it returned.
75
+ const KNOWN_SOURCES = new Set(["upstream", "disk-cache", "stale-cache", "seed-snapshot"]);
76
+
77
+ function labelFrom(allowed, candidates) {
78
+ for (const candidate of candidates) {
79
+ if (typeof candidate === "string" && allowed.has(candidate)) return candidate;
80
+ }
81
+ return "unknown";
82
+ }
83
+
37
84
  const state = {
38
85
  loaded: false,
39
86
  loadingPromise: null,
87
+ loadedAt: 0,
88
+ reloadPromise: null,
89
+ lastReloadAt: 0,
90
+ lastReloadError: null,
40
91
  litellmRawMap: seedRaw, // raw per-token; field shape from LiteLLM JSON
41
92
  litellmPerMillionMap: buildLitellmPerMillionMap(seedRaw), // USD/MTok
42
93
  source: Object.keys(seedRaw).length ? "seed-snapshot:sync" : null,
43
94
  // negativeCache prevents re-walking the LiteLLM map for models we've already
44
95
  // determined are unknown. Cleared on every reload.
45
96
  negativeCache: new Set(),
97
+ // model -> resolution tier, for the diagnostics surface. Cleared on reload.
98
+ tiers: new Map(),
99
+ // Models already warned about, so a hot path logs once, not once per row.
100
+ warned: new Set(),
101
+ reloadOptions: {},
46
102
  };
47
103
 
48
104
  function defaultCachePath() {
49
105
  return path.join(os.homedir(), ".tokentracker", "cache", "pricing.json");
50
106
  }
51
107
 
108
+ // `requireUpstream` guards the background path. loadLitellmData falls back on
109
+ // its own (upstream → stale disk cache → bundled seed), so a refresh that fails
110
+ // to reach upstream would otherwise REPLACE good in-memory data with the older
111
+ // seed — re-introducing exactly the "new model bills $0" bug this reload exists
112
+ // to fix. Verified: with the disk cache deleted and upstream down, a model
113
+ // priced at $5/$25 dropped to $0 after a failed refresh.
114
+ async function loadInto(opts, { requireUpstream = false } = {}) {
115
+ const cachePath = opts.cachePath || defaultCachePath();
116
+ const { data, source } = await loadLitellmData({ ...opts, cachePath });
117
+ if (requireUpstream && source !== "upstream") {
118
+ state.lastReloadError = `refresh-fell-back-to-${labelFrom(KNOWN_SOURCES, [source])}`;
119
+ return;
120
+ }
121
+ state.litellmRawMap = data || {};
122
+ state.litellmPerMillionMap = buildLitellmPerMillionMap(state.litellmRawMap);
123
+ state.source = source;
124
+ state.loaded = true;
125
+ state.loadedAt = Date.now();
126
+ state.negativeCache.clear();
127
+ state.tiers.clear();
128
+ }
129
+
52
130
  async function ensurePricingLoaded(opts = {}) {
53
131
  if (state.loaded) return state;
54
132
  if (state.loadingPromise) return state.loadingPromise;
55
133
 
134
+ // Remembered so a later background reload can reach the same cache path and
135
+ // fetch options without the caller having to plumb them through again.
136
+ state.reloadOptions = opts;
137
+
56
138
  state.loadingPromise = (async () => {
57
139
  try {
58
- const cachePath = opts.cachePath || defaultCachePath();
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();
140
+ await loadInto(opts);
65
141
  return state;
66
142
  } finally {
67
143
  state.loadingPromise = null;
@@ -71,37 +147,158 @@ async function ensurePricingLoaded(opts = {}) {
71
147
  return state.loadingPromise;
72
148
  }
73
149
 
150
+ // Fire-and-forget refresh. Single-flight, never awaited by a lookup: the caller
151
+ // keeps whatever price it already has for this request and the next request
152
+ // benefits. A failed reload leaves the existing snapshot in place.
153
+ //
154
+ // The cooldown matters because a model that is genuinely absent upstream (a
155
+ // local or unlisted model) misses on every row it appears in. Without it, each
156
+ // of those rows would queue another upstream fetch the moment the previous one
157
+ // finished.
158
+ function scheduleReload(nowMs = Date.now()) {
159
+ if (!state.loaded || state.reloadPromise) return state.reloadPromise;
160
+ if (nowMs - state.lastReloadAt < RELOAD_COOLDOWN_MS) return null;
161
+ state.lastReloadAt = nowMs;
162
+ state.reloadPromise = (async () => {
163
+ try {
164
+ // forceRefresh skips the disk cache; without it a reload would just
165
+ // re-read the same stale snapshot we already hold.
166
+ state.lastReloadError = null;
167
+ // forceRefresh skips the disk cache; without it a reload would just
168
+ // re-read the same stale snapshot we already hold.
169
+ await loadInto({ ...state.reloadOptions, forceRefresh: true }, { requireUpstream: true });
170
+ } catch (e) {
171
+ // Reached when loadLitellmData itself throws rather than falling back —
172
+ // statSafe rethrows a non-ENOENT stat error, so an unusable cache path
173
+ // lands here (QA probe: refresh-failed:TypeError). Only
174
+ // the error CODE is kept: messages from fs/fetch carry absolute paths and
175
+ // this string is served over HTTP to the dashboard.
176
+ state.lastReloadError = `refresh-failed:${labelFrom(KNOWN_ERROR_CODES, [e?.code, e?.name])}`;
177
+ } finally {
178
+ state.reloadPromise = null;
179
+ }
180
+ })();
181
+ return state.reloadPromise;
182
+ }
183
+
184
+ function isSnapshotStale(nowMs = Date.now()) {
185
+ return state.loaded && nowMs - state.loadedAt > RELOAD_AFTER_MS;
186
+ }
187
+
74
188
  // For tests: drop loaded state so a fresh call can re-load. Seeds with the
75
189
  // bundled snapshot so getModelPricing() still works without ensurePricingLoaded.
76
190
  function resetPricingForTests() {
77
191
  state.loaded = false;
78
192
  state.loadingPromise = null;
193
+ state.loadedAt = 0;
194
+ state.reloadPromise = null;
195
+ state.lastReloadAt = 0;
196
+ state.lastReloadError = null;
79
197
  state.litellmRawMap = seedRaw;
80
198
  state.litellmPerMillionMap = buildLitellmPerMillionMap(seedRaw);
81
199
  state.source = Object.keys(seedRaw).length ? "seed-snapshot:sync" : null;
82
200
  state.negativeCache.clear();
201
+ state.tiers.clear();
202
+ state.warned.clear();
203
+ state.reloadOptions = {};
83
204
  }
84
205
 
85
- function getModelPricing(model, opts = {}) {
86
- if (!model) return ZERO_PRICING;
87
- let lookupSource = null;
88
- if (typeof opts === "string") {
89
- lookupSource = opts.toLowerCase();
90
- } else if (typeof opts.source === "string") {
91
- lookupSource = opts.source.toLowerCase();
92
- }
206
+ function resolveLookupSource(opts) {
207
+ if (typeof opts === "string") return opts.toLowerCase();
208
+ if (opts && typeof opts.source === "string") return opts.source.toLowerCase();
209
+ return null;
210
+ }
211
+
212
+ // A row whose model id could not be determined is stored and aggregated under
213
+ // the literal id "unknown" — persisted into the queue by src/commands/sync.js
214
+ // and src/lib/claude-categorizer.js, then coalesced to it again at read time by
215
+ // src/lib/local-api.js. That is a placeholder for "no model", not a model, so
216
+ // pricing it turned a missing *attribution* into a missing *price*: it recorded
217
+ // a permanent miss, listed "unknown" in unpriced_models as though a real model
218
+ // needed a curated price, and logged advice to add it to curated-overrides.json
219
+ // where it could never match anything.
220
+ // Returns the tier to report, or null when this is a real model id to look up.
221
+ function resolvePlaceholderTier(model) {
222
+ if (!model) return "empty";
223
+ const normalized = String(model).trim().toLowerCase();
224
+ if (!normalized) return "empty";
225
+ return UNATTRIBUTED_MODEL_IDS.has(normalized) ? "unattributed" : null;
226
+ }
227
+
228
+ // Returns the price AND how it was resolved. getModelPricing keeps the old
229
+ // bare-numbers contract for the many existing callers; anything that wants to
230
+ // show the user how much to trust the number uses this.
231
+ function getModelPricingMeta(model, opts = {}) {
232
+ const placeholderTier = resolvePlaceholderTier(model);
233
+ if (placeholderTier) return { pricing: ZERO_PRICING, tier: placeholderTier };
234
+
235
+ const lookupSource = resolveLookupSource(opts);
93
236
  const cacheKey = lookupSource ? `${lookupSource}\0${model}` : model;
94
- if (state.negativeCache.has(cacheKey)) return ZERO_PRICING;
237
+
238
+ if (state.negativeCache.has(cacheKey)) {
239
+ // Still unknown as of the current snapshot. If that snapshot has aged out,
240
+ // a new model may have appeared upstream — refresh for the next caller.
241
+ if (isSnapshotStale()) scheduleReload();
242
+ return { pricing: ZERO_PRICING, tier: "miss" };
243
+ }
95
244
 
96
245
  const result = lookupPricing(model, {
97
246
  curated: curatedOverrides,
98
247
  litellm: state.litellmPerMillionMap,
99
248
  source: lookupSource,
100
249
  });
101
- if (result.hit) return result.value;
250
+
251
+ if (result.hit) {
252
+ state.tiers.set(cacheKey, { model, source: lookupSource, tier: result.source });
253
+ return { pricing: result.value, tier: result.source };
254
+ }
102
255
 
103
256
  state.negativeCache.add(cacheKey);
104
- return ZERO_PRICING;
257
+ state.tiers.set(cacheKey, { model, source: lookupSource, tier: "miss" });
258
+
259
+ // A miss is the strongest signal that our snapshot predates a model launch —
260
+ // exactly the claude-opus-5 case. Refresh in the background so the next
261
+ // request prices it, instead of waiting for a process restart.
262
+ scheduleReload();
263
+
264
+ if (!state.warned.has(cacheKey)) {
265
+ state.warned.add(cacheKey);
266
+ console.warn(
267
+ `[pricing] no price for model "${model}"${lookupSource ? ` (source: ${lookupSource})` : ""}`
268
+ + " — its cost is being counted as $0. Refreshing pricing data in the background;"
269
+ + " if it stays unpriced, add it to src/lib/pricing/curated-overrides.json.",
270
+ );
271
+ }
272
+
273
+ return { pricing: ZERO_PRICING, tier: "miss" };
274
+ }
275
+
276
+ function getModelPricing(model, opts = {}) {
277
+ return getModelPricingMeta(model, opts).pricing;
278
+ }
279
+
280
+ // Snapshot of what the pricing layer knows it got wrong or guessed at, for the
281
+ // API to hand to the dashboard.
282
+ function getPricingDiagnostics() {
283
+ // Keyed by source+model, matching the lookup itself: the same model id can
284
+ // resolve differently per provider (Antigravity normalises names before the
285
+ // lookup), so a model-only key would let one provider's exact hit hide
286
+ // another's miss.
287
+ const unpriced = new Set();
288
+ const fuzzy = [];
289
+ for (const entry of state.tiers.values()) {
290
+ if (entry.tier === "miss") unpriced.add(entry.model);
291
+ else if (FUZZY_SOURCES.has(entry.tier)) fuzzy.push({ model: entry.model, tier: entry.tier });
292
+ }
293
+ return {
294
+ source: state.source,
295
+ loaded_at: state.loadedAt ? new Date(state.loadedAt).toISOString() : null,
296
+ stale: isSnapshotStale(),
297
+ refreshing: Boolean(state.reloadPromise),
298
+ last_refresh_error: state.lastReloadError,
299
+ unpriced_models: Array.from(unpriced).sort(),
300
+ fuzzy_priced_models: fuzzy.sort((a, b) => a.model.localeCompare(b.model)),
301
+ };
105
302
  }
106
303
 
107
304
  // Same formula and Codex/every-code reasoning-folding rule as the previous
@@ -134,10 +331,14 @@ const MODEL_PRICING = curatedOverrides.exact;
134
331
  module.exports = {
135
332
  ensurePricingLoaded,
136
333
  getModelPricing,
334
+ getModelPricingMeta,
335
+ getPricingDiagnostics,
137
336
  computeRowCost,
138
337
  resetPricingForTests,
139
338
  MODEL_PRICING,
140
339
  ZERO_PRICING,
141
340
  // Internal hooks for tests.
142
341
  __getStateForTests: () => state,
342
+ __labelFromForTests: labelFrom,
343
+ __KNOWN_ERROR_CODES: KNOWN_ERROR_CODES,
143
344
  };
@@ -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;