@ipv9/tokentracker-cli 0.39.39 → 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 (38) hide show
  1. package/README.md +6 -6
  2. package/dashboard/dist/assets/{Card-BbRYRVXf.js → Card-BwY_Qv6N.js} +1 -1
  3. package/dashboard/dist/assets/DashboardPage-C7b_GjA-.js +60 -0
  4. package/dashboard/dist/assets/{FadeIn-CjAacJ5t.js → FadeIn-DiAafUDN.js} +1 -1
  5. package/dashboard/dist/assets/{IpCheckPage-BGALK8Vy.js → IpCheckPage-CYgWVZJr.js} +1 -1
  6. package/dashboard/dist/assets/LimitsPage-C1y75ftN.js +2 -0
  7. package/dashboard/dist/assets/{LocalOnlyNotice-Dxo2vFMQ.js → LocalOnlyNotice-B5_-ydeu.js} +1 -1
  8. package/dashboard/dist/assets/{PopoverPopup-DP9fwdtl.js → PopoverPopup-DDjjQ5CG.js} +1 -1
  9. package/dashboard/dist/assets/{Select-DPE53NmM.js → Select-BPspkl9p.js} +1 -1
  10. package/dashboard/dist/assets/{SelectItemText-D69v77is.js → SelectItemText-CX0dWYB1.js} +1 -1
  11. package/dashboard/dist/assets/{SettingsPage-Dxzqpi02.js → SettingsPage-9K5ST4D_.js} +1 -1
  12. package/dashboard/dist/assets/{SkillsPage-Bo7e39AB.js → SkillsPage-BpCSS7ls.js} +1 -1
  13. package/dashboard/dist/assets/{WidgetsPage-HHcIr_Eq.js → WidgetsPage-aBisLWrM.js} +1 -1
  14. package/dashboard/dist/assets/{WrappedPage-C-_jLXXU.js → WrappedPage-BgkuBG_j.js} +1 -1
  15. package/dashboard/dist/assets/{arrow-up-right-BJvJHUGj.js → arrow-up-right-DPb2FRSP.js} +1 -1
  16. package/dashboard/dist/assets/{download-CnzY_EgN.js → download-CbZ8YL8m.js} +1 -1
  17. package/dashboard/dist/assets/{format-1w-q5n2f.js → format-aRaCvnht.js} +1 -1
  18. package/dashboard/dist/assets/limitDisplay-1O9-AdgI.js +1 -0
  19. package/dashboard/dist/assets/{main-3gBEI6Jl.js → main-DRcFJLLD.js} +5 -3
  20. package/dashboard/dist/assets/main-ZrWkoMlr.css +1 -0
  21. package/dashboard/dist/assets/mock-data-sZ3-GZV0.js +1 -0
  22. package/dashboard/dist/assets/{use-limits-display-prefs-D1nT9-mr.js → use-limits-display-prefs-2-qYvffB.js} +1 -1
  23. package/dashboard/dist/assets/{use-native-settings-Dh6NzqjY.js → use-native-settings-C1uhDKdL.js} +1 -1
  24. package/dashboard/dist/assets/{useCurrency-Hhs5jbWw.js → useCurrency-BPCF4zUv.js} +1 -1
  25. package/dashboard/dist/index.html +2 -2
  26. package/package.json +3 -2
  27. package/src/commands/serve.js +121 -38
  28. package/src/lib/fs.js +149 -25
  29. package/src/lib/local-api.js +20 -2
  30. package/src/lib/pricing/curated-overrides.json +10 -3
  31. package/src/lib/pricing/index.js +197 -18
  32. package/src/lib/pricing/litellm-fetcher.js +6 -1
  33. package/src/lib/pricing/seed-snapshot.json +1 -1
  34. package/dashboard/dist/assets/DashboardPage-DYKHv0m3.js +0 -60
  35. package/dashboard/dist/assets/LimitsPage-LkZGPYvg.js +0 -2
  36. package/dashboard/dist/assets/main-1kxAVM3m.css +0 -1
  37. package/dashboard/dist/assets/mock-data-CJOMGcL9.js +0 -1
  38. package/dashboard/dist/assets/use-usage-limits-BXR6lzGf.js +0 -1
