tokentracker-cli 0.5.92 → 0.5.94
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 +7 -6
- package/dashboard/dist/assets/{main-CHSJgtKj.js → main-BvlEZHQ6.js} +179 -178
- package/dashboard/dist/index.html +1 -1
- package/dashboard/dist/share.html +1 -1
- package/package.json +3 -1
- package/src/commands/init.js +41 -2
- package/src/commands/serve.js +11 -0
- package/src/commands/status.js +31 -2
- package/src/commands/sync.js +28 -0
- package/src/commands/uninstall.js +18 -1
- package/src/lib/claude-config.js +16 -2
- package/src/lib/local-api.js +11 -116
- package/src/lib/pricing/curated-overrides.json +33 -0
- package/src/lib/pricing/index.js +135 -0
- package/src/lib/pricing/litellm-fetcher.js +172 -0
- package/src/lib/pricing/matcher.js +149 -0
- package/src/lib/pricing/seed-snapshot.json +1 -0
- package/src/lib/rollout.js +284 -0
- package/src/lib/tracker-paths.js +1 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Public pricing API. Replaces the hard-coded MODEL_PRICING table that used
|
|
2
|
+
// to live in src/lib/local-api.js. Keeps the same synchronous shape so all
|
|
3
|
+
// existing callers (computeRowCost, /functions/* handlers, tests) work
|
|
4
|
+
// unchanged after `await ensurePricingLoaded()` is awaited once at startup.
|
|
5
|
+
|
|
6
|
+
const fs = require("node:fs");
|
|
7
|
+
const path = require("node:path");
|
|
8
|
+
const os = require("node:os");
|
|
9
|
+
|
|
10
|
+
const curatedOverrides = require("./curated-overrides.json");
|
|
11
|
+
const {
|
|
12
|
+
lookupPricing,
|
|
13
|
+
buildLitellmPerMillionMap,
|
|
14
|
+
} = require("./matcher");
|
|
15
|
+
const { loadLitellmData } = require("./litellm-fetcher");
|
|
16
|
+
|
|
17
|
+
const ZERO_PRICING = { input: 0, output: 0, cache_read: 0, cache_write: 0 };
|
|
18
|
+
const SEED_SNAPSHOT_PATH = path.resolve(__dirname, "seed-snapshot.json");
|
|
19
|
+
|
|
20
|
+
// Sync seed load. Done at require-time so callers that haven't awaited
|
|
21
|
+
// ensurePricingLoaded() (e.g. tests, vite mock startup, edge functions) still
|
|
22
|
+
// get LiteLLM-backed pricing instead of all-zero. ensurePricingLoaded() will
|
|
23
|
+
// later upgrade this to fresh disk cache or upstream data.
|
|
24
|
+
function loadSeedSync() {
|
|
25
|
+
try {
|
|
26
|
+
const raw = fs.readFileSync(SEED_SNAPSHOT_PATH, "utf8");
|
|
27
|
+
const parsed = JSON.parse(raw);
|
|
28
|
+
delete parsed._meta;
|
|
29
|
+
return parsed;
|
|
30
|
+
} catch (e) {
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const seedRaw = loadSeedSync();
|
|
36
|
+
|
|
37
|
+
const state = {
|
|
38
|
+
loaded: false,
|
|
39
|
+
loadingPromise: null,
|
|
40
|
+
litellmRawMap: seedRaw, // raw per-token; field shape from LiteLLM JSON
|
|
41
|
+
litellmPerMillionMap: buildLitellmPerMillionMap(seedRaw), // USD/MTok
|
|
42
|
+
source: Object.keys(seedRaw).length ? "seed-snapshot:sync" : null,
|
|
43
|
+
// negativeCache prevents re-walking the LiteLLM map for models we've already
|
|
44
|
+
// determined are unknown. Cleared on every reload.
|
|
45
|
+
negativeCache: new Set(),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function defaultCachePath() {
|
|
49
|
+
return path.join(os.homedir(), ".tokentracker", "cache", "pricing.json");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function ensurePricingLoaded(opts = {}) {
|
|
53
|
+
if (state.loaded) return state;
|
|
54
|
+
if (state.loadingPromise) return state.loadingPromise;
|
|
55
|
+
|
|
56
|
+
state.loadingPromise = (async () => {
|
|
57
|
+
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();
|
|
65
|
+
return state;
|
|
66
|
+
} finally {
|
|
67
|
+
state.loadingPromise = null;
|
|
68
|
+
}
|
|
69
|
+
})();
|
|
70
|
+
|
|
71
|
+
return state.loadingPromise;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// For tests: drop loaded state so a fresh call can re-load. Seeds with the
|
|
75
|
+
// bundled snapshot so getModelPricing() still works without ensurePricingLoaded.
|
|
76
|
+
function resetPricingForTests() {
|
|
77
|
+
state.loaded = false;
|
|
78
|
+
state.loadingPromise = null;
|
|
79
|
+
state.litellmRawMap = seedRaw;
|
|
80
|
+
state.litellmPerMillionMap = buildLitellmPerMillionMap(seedRaw);
|
|
81
|
+
state.source = Object.keys(seedRaw).length ? "seed-snapshot:sync" : null;
|
|
82
|
+
state.negativeCache.clear();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function getModelPricing(model) {
|
|
86
|
+
if (!model) return ZERO_PRICING;
|
|
87
|
+
if (state.negativeCache.has(model)) return ZERO_PRICING;
|
|
88
|
+
|
|
89
|
+
const result = lookupPricing(model, {
|
|
90
|
+
curated: curatedOverrides,
|
|
91
|
+
litellm: state.litellmPerMillionMap,
|
|
92
|
+
});
|
|
93
|
+
if (result.hit) return result.value;
|
|
94
|
+
|
|
95
|
+
state.negativeCache.add(model);
|
|
96
|
+
return ZERO_PRICING;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Same formula and Codex/every-code reasoning-folding rule as the previous
|
|
100
|
+
// computeRowCost in src/lib/local-api.js. Moved here so vite mock + local
|
|
101
|
+
// server share one source of truth.
|
|
102
|
+
function computeRowCost(row) {
|
|
103
|
+
const pricing = getModelPricing(row.model);
|
|
104
|
+
const reasoningIncludedInOutput = row.source === "codex" || row.source === "every-code";
|
|
105
|
+
const reasoningCost = reasoningIncludedInOutput
|
|
106
|
+
? 0
|
|
107
|
+
: (row.reasoning_output_tokens || 0) * (pricing.output || 0);
|
|
108
|
+
return (
|
|
109
|
+
((row.input_tokens || 0) * (pricing.input || 0) +
|
|
110
|
+
(row.output_tokens || 0) * (pricing.output || 0) +
|
|
111
|
+
(row.cached_input_tokens || 0) * (pricing.cache_read || 0) +
|
|
112
|
+
(row.cache_creation_input_tokens || 0) * (pricing.cache_write || 0) +
|
|
113
|
+
reasoningCost) /
|
|
114
|
+
1_000_000
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Backwards-compatible MODEL_PRICING export. Test at
|
|
119
|
+
// test/model-breakdown.test.js:236 reads `localApi.MODEL_PRICING["kiro-agent"]`
|
|
120
|
+
// and expects { input, output, cache_read, cache_write } shape. We expose the
|
|
121
|
+
// CURATED.exact map (which contains the kiro entries by design); LiteLLM
|
|
122
|
+
// entries are NOT included here because they're keyed dynamically and the old
|
|
123
|
+
// table was authoritative for what is now CURATED.
|
|
124
|
+
const MODEL_PRICING = curatedOverrides.exact;
|
|
125
|
+
|
|
126
|
+
module.exports = {
|
|
127
|
+
ensurePricingLoaded,
|
|
128
|
+
getModelPricing,
|
|
129
|
+
computeRowCost,
|
|
130
|
+
resetPricingForTests,
|
|
131
|
+
MODEL_PRICING,
|
|
132
|
+
ZERO_PRICING,
|
|
133
|
+
// Internal hooks for tests.
|
|
134
|
+
__getStateForTests: () => state,
|
|
135
|
+
};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// LiteLLM data loader: 24h disk cache + bundled seed snapshot fallback.
|
|
2
|
+
// Fetches from upstream once when cache is missing or stale, then keeps a
|
|
3
|
+
// per-process in-memory map. fetchModelPricing() is async; subsequent reads
|
|
4
|
+
// (lookupPricing in matcher.js) operate on the in-memory map synchronously.
|
|
5
|
+
|
|
6
|
+
const fs = require("node:fs");
|
|
7
|
+
const fsp = require("node:fs/promises");
|
|
8
|
+
const path = require("node:path");
|
|
9
|
+
|
|
10
|
+
const LITELLM_PRICING_URL =
|
|
11
|
+
"https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
|
|
12
|
+
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24h
|
|
13
|
+
const DEFAULT_FETCH_TIMEOUT_MS = 10_000;
|
|
14
|
+
const SEED_SNAPSHOT_PATH = path.resolve(__dirname, "seed-snapshot.json");
|
|
15
|
+
|
|
16
|
+
function readJsonSync(p) {
|
|
17
|
+
const raw = fs.readFileSync(p, "utf8");
|
|
18
|
+
return JSON.parse(raw);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function readJsonAsync(p) {
|
|
22
|
+
const raw = await fsp.readFile(p, "utf8");
|
|
23
|
+
return JSON.parse(raw);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isFresh(stat, ttlMs) {
|
|
27
|
+
if (!stat) return false;
|
|
28
|
+
return Date.now() - stat.mtimeMs < ttlMs;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function statSafe(p) {
|
|
32
|
+
try {
|
|
33
|
+
return await fsp.stat(p);
|
|
34
|
+
} catch (e) {
|
|
35
|
+
if (e?.code === "ENOENT") return null;
|
|
36
|
+
throw e;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function loadSeedSnapshot() {
|
|
41
|
+
// Sync read is fine — file is bundled and small (~250KB). Falling back to
|
|
42
|
+
// sync avoids a race with caller's synchronous lookup if seed must answer
|
|
43
|
+
// immediately.
|
|
44
|
+
try {
|
|
45
|
+
return readJsonSync(SEED_SNAPSHOT_PATH);
|
|
46
|
+
} catch (e) {
|
|
47
|
+
// Tolerate missing seed in dev environments where the build script
|
|
48
|
+
// hasn't run yet. Empty data = LiteLLM lookup miss = falls back to
|
|
49
|
+
// CURATED only.
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function fetchUpstream({ url = LITELLM_PRICING_URL, timeoutMs = DEFAULT_FETCH_TIMEOUT_MS } = {}) {
|
|
55
|
+
const ctrl = new AbortController();
|
|
56
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
57
|
+
try {
|
|
58
|
+
const res = await fetch(url, { signal: ctrl.signal });
|
|
59
|
+
if (!res.ok) {
|
|
60
|
+
throw new Error(`LiteLLM fetch failed: HTTP ${res.status} ${res.statusText}`);
|
|
61
|
+
}
|
|
62
|
+
return await res.json();
|
|
63
|
+
} finally {
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function writeCache(cachePath, data) {
|
|
69
|
+
await fsp.mkdir(path.dirname(cachePath), { recursive: true });
|
|
70
|
+
// Persist only the slimmed shape (4 cost fields) to keep disk small and
|
|
71
|
+
// make the cache file easy to inspect/edit.
|
|
72
|
+
const slim = {};
|
|
73
|
+
let kept = 0;
|
|
74
|
+
for (const [name, entry] of Object.entries(data)) {
|
|
75
|
+
if (!entry || typeof entry !== "object" || name.startsWith("_")) continue;
|
|
76
|
+
const out = {};
|
|
77
|
+
let hasAny = false;
|
|
78
|
+
for (const f of [
|
|
79
|
+
"input_cost_per_token",
|
|
80
|
+
"output_cost_per_token",
|
|
81
|
+
"cache_read_input_token_cost",
|
|
82
|
+
"cache_creation_input_token_cost",
|
|
83
|
+
]) {
|
|
84
|
+
const v = entry[f];
|
|
85
|
+
if (typeof v === "number" && Number.isFinite(v)) {
|
|
86
|
+
out[f] = v;
|
|
87
|
+
hasAny = true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (hasAny) {
|
|
91
|
+
slim[name] = out;
|
|
92
|
+
kept++;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const payload = {
|
|
96
|
+
_meta: {
|
|
97
|
+
source: LITELLM_PRICING_URL,
|
|
98
|
+
cached_at: new Date().toISOString(),
|
|
99
|
+
kept_models: kept,
|
|
100
|
+
},
|
|
101
|
+
...slim,
|
|
102
|
+
};
|
|
103
|
+
await fsp.writeFile(cachePath, JSON.stringify(payload) + "\n");
|
|
104
|
+
return slim;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Public: load LiteLLM data into memory. Resolution chain:
|
|
108
|
+
// 1. disk cache (if mtime < ttl)
|
|
109
|
+
// 2. fetch upstream + write disk cache
|
|
110
|
+
// 3. stale disk cache (network failed)
|
|
111
|
+
// 4. bundled seed snapshot (fresh install / offline)
|
|
112
|
+
async function loadLitellmData({
|
|
113
|
+
cachePath,
|
|
114
|
+
ttlMs = DEFAULT_TTL_MS,
|
|
115
|
+
fetchTimeoutMs = DEFAULT_FETCH_TIMEOUT_MS,
|
|
116
|
+
fetchImpl = fetchUpstream,
|
|
117
|
+
url = LITELLM_PRICING_URL,
|
|
118
|
+
logger = null,
|
|
119
|
+
} = {}) {
|
|
120
|
+
if (!cachePath) {
|
|
121
|
+
throw new Error("loadLitellmData: cachePath is required");
|
|
122
|
+
}
|
|
123
|
+
const log = (level, msg) => {
|
|
124
|
+
if (logger && typeof logger[level] === "function") logger[level](msg);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// 1. Fresh disk cache
|
|
128
|
+
const stat = await statSafe(cachePath);
|
|
129
|
+
if (isFresh(stat, ttlMs)) {
|
|
130
|
+
try {
|
|
131
|
+
const data = await readJsonAsync(cachePath);
|
|
132
|
+
delete data._meta;
|
|
133
|
+
return { data, source: "disk-cache" };
|
|
134
|
+
} catch (e) {
|
|
135
|
+
log("warn", `[pricing] disk cache unreadable: ${e?.message || e}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 2. Fetch upstream
|
|
140
|
+
try {
|
|
141
|
+
const upstream = await fetchImpl({ url, timeoutMs: fetchTimeoutMs });
|
|
142
|
+
const slim = await writeCache(cachePath, upstream);
|
|
143
|
+
return { data: slim, source: "upstream" };
|
|
144
|
+
} catch (e) {
|
|
145
|
+
log("warn", `[pricing] upstream fetch failed: ${e?.message || e}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 3. Stale disk cache (better than seed)
|
|
149
|
+
if (stat) {
|
|
150
|
+
try {
|
|
151
|
+
const data = await readJsonAsync(cachePath);
|
|
152
|
+
delete data._meta;
|
|
153
|
+
log("warn", "[pricing] using stale disk cache");
|
|
154
|
+
return { data, source: "stale-cache" };
|
|
155
|
+
} catch (e) {
|
|
156
|
+
log("warn", `[pricing] stale cache unreadable: ${e?.message || e}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 4. Bundled seed snapshot
|
|
161
|
+
const seed = await loadSeedSnapshot();
|
|
162
|
+
delete seed._meta;
|
|
163
|
+
return { data: seed, source: "seed-snapshot" };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = {
|
|
167
|
+
LITELLM_PRICING_URL,
|
|
168
|
+
DEFAULT_TTL_MS,
|
|
169
|
+
loadLitellmData,
|
|
170
|
+
loadSeedSnapshot,
|
|
171
|
+
fetchUpstream,
|
|
172
|
+
};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Pure pricing-lookup logic. No I/O, no async. Tested in isolation.
|
|
2
|
+
//
|
|
3
|
+
// Resolve order:
|
|
4
|
+
// 1. CURATED exact match (self-defined aliases like kiro-*, hy3-*)
|
|
5
|
+
// 2. LiteLLM exact match (mainstream claude/gpt-5/gemini)
|
|
6
|
+
// 3. CURATED alias (e.g. "auto" -> "composer-1")
|
|
7
|
+
// 4. CURATED fuzzy substring (e.g. "kiro-future-xyz" matches via "kiro")
|
|
8
|
+
// 5. LiteLLM suffix-strip (gpt-5-codex-high-fast -> gpt-5-codex)
|
|
9
|
+
// 6. LiteLLM reverse substring (longest-key first)
|
|
10
|
+
// 7. null (caller decides what to do — typically zero-pricing + negative cache)
|
|
11
|
+
|
|
12
|
+
const SUFFIX_STRIP_PATTERNS = [
|
|
13
|
+
/-xhigh-fast$/,
|
|
14
|
+
/-high-fast$/,
|
|
15
|
+
/-medium-fast$/,
|
|
16
|
+
/-low-fast$/,
|
|
17
|
+
/-xhigh$/,
|
|
18
|
+
/-high$/,
|
|
19
|
+
/-medium$/,
|
|
20
|
+
/-low$/,
|
|
21
|
+
/-fast$/,
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
function stripReasoningSuffix(model) {
|
|
25
|
+
for (const re of SUFFIX_STRIP_PATTERNS) {
|
|
26
|
+
if (re.test(model)) return model.replace(re, "");
|
|
27
|
+
}
|
|
28
|
+
return model;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Memoise the sorted-by-length LiteLLM key list. Reverse-substring scan walks
|
|
32
|
+
// this once per uncached model; ~2k keys × negligible per-iteration cost, but
|
|
33
|
+
// computing the sort on every call would add up across a sync.
|
|
34
|
+
const sortedKeysCache = new WeakMap();
|
|
35
|
+
function getSortedKeys(litellm) {
|
|
36
|
+
let cached = sortedKeysCache.get(litellm);
|
|
37
|
+
if (!cached) {
|
|
38
|
+
cached = Object.keys(litellm).sort((a, b) => b.length - a.length);
|
|
39
|
+
sortedKeysCache.set(litellm, cached);
|
|
40
|
+
}
|
|
41
|
+
return cached;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function lookupPricing(model, { curated, litellm }) {
|
|
45
|
+
if (!model || typeof model !== "string") {
|
|
46
|
+
return { hit: false, source: "empty", value: null };
|
|
47
|
+
}
|
|
48
|
+
const lower = model.toLowerCase();
|
|
49
|
+
|
|
50
|
+
// 1. CURATED exact
|
|
51
|
+
if (curated.exact && curated.exact[model]) {
|
|
52
|
+
return { hit: true, source: "curated:exact", value: curated.exact[model] };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 2. LiteLLM exact
|
|
56
|
+
if (litellm && litellm[model]) {
|
|
57
|
+
return { hit: true, source: "litellm:exact", value: litellm[model] };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 3. CURATED alias (literal mapping like "auto" -> "composer-1")
|
|
61
|
+
if (curated.alias && curated.alias[model] && curated.exact[curated.alias[model]]) {
|
|
62
|
+
return {
|
|
63
|
+
hit: true,
|
|
64
|
+
source: "curated:alias",
|
|
65
|
+
value: curated.exact[curated.alias[model]],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 4. CURATED fuzzy substring
|
|
70
|
+
if (Array.isArray(curated.fuzzy)) {
|
|
71
|
+
for (const { match, ref } of curated.fuzzy) {
|
|
72
|
+
if (!match || !ref) continue;
|
|
73
|
+
if (lower.includes(match.toLowerCase()) && curated.exact[ref]) {
|
|
74
|
+
return { hit: true, source: "curated:fuzzy", value: curated.exact[ref] };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 5. LiteLLM suffix-strip
|
|
80
|
+
if (litellm) {
|
|
81
|
+
const stripped = stripReasoningSuffix(model);
|
|
82
|
+
if (stripped !== model && litellm[stripped]) {
|
|
83
|
+
return { hit: true, source: "litellm:strip", value: litellm[stripped] };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 6. LiteLLM reverse substring (longest-key first)
|
|
88
|
+
if (litellm) {
|
|
89
|
+
const sorted = getSortedKeys(litellm);
|
|
90
|
+
for (const key of sorted) {
|
|
91
|
+
const keyLower = key.toLowerCase();
|
|
92
|
+
// Only accept if model is a superset of key (model contains key), to
|
|
93
|
+
// avoid e.g. "gpt-5" matching "gpt-5-pro" in the wrong direction.
|
|
94
|
+
if (lower.includes(keyLower)) {
|
|
95
|
+
return { hit: true, source: "litellm:fuzzy", value: litellm[key] };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { hit: false, source: "miss", value: null };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Convert one LiteLLM entry (per-token) to internal per-million USD shape.
|
|
104
|
+
// Missing fields stay missing — callers default with `(pricing.x || 0)`.
|
|
105
|
+
//
|
|
106
|
+
// Why the round: floating-point math means 1e-7 * 1e6 = 0.09999999999999999.
|
|
107
|
+
// Rounding to 10 significant decimals ($0.0000000001 / MTok) is well below
|
|
108
|
+
// any realistic price step but cleans up the printed/asserted numbers.
|
|
109
|
+
function roundToTenDecimals(n) {
|
|
110
|
+
return Math.round(n * 1e10) / 1e10;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function convertLitellmEntry(entry) {
|
|
114
|
+
if (!entry || typeof entry !== "object") return null;
|
|
115
|
+
const out = {};
|
|
116
|
+
if (typeof entry.input_cost_per_token === "number") {
|
|
117
|
+
out.input = roundToTenDecimals(entry.input_cost_per_token * 1_000_000);
|
|
118
|
+
}
|
|
119
|
+
if (typeof entry.output_cost_per_token === "number") {
|
|
120
|
+
out.output = roundToTenDecimals(entry.output_cost_per_token * 1_000_000);
|
|
121
|
+
}
|
|
122
|
+
if (typeof entry.cache_read_input_token_cost === "number") {
|
|
123
|
+
out.cache_read = roundToTenDecimals(entry.cache_read_input_token_cost * 1_000_000);
|
|
124
|
+
}
|
|
125
|
+
if (typeof entry.cache_creation_input_token_cost === "number") {
|
|
126
|
+
out.cache_write = roundToTenDecimals(entry.cache_creation_input_token_cost * 1_000_000);
|
|
127
|
+
}
|
|
128
|
+
return Object.keys(out).length ? out : null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Build a per-million-USD map from a LiteLLM raw map (or seed snapshot which
|
|
132
|
+
// uses the same field names). Skips meta keys starting with "_".
|
|
133
|
+
function buildLitellmPerMillionMap(rawData) {
|
|
134
|
+
if (!rawData || typeof rawData !== "object") return {};
|
|
135
|
+
const out = {};
|
|
136
|
+
for (const [name, entry] of Object.entries(rawData)) {
|
|
137
|
+
if (name.startsWith("_")) continue;
|
|
138
|
+
const converted = convertLitellmEntry(entry);
|
|
139
|
+
if (converted) out[name] = converted;
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = {
|
|
145
|
+
lookupPricing,
|
|
146
|
+
stripReasoningSuffix,
|
|
147
|
+
convertLitellmEntry,
|
|
148
|
+
buildLitellmPerMillionMap,
|
|
149
|
+
};
|