@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.
Files changed (35) hide show
  1. package/README.md +5 -5
  2. package/dashboard/dist/assets/{Card-JS4gjUZf.js → Card-BwY_Qv6N.js} +1 -1
  3. package/dashboard/dist/assets/DashboardPage-C7b_GjA-.js +60 -0
  4. package/dashboard/dist/assets/{FadeIn-DvLfvwys.js → FadeIn-DiAafUDN.js} +1 -1
  5. package/dashboard/dist/assets/{IpCheckPage-Dcfc0Uts.js → IpCheckPage-CYgWVZJr.js} +1 -1
  6. package/dashboard/dist/assets/{LimitsPage-Vz-GVdi9.js → LimitsPage-C1y75ftN.js} +1 -1
  7. package/dashboard/dist/assets/{LocalOnlyNotice-8xF5couh.js → LocalOnlyNotice-B5_-ydeu.js} +1 -1
  8. package/dashboard/dist/assets/{PopoverPopup-7HrUzeRx.js → PopoverPopup-DDjjQ5CG.js} +1 -1
  9. package/dashboard/dist/assets/{Select-9XDsaO_C.js → Select-BPspkl9p.js} +1 -1
  10. package/dashboard/dist/assets/{SelectItemText-DE5Fs9p6.js → SelectItemText-CX0dWYB1.js} +1 -1
  11. package/dashboard/dist/assets/{SettingsPage-CMnfAptE.js → SettingsPage-9K5ST4D_.js} +1 -1
  12. package/dashboard/dist/assets/{SkillsPage-CbvLHN0L.js → SkillsPage-BpCSS7ls.js} +1 -1
  13. package/dashboard/dist/assets/{WidgetsPage-CeGQlyt7.js → WidgetsPage-aBisLWrM.js} +1 -1
  14. package/dashboard/dist/assets/{WrappedPage-Bux8qLUC.js → WrappedPage-BgkuBG_j.js} +1 -1
  15. package/dashboard/dist/assets/{arrow-up-right-B7rCoZdW.js → arrow-up-right-DPb2FRSP.js} +1 -1
  16. package/dashboard/dist/assets/{download-CtTYpbcx.js → download-CbZ8YL8m.js} +1 -1
  17. package/dashboard/dist/assets/{format-BuQKK6kQ.js → format-aRaCvnht.js} +1 -1
  18. package/dashboard/dist/assets/limitDisplay-1O9-AdgI.js +1 -0
  19. package/dashboard/dist/assets/{main-Ck4SUH4m.js → main-DRcFJLLD.js} +3 -3
  20. package/dashboard/dist/assets/mock-data-sZ3-GZV0.js +1 -0
  21. package/dashboard/dist/assets/{use-limits-display-prefs-BKWYPgeA.js → use-limits-display-prefs-2-qYvffB.js} +1 -1
  22. package/dashboard/dist/assets/{use-native-settings-D7Nj1dXR.js → use-native-settings-C1uhDKdL.js} +1 -1
  23. package/dashboard/dist/assets/{useCurrency-BX3pvyhh.js → useCurrency-BPCF4zUv.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 +197 -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,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
- 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();
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 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
- }
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
- if (state.negativeCache.has(cacheKey)) return ZERO_PRICING;
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
- if (result.hit) return result.value;
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
- return ZERO_PRICING;
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;