package/src/lib/fs.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const fs = require("node:fs/promises");
2
+ const os = require("node:os");
2
3
  const path = require("node:path");
3
4
 
4
5
  async function ensureDir(p) {
@@ -47,37 +48,157 @@ async function chmod600IfPossible(filePath) {
47
48
  } catch (_e) {}
48
49
  }
49
50
 
50
- const LOCK_STALE_MS = 5 * 60 * 1000; // 5 minutes
51
+ // The holder heartbeats the lock's mtime, so "stale" now means "the holder
52
+ // died", not "the holder is slow". That lets the threshold be generous: the old
53
+ // 5-minute window silently stole the lock from any sync that ran longer than
54
+ // one local-sync tick (full-corpus rebuilds and migration reparses do — see the
55
+ // post-mortem at src/commands/sync.js:74-123), letting two writers interleave
56
+ // appends into queue.jsonl. A torn line is skipped by the reader, and a skipped
57
+ // retraction row is a permanent silent overcount. Issue #89.
58
+ const LOCK_STALE_MS = 30 * 60 * 1000; // 30 minutes
59
+ const LOCK_HEARTBEAT_MS = 30 * 1000; // touch mtime every 30s while held
60
+ const MAX_LOCK_ATTEMPTS = 3;
51
61
 
52
- async function openLock(lockPath, { quietIfLocked }) {
62
+ // mkdir is atomic and exclusive, which makes it a usable mutex on every
63
+ // filesystem we care about. Held only for the few syscalls of a takeover.
64
+ const TAKEOVER_ABANDONED_MS = 60 * 1000;
65
+
66
+ function takeoverMutexPath(lockPath) {
67
+ return `${lockPath}.takeover`;
68
+ }
69
+
70
+ async function acquireTakeoverMutex(lockPath) {
71
+ const mutexPath = takeoverMutexPath(lockPath);
53
72
  try {
54
- const handle = await fs.open(lockPath, "wx");
55
- return {
56
- async release() {
57
- await handle.close().catch(() => {});
58
- await fs.unlink(lockPath).catch(() => {});
59
- },
60
- };
73
+ await fs.mkdir(mutexPath);
74
+ return true;
61
75
  } catch (e) {
62
- if (e && e.code === "EEXIST") {
63
- // Check if lock is stale
64
- try {
65
- const stat = await fs.stat(lockPath);
66
- if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
67
- await fs.unlink(lockPath).catch(() => {});
68
- return openLock(lockPath, { quietIfLocked });
69
- }
70
- } catch (_statErr) {
71
- // Lock file disappeared between checks, retry
72
- return openLock(lockPath, { quietIfLocked });
73
- }
74
- if (!quietIfLocked) {
75
- process.stdout.write("Another sync is already running.\n");
76
- }
77
- return null;
76
+ if (!e || e.code !== "EEXIST") return false;
77
+ }
78
+ // A process that died mid-takeover would otherwise block every future
79
+ // takeover forever. The window is milliseconds, so anything this old is dead.
80
+ try {
81
+ const stat = await fs.stat(mutexPath);
82
+ if (Date.now() - stat.mtimeMs > TAKEOVER_ABANDONED_MS) {
83
+ await fs.rmdir(mutexPath).catch(() => {});
78
84
  }
85
+ } catch (_e) {
86
+ // Vanished under us — the caller retries either way.
87
+ }
88
+ return false;
89
+ }
90
+
91
+ async function readLockOwner(lockPath) {
92
+ try {
93
+ const raw = await fs.readFile(lockPath, "utf8");
94
+ const parsed = JSON.parse(raw);
95
+ if (parsed && typeof parsed === "object") return parsed;
96
+ } catch (_e) {
97
+ // Empty, truncated, or pre-upgrade lock file — fall back to the mtime rule.
98
+ }
99
+ return null;
100
+ }
101
+
102
+ function isProcessAlive(pid) {
103
+ try {
104
+ process.kill(pid, 0);
105
+ return true;
106
+ } catch (e) {
107
+ // EPERM means the process exists but belongs to someone else.
108
+ return Boolean(e && e.code === "EPERM");
109
+ }
110
+ }
111
+
112
+ // A lock whose owner process is gone is stale immediately — no need to wait out
113
+ // the full window. Only trust the pid when the lock was taken on this host, and
114
+ // never when it is our own pid (a recycled pid would look alive forever).
115
+ function isOwnerGone(owner) {
116
+ if (!owner || typeof owner.pid !== "number" || !Number.isInteger(owner.pid)) return false;
117
+ if (owner.host !== os.hostname()) return false;
118
+ if (owner.pid === process.pid) return false;
119
+ return !isProcessAlive(owner.pid);
120
+ }
121
+
122
+ // Throws EEXIST when the lock is already held — that is the caller's signal to
123
+ // decide whether the existing lock is stale.
124
+ async function createHeldLock(lockPath, heartbeatMs) {
125
+ const handle = await fs.open(lockPath, "wx");
126
+ const owner = { pid: process.pid, host: os.hostname(), startedAt: new Date().toISOString() };
127
+
128
+ try {
129
+ await handle.writeFile(`${JSON.stringify(owner, null, 2)}\n`, { encoding: "utf8" });
130
+ } catch (e) {
131
+ // A write failure would otherwise leave a leaked fd plus an empty lock file
132
+ // that nothing releases — blocking every sync until the stale window elapses.
133
+ await handle.close().catch(() => {});
134
+ await fs.unlink(lockPath).catch(() => {});
79
135
  throw e;
80
136
  }
137
+
138
+ // unref'd so a held lock can never keep the CLI process alive on its own.
139
+ const heartbeat = setInterval(() => {
140
+ const now = new Date();
141
+ fs.utimes(lockPath, now, now).catch(() => {});
142
+ }, heartbeatMs);
143
+ if (typeof heartbeat.unref === "function") heartbeat.unref();
144
+
145
+ return {
146
+ owner,
147
+ async release() {
148
+ clearInterval(heartbeat);
149
+ await handle.close().catch(() => {});
150
+ await fs.unlink(lockPath).catch(() => {});
151
+ },
152
+ };
153
+ }
154
+
155
+ // Removing a stale lock is a check-then-act, and the caller's check ran outside
156
+ // any mutual exclusion: by now another waiter may already have reclaimed it and
157
+ // be holding a *fresh* lock. Deleting (or renaming) the path blind would drop
158
+ // that winner's lock and let two syncs run at once. The mkdir mutex makes the
159
+ // re-check and the delete atomic with respect to other waiters.
160
+ async function reclaimStaleLock(lockPath) {
161
+ if (!(await acquireTakeoverMutex(lockPath))) return;
162
+ try {
163
+ const current = await fs.stat(lockPath).catch(() => null);
164
+ if (!current) return;
165
+ const currentOwner = await readLockOwner(lockPath);
166
+ const stillStale = Date.now() - current.mtimeMs > LOCK_STALE_MS || isOwnerGone(currentOwner);
167
+ if (stillStale) await fs.unlink(lockPath).catch(() => {});
168
+ } finally {
169
+ await fs.rmdir(takeoverMutexPath(lockPath)).catch(() => {});
170
+ }
171
+ }
172
+
173
+ async function openLock(lockPath, { quietIfLocked, heartbeatMs = LOCK_HEARTBEAT_MS } = {}, attempt = 1) {
174
+ try {
175
+ return await createHeldLock(lockPath, heartbeatMs);
176
+ } catch (e) {
177
+ if (!e || e.code !== "EEXIST") throw e;
178
+
179
+ const retry = () => openLock(lockPath, { quietIfLocked, heartbeatMs }, attempt + 1);
180
+ const giveUp = () => {
181
+ if (!quietIfLocked) process.stdout.write("Another sync is already running.\n");
182
+ return null;
183
+ };
184
+
185
+ if (attempt >= MAX_LOCK_ATTEMPTS) return giveUp();
186
+
187
+ let stat;
188
+ try {
189
+ stat = await fs.stat(lockPath);
190
+ } catch (_statErr) {
191
+ return retry(); // Lock file disappeared between checks.
192
+ }
193
+
194
+ const owner = await readLockOwner(lockPath);
195
+ const expired = Date.now() - stat.mtimeMs > LOCK_STALE_MS;
196
+ // A live holder heartbeats its lock, so a fresh mtime means "still working".
197
+ if (!expired && !isOwnerGone(owner)) return giveUp();
198
+
199
+ await reclaimStaleLock(lockPath);
200
+ return retry();
201
+ }
81
202
  }
82
203
 
83
204
  module.exports = {
@@ -88,4 +209,7 @@ module.exports = {
88
209
  writeJson,
89
210
  chmod600IfPossible,
90
211
  openLock,
212
+ // Exported for tests.
213
+ LOCK_STALE_MS,
214
+ LOCK_HEARTBEAT_MS,
91
215
  };
@@ -30,6 +30,8 @@ const avatarProxyCache = new Map();
30
30
  const {
31
31
  MODEL_PRICING,
32
32
  getModelPricing,
33
+ getModelPricingMeta,
34
+ getPricingDiagnostics,
33
35
  computeRowCost,
34
36
  ensurePricingLoaded,
35
37
  } = require("./pricing");
@@ -1147,7 +1149,15 @@ function createLocalApiHandler({ queuePath }) {
1147
1149
  model: m.model,
1148
1150
  source: s.source,
1149
1151
  });
1150
- return { ...m, totals: { ...m.totals, total_cost_usd: cost.toFixed(6) } };
1152
+ // How the price was resolved, so the dashboard can say "unpriced"
1153
+ // or "matched by substring" instead of inferring it from a $0 cost
1154
+ // — a genuinely free model and an unknown one both cost $0.
1155
+ const { tier } = getModelPricingMeta(m.model, { source: s.source });
1156
+ return {
1157
+ ...m,
1158
+ pricing_tier: tier,
1159
+ totals: { ...m.totals, total_cost_usd: cost.toFixed(6) },
1160
+ };
1151
1161
  })
