@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.
- package/README.md +6 -6
- package/dashboard/dist/assets/{Card-BbRYRVXf.js → Card-BwY_Qv6N.js} +1 -1
- package/dashboard/dist/assets/DashboardPage-C7b_GjA-.js +60 -0
- package/dashboard/dist/assets/{FadeIn-CjAacJ5t.js → FadeIn-DiAafUDN.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-BGALK8Vy.js → IpCheckPage-CYgWVZJr.js} +1 -1
- package/dashboard/dist/assets/LimitsPage-C1y75ftN.js +2 -0
- package/dashboard/dist/assets/{LocalOnlyNotice-Dxo2vFMQ.js → LocalOnlyNotice-B5_-ydeu.js} +1 -1
- package/dashboard/dist/assets/{PopoverPopup-DP9fwdtl.js → PopoverPopup-DDjjQ5CG.js} +1 -1
- package/dashboard/dist/assets/{Select-DPE53NmM.js → Select-BPspkl9p.js} +1 -1
- package/dashboard/dist/assets/{SelectItemText-D69v77is.js → SelectItemText-CX0dWYB1.js} +1 -1
- package/dashboard/dist/assets/{SettingsPage-Dxzqpi02.js → SettingsPage-9K5ST4D_.js} +1 -1
- package/dashboard/dist/assets/{SkillsPage-Bo7e39AB.js → SkillsPage-BpCSS7ls.js} +1 -1
- package/dashboard/dist/assets/{WidgetsPage-HHcIr_Eq.js → WidgetsPage-aBisLWrM.js} +1 -1
- package/dashboard/dist/assets/{WrappedPage-C-_jLXXU.js → WrappedPage-BgkuBG_j.js} +1 -1
- package/dashboard/dist/assets/{arrow-up-right-BJvJHUGj.js → arrow-up-right-DPb2FRSP.js} +1 -1
- package/dashboard/dist/assets/{download-CnzY_EgN.js → download-CbZ8YL8m.js} +1 -1
- package/dashboard/dist/assets/{format-1w-q5n2f.js → format-aRaCvnht.js} +1 -1
- package/dashboard/dist/assets/limitDisplay-1O9-AdgI.js +1 -0
- package/dashboard/dist/assets/{main-3gBEI6Jl.js → main-DRcFJLLD.js} +5 -3
- package/dashboard/dist/assets/main-ZrWkoMlr.css +1 -0
- package/dashboard/dist/assets/mock-data-sZ3-GZV0.js +1 -0
- package/dashboard/dist/assets/{use-limits-display-prefs-D1nT9-mr.js → use-limits-display-prefs-2-qYvffB.js} +1 -1
- package/dashboard/dist/assets/{use-native-settings-Dh6NzqjY.js → use-native-settings-C1uhDKdL.js} +1 -1
- package/dashboard/dist/assets/{useCurrency-Hhs5jbWw.js → useCurrency-BPCF4zUv.js} +1 -1
- package/dashboard/dist/index.html +2 -2
- 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-DYKHv0m3.js +0 -60
- package/dashboard/dist/assets/LimitsPage-LkZGPYvg.js +0 -2
- package/dashboard/dist/assets/main-1kxAVM3m.css +0 -1
- package/dashboard/dist/assets/mock-data-CJOMGcL9.js +0 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
};
|
package/src/lib/local-api.js
CHANGED
|
@@ -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
|
-
|
|
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: {
|
|
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
|
-
"
|
|
6
|
-
"
|
|
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":
|
|
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 },
|
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;
|