@ipv9/tokentracker-cli 0.39.0
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/LICENSE +21 -0
- package/README.ja.md +464 -0
- package/README.ko.md +464 -0
- package/README.md +476 -0
- package/README.zh-CN.md +469 -0
- package/bin/tracker.js +52 -0
- package/dashboard/dist/app-icon.png +0 -0
- package/dashboard/dist/apple-touch-icon.png +0 -0
- package/dashboard/dist/assets/ActivityHeatmap-BxBGHY1z.js +42 -0
- package/dashboard/dist/assets/Card-GROxUc_6.js +1 -0
- package/dashboard/dist/assets/DashboardPage-BZ5VFoBk.js +19 -0
- package/dashboard/dist/assets/DevicePage-CQts7ivB.js +1 -0
- package/dashboard/dist/assets/DialogTitle-DqFsk26U.js +1 -0
- package/dashboard/dist/assets/FadeIn-HBBXW_3q.js +1 -0
- package/dashboard/dist/assets/HeaderGithubStar-COD2HfaF.js +1 -0
- package/dashboard/dist/assets/IpCheckPage-Cn0l8-BQ.js +15 -0
- package/dashboard/dist/assets/LandingPage-CmV1BMU4.js +4356 -0
- package/dashboard/dist/assets/LeaderboardAvatar-CiIpWZwb.js +1 -0
- package/dashboard/dist/assets/LeaderboardPage-BU0mlR3g.js +6 -0
- package/dashboard/dist/assets/LeaderboardProfileModal-C49_3Xm9.js +72 -0
- package/dashboard/dist/assets/LeaderboardProfilePage-Bh1n0BAM.js +1 -0
- package/dashboard/dist/assets/LimitsPage-fZAmwLWT.js +2 -0
- package/dashboard/dist/assets/LocalOnlyNotice-D3rQdA-P.js +1 -0
- package/dashboard/dist/assets/LoginPage-enO4XP-c.js +1 -0
- package/dashboard/dist/assets/PopoverPopup-pitJEpN1.js +1 -0
- package/dashboard/dist/assets/Select-tT9x4ntD.js +1 -0
- package/dashboard/dist/assets/SelectItemText-C7F7eIm0.js +1 -0
- package/dashboard/dist/assets/SettingsPage-pDasZNU2.js +1 -0
- package/dashboard/dist/assets/SkillsPage-DEu7FY-2.js +1 -0
- package/dashboard/dist/assets/WidgetsPage-DGcO7rbp.js +1 -0
- package/dashboard/dist/assets/WrappedPage-D_1ID213.js +1 -0
- package/dashboard/dist/assets/agent-logos-DXQVg1xy.js +1 -0
- package/dashboard/dist/assets/arrow-up-right-DNLo92Ba.js +1 -0
- package/dashboard/dist/assets/download-DbBBBpJF.js +1 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-400-normal-BPBWmzPh.woff +0 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-400-normal-Ce5q_31Z.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-500-normal-CJBLNVQT.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-500-normal-mNhfPmgl.woff +0 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-700-normal-DH5Q319x.woff +0 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-700-normal-VCNRadI3.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-900-normal-BKC3PZkM.woff +0 -0
- package/dashboard/dist/assets/geist-mono-cyrillic-900-normal-DdrQ8KBw.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-400-normal-CoULgQGM.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-400-normal-LC9RFr9I.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-500-normal-D3o2eNa9.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-500-normal-DOxI7kZ4.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-700-normal-D6izGJRP.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-700-normal-QGw08Lff.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-900-normal-CmoKXrdK.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-900-normal-Cu5MFKsu.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-400-normal-Cgks_Qgx.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-400-normal-CxNRRMGd.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-500-normal-CQcGuCNt.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-500-normal-diTenJ8L.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-700-normal-BX9f1BHp.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-700-normal-YOllDaLV.woff2 +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-900-normal-Br6WNUGb.woff +0 -0
- package/dashboard/dist/assets/geist-mono-latin-ext-900-normal-CrmBTzU2.woff2 +0 -0
- package/dashboard/dist/assets/index-BeoRn2gJ.js +2 -0
- package/dashboard/dist/assets/info-Bh5qTz6k.js +1 -0
- package/dashboard/dist/assets/main-BIY3Nj6I.js +969 -0
- package/dashboard/dist/assets/main-G_F9Iu_x.css +1 -0
- package/dashboard/dist/assets/use-limits-display-prefs-B4iJ-bBc.js +1 -0
- package/dashboard/dist/assets/use-native-settings-DHjPFDv8.js +1 -0
- package/dashboard/dist/assets/use-usage-limits-odqe9OY7.js +1 -0
- package/dashboard/dist/assets/useCurrency-BsSZiZMP.js +1 -0
- package/dashboard/dist/assets/useOpenInteractionType-BQhgq4rv.js +12 -0
- package/dashboard/dist/brand-logos/antigravity.svg +1 -0
- package/dashboard/dist/brand-logos/claude-code.svg +1 -0
- package/dashboard/dist/brand-logos/codebuddy.svg +1 -0
- package/dashboard/dist/brand-logos/codex.svg +15 -0
- package/dashboard/dist/brand-logos/copilot.svg +1 -0
- package/dashboard/dist/brand-logos/cursor.svg +1 -0
- package/dashboard/dist/brand-logos/gemini.svg +1 -0
- package/dashboard/dist/brand-logos/grok.svg +1 -0
- package/dashboard/dist/brand-logos/hermes.svg +1 -0
- package/dashboard/dist/brand-logos/kilo.svg +4 -0
- package/dashboard/dist/brand-logos/kimi.svg +1 -0
- package/dashboard/dist/brand-logos/kiro.svg +1 -0
- package/dashboard/dist/brand-logos/openclaw.svg +22 -0
- package/dashboard/dist/brand-logos/opencode.svg +3 -0
- package/dashboard/dist/clawd/happy.svg +161 -0
- package/dashboard/dist/clawd/idle/collapse.svg +101 -0
- package/dashboard/dist/clawd/idle/doze.svg +72 -0
- package/dashboard/dist/clawd/idle/follow.svg +64 -0
- package/dashboard/dist/clawd/idle/living.svg +196 -0
- package/dashboard/dist/clawd/idle/look.svg +115 -0
- package/dashboard/dist/clawd/idle/yawn.svg +158 -0
- package/dashboard/dist/clawd/mini/alert.svg +129 -0
- package/dashboard/dist/clawd/mini/crabwalk.svg +76 -0
- package/dashboard/dist/clawd/mini/enter-sleep.svg +65 -0
- package/dashboard/dist/clawd/mini/enter.svg +115 -0
- package/dashboard/dist/clawd/mini/happy.svg +124 -0
- package/dashboard/dist/clawd/mini/idle-tight.svg +15 -0
- package/dashboard/dist/clawd/mini/idle.svg +83 -0
- package/dashboard/dist/clawd/mini/peek.svg +82 -0
- package/dashboard/dist/clawd/mini/sleep.svg +112 -0
- package/dashboard/dist/clawd/react/double.svg +108 -0
- package/dashboard/dist/clawd/react/drag.svg +102 -0
- package/dashboard/dist/clawd/react/left.svg +102 -0
- package/dashboard/dist/clawd/react/right.svg +102 -0
- package/dashboard/dist/clawd/sleep/collapse-sleep.svg +247 -0
- package/dashboard/dist/clawd/sleep/sleeping.svg +118 -0
- package/dashboard/dist/clawd/sleep/wake.svg +277 -0
- package/dashboard/dist/clawd/static-base.svg +21 -0
- package/dashboard/dist/clawd/status/disconnected.svg +136 -0
- package/dashboard/dist/clawd/status/error.svg +94 -0
- package/dashboard/dist/clawd/status/notification.svg +134 -0
- package/dashboard/dist/clawd/working/building.svg +231 -0
- package/dashboard/dist/clawd/working/carrying.svg +107 -0
- package/dashboard/dist/clawd/working/conducting.svg +118 -0
- package/dashboard/dist/clawd/working/confused.svg +113 -0
- package/dashboard/dist/clawd/working/debugger.svg +91 -0
- package/dashboard/dist/clawd/working/juggling.svg +82 -0
- package/dashboard/dist/clawd/working/overheated.svg +75 -0
- package/dashboard/dist/clawd/working/pushing.svg +125 -0
- package/dashboard/dist/clawd/working/sweeping.svg +79 -0
- package/dashboard/dist/clawd/working/thinking.svg +119 -0
- package/dashboard/dist/clawd/working/typing.svg +153 -0
- package/dashboard/dist/clawd/working/ultrathink.svg +166 -0
- package/dashboard/dist/clawd/working/wizard.svg +98 -0
- package/dashboard/dist/dashboard-dark.png +0 -0
- package/dashboard/dist/favicon-16.png +0 -0
- package/dashboard/dist/favicon-32.png +0 -0
- package/dashboard/dist/favicon.ico +0 -0
- package/dashboard/dist/feed.xml +34 -0
- package/dashboard/dist/icon-192.png +0 -0
- package/dashboard/dist/icon-512.png +0 -0
- package/dashboard/dist/index.html +355 -0
- package/dashboard/dist/llms.txt +53 -0
- package/dashboard/dist/neon_bg.png +0 -0
- package/dashboard/dist/robots.txt +7 -0
- package/dashboard/dist/share.html +140 -0
- package/dashboard/dist/sitemap.xml +27 -0
- package/dashboard/dist/widgets-overview.png +0 -0
- package/package.json +78 -0
- package/scripts/install-local-service.sh +140 -0
- package/scripts/uninstall-local-service.sh +21 -0
- package/src/cli.js +95 -0
- package/src/commands/device-login.js +161 -0
- package/src/commands/diagnostics.js +38 -0
- package/src/commands/doctor.js +96 -0
- package/src/commands/init.js +1195 -0
- package/src/commands/serve.js +351 -0
- package/src/commands/status.js +609 -0
- package/src/commands/sync.js +2285 -0
- package/src/commands/uninstall.js +189 -0
- package/src/commands/wrapped.js +150 -0
- package/src/lib/antigravity-paths.js +48 -0
- package/src/lib/browser-auth.js +281 -0
- package/src/lib/categorizer-utils.js +232 -0
- package/src/lib/claude-categorizer.js +1319 -0
- package/src/lib/claude-config.js +204 -0
- package/src/lib/cli-ui.js +179 -0
- package/src/lib/codex-config.js +350 -0
- package/src/lib/codex-context-breakdown.js +400 -0
- package/src/lib/codex-rollout-parser.js +623 -0
- package/src/lib/codex-token-refresh.js +112 -0
- package/src/lib/cursor-config.js +425 -0
- package/src/lib/debug-flags.js +7 -0
- package/src/lib/diagnostics.js +272 -0
- package/src/lib/doctor.js +338 -0
- package/src/lib/fs.js +91 -0
- package/src/lib/gemini-config.js +288 -0
- package/src/lib/grok-hook.js +351 -0
- package/src/lib/init-flow.js +42 -0
- package/src/lib/local-api.js +1861 -0
- package/src/lib/openclaw-hook.js +420 -0
- package/src/lib/openclaw-session-plugin.js +520 -0
- package/src/lib/opencode-config.js +99 -0
- package/src/lib/passive-mode.js +185 -0
- package/src/lib/pricing/curated-overrides.json +79 -0
- package/src/lib/pricing/index.js +143 -0
- package/src/lib/pricing/litellm-fetcher.js +172 -0
- package/src/lib/pricing/matcher.js +255 -0
- package/src/lib/pricing/seed-snapshot.json +1 -0
- package/src/lib/progress.js +76 -0
- package/src/lib/project-usage-purge.js +106 -0
- package/src/lib/prompt.js +24 -0
- package/src/lib/proxy-env.js +131 -0
- package/src/lib/rollout.js +8442 -0
- package/src/lib/runtime-config.js +122 -0
- package/src/lib/skill-usage.js +267 -0
- package/src/lib/skills-manager.js +1127 -0
- package/src/lib/source-metadata.js +48 -0
- package/src/lib/sqlite-reader.js +136 -0
- package/src/lib/static-server.js +59 -0
- package/src/lib/subscriptions.js +453 -0
- package/src/lib/tracker-paths.js +16 -0
- package/src/lib/upload-throttle.js +148 -0
- package/src/lib/usage-limits.js +1806 -0
- package/src/lib/wrapped-aggregator.js +225 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Passive-mode detection.
|
|
3
|
+
*
|
|
4
|
+
* Token Tracker normally collects token usage by installing SessionEnd hooks
|
|
5
|
+
* into supported CLIs (Claude Code, Codex CLI, Gemini CLI, …). The hook
|
|
6
|
+
* fires `notify.cjs` after each turn → sync runs immediately → data is
|
|
7
|
+
* fresh within seconds.
|
|
8
|
+
*
|
|
9
|
+
* On environments where hook installation fails — WSL with read-only mount,
|
|
10
|
+
* UNC paths on Windows, locked `settings.json`, sandboxed processes —
|
|
11
|
+
* SessionEnd hooks never fire, but the underlying CLIs *still* write
|
|
12
|
+
* session logs (`~/.claude/projects/`, `~/.gemini/sessions/`, etc.). Our
|
|
13
|
+
* parsers can read those logs directly without the hook; the only loss is
|
|
14
|
+
* "data is fresh within seconds" → it's now "data is fresh after the next
|
|
15
|
+
* scheduled `tracker sync`".
|
|
16
|
+
*
|
|
17
|
+
* This module surfaces that fact: for each hook-driven provider, decide
|
|
18
|
+
* whether the local install is in **passive mode** (no hook + log dir
|
|
19
|
+
* present). `tracker status` reports it, and the dashboard can show a
|
|
20
|
+
* single banner asking the user whether to acknowledge & continue in
|
|
21
|
+
* passive mode or attempt re-install (`tracker init`).
|
|
22
|
+
*
|
|
23
|
+
* Detection is purely best-effort — we never *fail* sync because hooks are
|
|
24
|
+
* missing; we just label the source so users / AI agents know latency is
|
|
25
|
+
* minutes, not seconds.
|
|
26
|
+
*/
|
|
27
|
+
"use strict";
|
|
28
|
+
|
|
29
|
+
const fs = require("node:fs");
|
|
30
|
+
const path = require("node:path");
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {Object} PassiveProvider
|
|
34
|
+
* @property {string} name - canonical provider name
|
|
35
|
+
* @property {boolean} hook_expected - whether this provider supports hooks
|
|
36
|
+
* @property {boolean} hook_installed - true when the hook is set
|
|
37
|
+
* @property {boolean} logs_present - true when the provider's log dir
|
|
38
|
+
* exists and contains at least one
|
|
39
|
+
* session file we can parse
|
|
40
|
+
* @property {boolean} passive - true iff hook_expected &&
|
|
41
|
+
* !hook_installed && logs_present
|
|
42
|
+
* @property {string|null} hook_failure_reason - if hook_expected and not
|
|
43
|
+
* installed, why we think it failed
|
|
44
|
+
* (e.g. "settings.json not writable",
|
|
45
|
+
* "config dir not found")
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
function dirHasFile(dir, predicate) {
|
|
49
|
+
if (!dir || !fs.existsSync(dir)) return false;
|
|
50
|
+
let entries;
|
|
51
|
+
try {
|
|
52
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
53
|
+
} catch (_e) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
for (const entry of entries) {
|
|
57
|
+
const full = path.join(dir, entry.name);
|
|
58
|
+
// Let the predicate accept either a file OR a directory name (so
|
|
59
|
+
// "sessions" or "tmp" as a bare subdirectory still counts).
|
|
60
|
+
if (!predicate || predicate(full, entry.name, entry.isDirectory())) return true;
|
|
61
|
+
if (entry.isDirectory()) {
|
|
62
|
+
// Shallow recurse — at most 1 level (covers projects/<name>/*.jsonl)
|
|
63
|
+
if (dirHasFile(full, predicate)) return true;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function classifyWritableFailure(filePath) {
|
|
70
|
+
if (!filePath) return "no path";
|
|
71
|
+
try {
|
|
72
|
+
fs.accessSync(filePath, fs.constants.W_OK);
|
|
73
|
+
return "writable (hook may have been removed externally)";
|
|
74
|
+
} catch (e) {
|
|
75
|
+
if (e && e.code === "ENOENT") return "settings file missing";
|
|
76
|
+
if (e && e.code === "EACCES") return "permission denied";
|
|
77
|
+
if (e && e.code === "EROFS") return "read-only filesystem";
|
|
78
|
+
return e?.code || "unknown";
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Detect passive mode for each hook-driven provider.
|
|
84
|
+
*
|
|
85
|
+
* @param {Object} opts
|
|
86
|
+
* @param {string} opts.home - user home directory
|
|
87
|
+
* @param {Object} opts.hookStatus - per-provider hook-installed booleans
|
|
88
|
+
* (already collected by status.js); shape: { claude, gemini, codex,
|
|
89
|
+
* every_code, opencode, openclaw, codebuddy, grok }
|
|
90
|
+
* @returns {PassiveProvider[]}
|
|
91
|
+
*/
|
|
92
|
+
function detectPassiveProviders({ home, hookStatus }) {
|
|
93
|
+
const out = [];
|
|
94
|
+
|
|
95
|
+
// Claude Code — logs at ~/.claude/projects/<name>/*.jsonl
|
|
96
|
+
out.push(buildEntry({
|
|
97
|
+
name: "claude",
|
|
98
|
+
hookExpected: true,
|
|
99
|
+
hookInstalled: Boolean(hookStatus?.claude),
|
|
100
|
+
logsDir: path.join(home, ".claude", "projects"),
|
|
101
|
+
logsPredicate: (full) => full.endsWith(".jsonl"),
|
|
102
|
+
settingsPath: path.join(home, ".claude", "settings.json"),
|
|
103
|
+
}));
|
|
104
|
+
|
|
105
|
+
// Gemini CLI — logs at ~/.gemini/tmp/<session-id>/logs.json
|
|
106
|
+
// (path varies by version; presence of ~/.gemini/sessions or
|
|
107
|
+
// ~/.gemini/tmp is enough to call it "active")
|
|
108
|
+
out.push(buildEntry({
|
|
109
|
+
name: "gemini",
|
|
110
|
+
hookExpected: true,
|
|
111
|
+
hookInstalled: Boolean(hookStatus?.gemini),
|
|
112
|
+
logsDir: path.join(home, ".gemini"),
|
|
113
|
+
logsPredicate: (_full, name, isDir) =>
|
|
114
|
+
(isDir && (name === "tmp" || name === "sessions")) || name === "logs.json",
|
|
115
|
+
settingsPath: path.join(home, ".gemini", "settings.json"),
|
|
116
|
+
}));
|
|
117
|
+
|
|
118
|
+
// Codex CLI — logs at ~/.codex/sessions/
|
|
119
|
+
// Match either a "sessions" subdir (even if empty — the CLI created it)
|
|
120
|
+
// or any *.jsonl session file.
|
|
121
|
+
out.push(buildEntry({
|
|
122
|
+
name: "codex",
|
|
123
|
+
hookExpected: true,
|
|
124
|
+
hookInstalled: Boolean(hookStatus?.codex_notify ?? hookStatus?.codex),
|
|
125
|
+
logsDir: path.join(home, ".codex"),
|
|
126
|
+
logsPredicate: (_full, name, isDir) =>
|
|
127
|
+
(isDir && name === "sessions") || (!isDir && name.endsWith(".jsonl")),
|
|
128
|
+
settingsPath: path.join(home, ".codex", "config.toml"),
|
|
129
|
+
}));
|
|
130
|
+
|
|
131
|
+
// Every Code — same shape as Codex, different home
|
|
132
|
+
out.push(buildEntry({
|
|
133
|
+
name: "every_code",
|
|
134
|
+
hookExpected: true,
|
|
135
|
+
hookInstalled: Boolean(hookStatus?.every_code_notify ?? hookStatus?.every_code),
|
|
136
|
+
logsDir: path.join(home, ".code"),
|
|
137
|
+
logsPredicate: (_full, name, isDir) =>
|
|
138
|
+
(isDir && name === "sessions") || (!isDir && name.endsWith(".jsonl")),
|
|
139
|
+
settingsPath: path.join(home, ".code", "config.toml"),
|
|
140
|
+
}));
|
|
141
|
+
|
|
142
|
+
// CodeBuddy — Claude-fork; hook in ~/.codebuddy/settings.json
|
|
143
|
+
out.push(buildEntry({
|
|
144
|
+
name: "codebuddy",
|
|
145
|
+
hookExpected: true,
|
|
146
|
+
hookInstalled: Boolean(hookStatus?.codebuddy),
|
|
147
|
+
logsDir: path.join(home, ".codebuddy"),
|
|
148
|
+
logsPredicate: (_full, name) => name === "projects" || name.endsWith(".jsonl"),
|
|
149
|
+
settingsPath: path.join(home, ".codebuddy", "settings.json"),
|
|
150
|
+
}));
|
|
151
|
+
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function buildEntry({ name, hookExpected, hookInstalled, logsDir, logsPredicate, settingsPath }) {
|
|
156
|
+
const logsPresent = dirHasFile(logsDir, logsPredicate);
|
|
157
|
+
const passive = hookExpected && !hookInstalled && logsPresent;
|
|
158
|
+
let reason = null;
|
|
159
|
+
if (hookExpected && !hookInstalled) {
|
|
160
|
+
reason = classifyWritableFailure(settingsPath);
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
name,
|
|
164
|
+
hook_expected: hookExpected,
|
|
165
|
+
hook_installed: hookInstalled,
|
|
166
|
+
logs_present: logsPresent,
|
|
167
|
+
passive,
|
|
168
|
+
hook_failure_reason: reason,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Convenience boolean: is at least one provider in passive mode?
|
|
174
|
+
*/
|
|
175
|
+
function isPassiveModeActive(providers) {
|
|
176
|
+
return providers.some((p) => p.passive);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
module.exports = {
|
|
180
|
+
detectPassiveProviders,
|
|
181
|
+
isPassiveModeActive,
|
|
182
|
+
// Exposed for unit testing
|
|
183
|
+
dirHasFile,
|
|
184
|
+
classifyWritableFailure,
|
|
185
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_meta": {
|
|
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
|
+
"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
|
+
},
|
|
7
|
+
"exact": {
|
|
8
|
+
"claude-opus-4-8": { "input": 5, "output": 25, "cache_read": 0.5, "cache_write": 6.25, "note": "Opus 4.8 not yet in LiteLLM. Pin to the standard Opus tier (same as 4.6/4.7); remove once LiteLLM carries it." },
|
|
9
|
+
"kiro-agent": { "input": 3, "output": 15, "cache_read": 0.3, "cache_write": 3.75 },
|
|
10
|
+
"kiro-cli-agent": { "input": 3, "output": 15, "cache_read": 0.3, "cache_write": 3.75 },
|
|
11
|
+
"hy3-preview-agent":{ "input": 0, "output": 0, "cache_read": 0 },
|
|
12
|
+
"hy3-preview": { "input": 0, "output": 0, "cache_read": 0 },
|
|
13
|
+
"composer-1": { "input": 1.25, "output": 10, "cache_read": 0.125 },
|
|
14
|
+
"composer-1.5": { "input": 3.5, "output": 17.5, "cache_read": 0.35 },
|
|
15
|
+
"composer-2": { "input": 0.5, "output": 2.5, "cache_read": 0.2 },
|
|
16
|
+
"composer-2-fast": { "input": 1.5, "output": 7.5, "cache_read": 0.15 },
|
|
17
|
+
"MiniMax-M2.7": { "input": 0.3, "output": 1.2, "cache_read": 0.06, "cache_write": 0.375 },
|
|
18
|
+
"MiniMax-M2.7-highspeed":{ "input": 0.6, "output": 2.4, "cache_read": 0.06, "cache_write": 0.375 },
|
|
19
|
+
"deepseek-v4-flash":{ "input": 0.14, "output": 0.28, "cache_read": 0.0028, "cache_write": 0.14 },
|
|
20
|
+
"deepseek-v4-pro": { "input": 0.435,"output": 0.87, "cache_read": 0.003625, "cache_write": 0.435 },
|
|
21
|
+
"deepseek-chat": { "input": 0.14, "output": 0.28, "cache_read": 0.0028, "cache_write": 0.14 },
|
|
22
|
+
"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." },
|
|
23
|
+
"grok-4-0709": { "input": 3.00, "output": 15.00, "cache_read": 0.75 },
|
|
24
|
+
"grok-4": { "input": 3.00, "output": 15.00, "cache_read": 0.75 },
|
|
25
|
+
"grok-4-latest": { "input": 3.00, "output": 15.00, "cache_read": 0.75 },
|
|
26
|
+
"grok-4-fast": { "input": 0.20, "output": 0.50, "cache_read": 0.05 },
|
|
27
|
+
"grok-4-fast-reasoning": { "input": 0.20, "output": 0.50, "cache_read": 0.05 },
|
|
28
|
+
"grok-4-fast-non-reasoning": { "input": 0.20, "output": 0.50, "cache_read": 0.05 },
|
|
29
|
+
"grok-4-1-fast-non-reasoning": { "input": 0.20, "output": 0.50, "cache_read": 0.05 },
|
|
30
|
+
"deepseek-reasoner":{ "input": 0.14, "output": 0.28, "cache_read": 0.0028, "cache_write": 0.14 },
|
|
31
|
+
"kimi-for-coding": { "input": 0.6, "output": 2, "cache_read": 0.15 },
|
|
32
|
+
"kimi-k2.5": { "input": 0.6, "output": 2, "cache_read": 0.15 },
|
|
33
|
+
"kimi-k2.5-free": { "input": 0, "output": 0, "cache_read": 0 },
|
|
34
|
+
"kimi-k2.6": { "input": 0.95, "output": 4, "cache_read": 0.16 },
|
|
35
|
+
"glm-5.1": { "input": 1.4, "output": 4.4, "cache_read": 0.26 },
|
|
36
|
+
"glm-5": { "input": 1.0, "output": 3.2, "cache_read": 0.2 },
|
|
37
|
+
"glm-5-turbo": { "input": 1.2, "output": 4.0, "cache_read": 0.24 },
|
|
38
|
+
"glm-4.7": { "input": 0.6, "output": 2.2, "cache_read": 0.11 },
|
|
39
|
+
"glm-4.7-flashx": { "input": 0.07, "output": 0.4, "cache_read": 0.01 },
|
|
40
|
+
"glm-4.7-flash": { "input": 0, "output": 0, "cache_read": 0 },
|
|
41
|
+
"glm-4.6": { "input": 0.6, "output": 2.2, "cache_read": 0.11 },
|
|
42
|
+
"glm-4.5": { "input": 0.6, "output": 2.2, "cache_read": 0.11 },
|
|
43
|
+
"glm-4.5-x": { "input": 2.2, "output": 8.9, "cache_read": 0.45 },
|
|
44
|
+
"glm-4.5-air": { "input": 0.2, "output": 1.1, "cache_read": 0.03 },
|
|
45
|
+
"glm-4.5-airx": { "input": 1.1, "output": 4.5, "cache_read": 0.22 },
|
|
46
|
+
"glm-4.5-flash": { "input": 0, "output": 0, "cache_read": 0 },
|
|
47
|
+
"glm-4.7-free": { "input": 0, "output": 0, "cache_read": 0 },
|
|
48
|
+
"nemotron-3-super-free":{ "input": 0, "output": 0, "cache_read": 0 },
|
|
49
|
+
"mimo-v2-pro-free": { "input": 0, "output": 0, "cache_read": 0 },
|
|
50
|
+
"minimax-m2.1-free":{ "input": 0, "output": 0, "cache_read": 0 },
|
|
51
|
+
"MiniMax-M2.1": { "input": 0.5, "output": 3, "cache_read": 0.05 },
|
|
52
|
+
"antigravity-gpt-oss-120b": { "input": 2.5, "output": 10, "cache_read": 0, "note": "Antigravity bridge alias. Approximate with GPT-4o-class pricing until Google exposes route-specific billing metadata." }
|
|
53
|
+
},
|
|
54
|
+
"alias": {
|
|
55
|
+
"auto": "composer-1"
|
|
56
|
+
},
|
|
57
|
+
"fuzzy": [
|
|
58
|
+
{ "match": "kiro", "ref": "kiro-cli-agent" },
|
|
59
|
+
{ "match": "hy3", "ref": "hy3-preview-agent" },
|
|
60
|
+
{ "match": "composer", "ref": "composer-1" },
|
|
61
|
+
{ "match": "minimax-m2.7-highspeed", "ref": "MiniMax-M2.7-highspeed" },
|
|
62
|
+
{ "match": "minimax-m2.7", "ref": "MiniMax-M2.7" },
|
|
63
|
+
{ "match": "deepseek-v4-flash", "ref": "deepseek-v4-flash" },
|
|
64
|
+
{ "match": "deepseek-v4-pro", "ref": "deepseek-v4-pro" },
|
|
65
|
+
{ "match": "kimi", "ref": "kimi-k2.5" },
|
|
66
|
+
{ "match": "glm-4.5-airx", "ref": "glm-4.5-airx" },
|
|
67
|
+
{ "match": "glm-4.5-air", "ref": "glm-4.5-air" },
|
|
68
|
+
{ "match": "glm-4.5-x", "ref": "glm-4.5-x" },
|
|
69
|
+
{ "match": "glm-4.5-flash", "ref": "glm-4.5-flash" },
|
|
70
|
+
{ "match": "glm-4.5", "ref": "glm-4.5" },
|
|
71
|
+
{ "match": "glm-4.7-flashx", "ref": "glm-4.7-flashx" },
|
|
72
|
+
{ "match": "glm-4.7-flash", "ref": "glm-4.7-flash" },
|
|
73
|
+
{ "match": "glm-4.7", "ref": "glm-4.7" },
|
|
74
|
+
{ "match": "glm-4.6", "ref": "glm-4.6" },
|
|
75
|
+
{ "match": "glm-5-turbo", "ref": "glm-5-turbo" },
|
|
76
|
+
{ "match": "glm-5.1", "ref": "glm-5.1" },
|
|
77
|
+
{ "match": "glm-5", "ref": "glm-5" }
|
|
78
|
+
]
|
|
79
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
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, 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
|
+
}
|
|
93
|
+
const cacheKey = lookupSource ? `${lookupSource}\0${model}` : model;
|
|
94
|
+
if (state.negativeCache.has(cacheKey)) return ZERO_PRICING;
|
|
95
|
+
|
|
96
|
+
const result = lookupPricing(model, {
|
|
97
|
+
curated: curatedOverrides,
|
|
98
|
+
litellm: state.litellmPerMillionMap,
|
|
99
|
+
source: lookupSource,
|
|
100
|
+
});
|
|
101
|
+
if (result.hit) return result.value;
|
|
102
|
+
|
|
103
|
+
state.negativeCache.add(cacheKey);
|
|
104
|
+
return ZERO_PRICING;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Same formula and Codex/every-code reasoning-folding rule as the previous
|
|
108
|
+
// computeRowCost in src/lib/local-api.js. Moved here so vite mock + local
|
|
109
|
+
// server share one source of truth.
|
|
110
|
+
function computeRowCost(row) {
|
|
111
|
+
const pricing = getModelPricing(row.model, { source: row.source });
|
|
112
|
+
const reasoningIncludedInOutput = row.source === "codex" || row.source === "every-code";
|
|
113
|
+
const reasoningCost = reasoningIncludedInOutput
|
|
114
|
+
? 0
|
|
115
|
+
: (row.reasoning_output_tokens || 0) * (pricing.output || 0);
|
|
116
|
+
return (
|
|
117
|
+
((row.input_tokens || 0) * (pricing.input || 0) +
|
|
118
|
+
(row.output_tokens || 0) * (pricing.output || 0) +
|
|
119
|
+
(row.cached_input_tokens || 0) * (pricing.cache_read || 0) +
|
|
120
|
+
(row.cache_creation_input_tokens || 0) * (pricing.cache_write || 0) +
|
|
121
|
+
reasoningCost) /
|
|
122
|
+
1_000_000
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Backwards-compatible MODEL_PRICING export. Test at
|
|
127
|
+
// test/model-breakdown.test.js:236 reads `localApi.MODEL_PRICING["kiro-agent"]`
|
|
128
|
+
// and expects { input, output, cache_read, cache_write } shape. We expose the
|
|
129
|
+
// CURATED.exact map (which contains the kiro entries by design); LiteLLM
|
|
130
|
+
// entries are NOT included here because they're keyed dynamically and the old
|
|
131
|
+
// table was authoritative for what is now CURATED.
|
|
132
|
+
const MODEL_PRICING = curatedOverrides.exact;
|
|
133
|
+
|
|
134
|
+
module.exports = {
|
|
135
|
+
ensurePricingLoaded,
|
|
136
|
+
getModelPricing,
|
|
137
|
+
computeRowCost,
|
|
138
|
+
resetPricingForTests,
|
|
139
|
+
MODEL_PRICING,
|
|
140
|
+
ZERO_PRICING,
|
|
141
|
+
// Internal hooks for tests.
|
|
142
|
+
__getStateForTests: () => state,
|
|
143
|
+
};
|
|
@@ -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
|
+
};
|