1152
1162
  .sort((a, b) => b.totals.total_tokens - a.totals.total_tokens);
1153
1163
  const sourceCost = s.models.reduce((sum, m) => sum + Number(m.totals.total_cost_usd), 0);
@@ -1157,7 +1167,13 @@ function createLocalApiHandler({ queuePath }) {
1157
1167
 
1158
1168
  json(res, {
1159
1169
  from, to, days: 0, scope, excluded_sources: excludedSources, sources,
1160
- pricing: { model: "per-model", pricing_mode: "per_token_type", source: "litellm", effective_from: new Date().toISOString().slice(0, 10) },
1170
+ pricing: {
1171
+ model: "per-model",
1172
+ pricing_mode: "per_token_type",
1173
+ source: "litellm",
1174
+ effective_from: new Date().toISOString().slice(0, 10),
1175
+ ...getPricingDiagnostics(),
1176
+ },
1161
1177
  });
1162
1178
  return true;
1163
1179
  }
@@ -1522,6 +1538,8 @@ module.exports = {
1522
1538
  resolveQueuePath,
1523
1539
  // Exported for cross-consumer tests (pricing + native contract lock).
1524
1540
  MODEL_PRICING,
1541
+ // Shared with serve.js so the Host allowlist and the Origin allowlist agree.
1542
+ isLoopbackHostname,
1525
1543
  getModelPricing,
1526
1544
  computeRowCost,
1527
1545
  ensurePricingLoaded,
@@ -2,8 +2,15 @@
2
2
  "_meta": {
3
3
  "note": "Curated price overrides. Always wins over LiteLLM. Two reasons to live here: (1) self-defined alias names that LiteLLM will never carry (kiro-*, hy3-*, composer-*, kimi-for-coding, free-tier OpenRouter routes); (2) prices we want to pin even if LiteLLM has the model (e.g. cache_write fields LiteLLM often omits). Units: USD per million tokens. Edit this file to override pricing without redeploying.",
4
4
  "units": "usd_per_million_tokens",
5
- "deepseek_v4_pro_discount_expiry": "2026-05-31T15:59:00Z DeepSeek v4-pro is currently at a 75% promotional discount. After expiry the prices revert to 4x: input $1.74/M, output $3.48/M, cache_read $0.0145/M, cache_write $1.74/M. Update this file before the cutover.",
6
- "sonnet5_intro_price_expiry": "2026-08-31 — Sonnet 5 intro price 2/10 reverts to 3/15 sticker. The src pricing path auto-tracks LiteLLM (no edit needed); re-vendor the seed after the cutover. See issue #16."
5
+ "expiries_note": "Time-boxed pricing facts. Machine-checked by scripts/validate-curated-expiry.cjs (npm run validate:curated-expiry, part of ci:local): once expires_at has passed, the check FAILS until a human applies `action` and then removes or advances the entry. Free-text expiry notes are not allowed here a date nobody checks is how deepseek-v4-pro stayed on a 75%-off promo price for 55 days past its cutover (issue #87).",
6
+ "expiries": [
7
+ {
8
+ "id": "sonnet5-intro-price",
9
+ "expires_at": "2026-08-31",
10
+ "what": "Sonnet 5 introductory pricing (2/10) reverts to the 3/15 sticker price.",
11
+ "action": "No curated entry to edit — the src pricing path auto-tracks LiteLLM. Re-vendor the bundled seed (npm run pricing:build-seed) so a cold start also prices Sonnet 5 correctly, then delete this entry. See issue #16."
12
+ }
13
+ ]
7
14
  },
8
15
  "exact": {
9
16
  "claude-fable-5": { "input": 10, "output": 50, "cache_read": 1, "cache_write": 12.5, "note": "Pinned from Anthropic public pricing until bundled LiteLLM seed carries Fable 5." },
@@ -20,7 +27,7 @@
20
27
  "MiniMax-M2.7": { "input": 0.3, "output": 1.2, "cache_read": 0.06, "cache_write": 0.375 },
21
28
  "MiniMax-M2.7-highspeed":{ "input": 0.6, "output": 2.4, "cache_read": 0.06, "cache_write": 0.375 },
22
29
  "deepseek-v4-flash":{ "input": 0.14, "output": 0.28, "cache_read": 0.0028, "cache_write": 0.14 },
23
- "deepseek-v4-pro": { "input": 0.435,"output": 0.87, "cache_read": 0.003625, "cache_write": 0.435 },
30
+ "deepseek-v4-pro": { "input": 1.74, "output": 3.48, "cache_read": 0.0145, "cache_write": 1.74, "note": "Standard (post-promo) pricing. The 75%-off launch promo expired 2026-05-31; these rates applied 2026-07-25 (issue #87)." },
24
31
  "deepseek-chat": { "input": 0.14, "output": 0.28, "cache_read": 0.0028, "cache_write": 0.14 },
25
32
  "grok-build": { "input": 1.25, "output": 2.50, "cache_read": 0.20, "note": "Grok Build TUI estimate. Local telemetry currently exposes totalTokens without a stable prompt/output/cache split, so TokenTracker estimates input/output split until Grok exposes per-call usage details." },
26
33
  "grok-4-0709": { "input": 3.00, "output": 15.00, "cache_read": 0.75 },
@@ -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;