@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.
Files changed (192) hide show
  1. package/LICENSE +21 -0
  2. package/README.ja.md +464 -0
  3. package/README.ko.md +464 -0
  4. package/README.md +476 -0
  5. package/README.zh-CN.md +469 -0
  6. package/bin/tracker.js +52 -0
  7. package/dashboard/dist/app-icon.png +0 -0
  8. package/dashboard/dist/apple-touch-icon.png +0 -0
  9. package/dashboard/dist/assets/ActivityHeatmap-BxBGHY1z.js +42 -0
  10. package/dashboard/dist/assets/Card-GROxUc_6.js +1 -0
  11. package/dashboard/dist/assets/DashboardPage-BZ5VFoBk.js +19 -0
  12. package/dashboard/dist/assets/DevicePage-CQts7ivB.js +1 -0
  13. package/dashboard/dist/assets/DialogTitle-DqFsk26U.js +1 -0
  14. package/dashboard/dist/assets/FadeIn-HBBXW_3q.js +1 -0
  15. package/dashboard/dist/assets/HeaderGithubStar-COD2HfaF.js +1 -0
  16. package/dashboard/dist/assets/IpCheckPage-Cn0l8-BQ.js +15 -0
  17. package/dashboard/dist/assets/LandingPage-CmV1BMU4.js +4356 -0
  18. package/dashboard/dist/assets/LeaderboardAvatar-CiIpWZwb.js +1 -0
  19. package/dashboard/dist/assets/LeaderboardPage-BU0mlR3g.js +6 -0
  20. package/dashboard/dist/assets/LeaderboardProfileModal-C49_3Xm9.js +72 -0
  21. package/dashboard/dist/assets/LeaderboardProfilePage-Bh1n0BAM.js +1 -0
  22. package/dashboard/dist/assets/LimitsPage-fZAmwLWT.js +2 -0
  23. package/dashboard/dist/assets/LocalOnlyNotice-D3rQdA-P.js +1 -0
  24. package/dashboard/dist/assets/LoginPage-enO4XP-c.js +1 -0
  25. package/dashboard/dist/assets/PopoverPopup-pitJEpN1.js +1 -0
  26. package/dashboard/dist/assets/Select-tT9x4ntD.js +1 -0
  27. package/dashboard/dist/assets/SelectItemText-C7F7eIm0.js +1 -0
  28. package/dashboard/dist/assets/SettingsPage-pDasZNU2.js +1 -0
  29. package/dashboard/dist/assets/SkillsPage-DEu7FY-2.js +1 -0
  30. package/dashboard/dist/assets/WidgetsPage-DGcO7rbp.js +1 -0
  31. package/dashboard/dist/assets/WrappedPage-D_1ID213.js +1 -0
  32. package/dashboard/dist/assets/agent-logos-DXQVg1xy.js +1 -0
  33. package/dashboard/dist/assets/arrow-up-right-DNLo92Ba.js +1 -0
  34. package/dashboard/dist/assets/download-DbBBBpJF.js +1 -0
  35. package/dashboard/dist/assets/geist-mono-cyrillic-400-normal-BPBWmzPh.woff +0 -0
  36. package/dashboard/dist/assets/geist-mono-cyrillic-400-normal-Ce5q_31Z.woff2 +0 -0
  37. package/dashboard/dist/assets/geist-mono-cyrillic-500-normal-CJBLNVQT.woff2 +0 -0
  38. package/dashboard/dist/assets/geist-mono-cyrillic-500-normal-mNhfPmgl.woff +0 -0
  39. package/dashboard/dist/assets/geist-mono-cyrillic-700-normal-DH5Q319x.woff +0 -0
  40. package/dashboard/dist/assets/geist-mono-cyrillic-700-normal-VCNRadI3.woff2 +0 -0
  41. package/dashboard/dist/assets/geist-mono-cyrillic-900-normal-BKC3PZkM.woff +0 -0
  42. package/dashboard/dist/assets/geist-mono-cyrillic-900-normal-DdrQ8KBw.woff2 +0 -0
  43. package/dashboard/dist/assets/geist-mono-latin-400-normal-CoULgQGM.woff +0 -0
  44. package/dashboard/dist/assets/geist-mono-latin-400-normal-LC9RFr9I.woff2 +0 -0
  45. package/dashboard/dist/assets/geist-mono-latin-500-normal-D3o2eNa9.woff2 +0 -0
  46. package/dashboard/dist/assets/geist-mono-latin-500-normal-DOxI7kZ4.woff +0 -0
  47. package/dashboard/dist/assets/geist-mono-latin-700-normal-D6izGJRP.woff2 +0 -0
  48. package/dashboard/dist/assets/geist-mono-latin-700-normal-QGw08Lff.woff +0 -0
  49. package/dashboard/dist/assets/geist-mono-latin-900-normal-CmoKXrdK.woff +0 -0
  50. package/dashboard/dist/assets/geist-mono-latin-900-normal-Cu5MFKsu.woff2 +0 -0
  51. package/dashboard/dist/assets/geist-mono-latin-ext-400-normal-Cgks_Qgx.woff2 +0 -0
  52. package/dashboard/dist/assets/geist-mono-latin-ext-400-normal-CxNRRMGd.woff +0 -0
  53. package/dashboard/dist/assets/geist-mono-latin-ext-500-normal-CQcGuCNt.woff2 +0 -0
  54. package/dashboard/dist/assets/geist-mono-latin-ext-500-normal-diTenJ8L.woff +0 -0
  55. package/dashboard/dist/assets/geist-mono-latin-ext-700-normal-BX9f1BHp.woff +0 -0
  56. package/dashboard/dist/assets/geist-mono-latin-ext-700-normal-YOllDaLV.woff2 +0 -0
  57. package/dashboard/dist/assets/geist-mono-latin-ext-900-normal-Br6WNUGb.woff +0 -0
  58. package/dashboard/dist/assets/geist-mono-latin-ext-900-normal-CrmBTzU2.woff2 +0 -0
  59. package/dashboard/dist/assets/index-BeoRn2gJ.js +2 -0
  60. package/dashboard/dist/assets/info-Bh5qTz6k.js +1 -0
  61. package/dashboard/dist/assets/main-BIY3Nj6I.js +969 -0
  62. package/dashboard/dist/assets/main-G_F9Iu_x.css +1 -0
  63. package/dashboard/dist/assets/use-limits-display-prefs-B4iJ-bBc.js +1 -0
  64. package/dashboard/dist/assets/use-native-settings-DHjPFDv8.js +1 -0
  65. package/dashboard/dist/assets/use-usage-limits-odqe9OY7.js +1 -0
  66. package/dashboard/dist/assets/useCurrency-BsSZiZMP.js +1 -0
  67. package/dashboard/dist/assets/useOpenInteractionType-BQhgq4rv.js +12 -0
  68. package/dashboard/dist/brand-logos/antigravity.svg +1 -0
  69. package/dashboard/dist/brand-logos/claude-code.svg +1 -0
  70. package/dashboard/dist/brand-logos/codebuddy.svg +1 -0
  71. package/dashboard/dist/brand-logos/codex.svg +15 -0
  72. package/dashboard/dist/brand-logos/copilot.svg +1 -0
  73. package/dashboard/dist/brand-logos/cursor.svg +1 -0
  74. package/dashboard/dist/brand-logos/gemini.svg +1 -0
  75. package/dashboard/dist/brand-logos/grok.svg +1 -0
  76. package/dashboard/dist/brand-logos/hermes.svg +1 -0
  77. package/dashboard/dist/brand-logos/kilo.svg +4 -0
  78. package/dashboard/dist/brand-logos/kimi.svg +1 -0
  79. package/dashboard/dist/brand-logos/kiro.svg +1 -0
  80. package/dashboard/dist/brand-logos/openclaw.svg +22 -0
  81. package/dashboard/dist/brand-logos/opencode.svg +3 -0
  82. package/dashboard/dist/clawd/happy.svg +161 -0
  83. package/dashboard/dist/clawd/idle/collapse.svg +101 -0
  84. package/dashboard/dist/clawd/idle/doze.svg +72 -0
  85. package/dashboard/dist/clawd/idle/follow.svg +64 -0
  86. package/dashboard/dist/clawd/idle/living.svg +196 -0
  87. package/dashboard/dist/clawd/idle/look.svg +115 -0
  88. package/dashboard/dist/clawd/idle/yawn.svg +158 -0
  89. package/dashboard/dist/clawd/mini/alert.svg +129 -0
  90. package/dashboard/dist/clawd/mini/crabwalk.svg +76 -0
  91. package/dashboard/dist/clawd/mini/enter-sleep.svg +65 -0
  92. package/dashboard/dist/clawd/mini/enter.svg +115 -0
  93. package/dashboard/dist/clawd/mini/happy.svg +124 -0
  94. package/dashboard/dist/clawd/mini/idle-tight.svg +15 -0
  95. package/dashboard/dist/clawd/mini/idle.svg +83 -0
  96. package/dashboard/dist/clawd/mini/peek.svg +82 -0
  97. package/dashboard/dist/clawd/mini/sleep.svg +112 -0
  98. package/dashboard/dist/clawd/react/double.svg +108 -0
  99. package/dashboard/dist/clawd/react/drag.svg +102 -0
  100. package/dashboard/dist/clawd/react/left.svg +102 -0
  101. package/dashboard/dist/clawd/react/right.svg +102 -0
  102. package/dashboard/dist/clawd/sleep/collapse-sleep.svg +247 -0
  103. package/dashboard/dist/clawd/sleep/sleeping.svg +118 -0
  104. package/dashboard/dist/clawd/sleep/wake.svg +277 -0
  105. package/dashboard/dist/clawd/static-base.svg +21 -0
  106. package/dashboard/dist/clawd/status/disconnected.svg +136 -0
  107. package/dashboard/dist/clawd/status/error.svg +94 -0
  108. package/dashboard/dist/clawd/status/notification.svg +134 -0
  109. package/dashboard/dist/clawd/working/building.svg +231 -0
  110. package/dashboard/dist/clawd/working/carrying.svg +107 -0
  111. package/dashboard/dist/clawd/working/conducting.svg +118 -0
  112. package/dashboard/dist/clawd/working/confused.svg +113 -0
  113. package/dashboard/dist/clawd/working/debugger.svg +91 -0
  114. package/dashboard/dist/clawd/working/juggling.svg +82 -0
  115. package/dashboard/dist/clawd/working/overheated.svg +75 -0
  116. package/dashboard/dist/clawd/working/pushing.svg +125 -0
  117. package/dashboard/dist/clawd/working/sweeping.svg +79 -0
  118. package/dashboard/dist/clawd/working/thinking.svg +119 -0
  119. package/dashboard/dist/clawd/working/typing.svg +153 -0
  120. package/dashboard/dist/clawd/working/ultrathink.svg +166 -0
  121. package/dashboard/dist/clawd/working/wizard.svg +98 -0
  122. package/dashboard/dist/dashboard-dark.png +0 -0
  123. package/dashboard/dist/favicon-16.png +0 -0
  124. package/dashboard/dist/favicon-32.png +0 -0
  125. package/dashboard/dist/favicon.ico +0 -0
  126. package/dashboard/dist/feed.xml +34 -0
  127. package/dashboard/dist/icon-192.png +0 -0
  128. package/dashboard/dist/icon-512.png +0 -0
  129. package/dashboard/dist/index.html +355 -0
  130. package/dashboard/dist/llms.txt +53 -0
  131. package/dashboard/dist/neon_bg.png +0 -0
  132. package/dashboard/dist/robots.txt +7 -0
  133. package/dashboard/dist/share.html +140 -0
  134. package/dashboard/dist/sitemap.xml +27 -0
  135. package/dashboard/dist/widgets-overview.png +0 -0
  136. package/package.json +78 -0
  137. package/scripts/install-local-service.sh +140 -0
  138. package/scripts/uninstall-local-service.sh +21 -0
  139. package/src/cli.js +95 -0
  140. package/src/commands/device-login.js +161 -0
  141. package/src/commands/diagnostics.js +38 -0
  142. package/src/commands/doctor.js +96 -0
  143. package/src/commands/init.js +1195 -0
  144. package/src/commands/serve.js +351 -0
  145. package/src/commands/status.js +609 -0
  146. package/src/commands/sync.js +2285 -0
  147. package/src/commands/uninstall.js +189 -0
  148. package/src/commands/wrapped.js +150 -0
  149. package/src/lib/antigravity-paths.js +48 -0
  150. package/src/lib/browser-auth.js +281 -0
  151. package/src/lib/categorizer-utils.js +232 -0
  152. package/src/lib/claude-categorizer.js +1319 -0
  153. package/src/lib/claude-config.js +204 -0
  154. package/src/lib/cli-ui.js +179 -0
  155. package/src/lib/codex-config.js +350 -0
  156. package/src/lib/codex-context-breakdown.js +400 -0
  157. package/src/lib/codex-rollout-parser.js +623 -0
  158. package/src/lib/codex-token-refresh.js +112 -0
  159. package/src/lib/cursor-config.js +425 -0
  160. package/src/lib/debug-flags.js +7 -0
  161. package/src/lib/diagnostics.js +272 -0
  162. package/src/lib/doctor.js +338 -0
  163. package/src/lib/fs.js +91 -0
  164. package/src/lib/gemini-config.js +288 -0
  165. package/src/lib/grok-hook.js +351 -0
  166. package/src/lib/init-flow.js +42 -0
  167. package/src/lib/local-api.js +1861 -0
  168. package/src/lib/openclaw-hook.js +420 -0
  169. package/src/lib/openclaw-session-plugin.js +520 -0
  170. package/src/lib/opencode-config.js +99 -0
  171. package/src/lib/passive-mode.js +185 -0
  172. package/src/lib/pricing/curated-overrides.json +79 -0
  173. package/src/lib/pricing/index.js +143 -0
  174. package/src/lib/pricing/litellm-fetcher.js +172 -0
  175. package/src/lib/pricing/matcher.js +255 -0
  176. package/src/lib/pricing/seed-snapshot.json +1 -0
  177. package/src/lib/progress.js +76 -0
  178. package/src/lib/project-usage-purge.js +106 -0
  179. package/src/lib/prompt.js +24 -0
  180. package/src/lib/proxy-env.js +131 -0
  181. package/src/lib/rollout.js +8442 -0
  182. package/src/lib/runtime-config.js +122 -0
  183. package/src/lib/skill-usage.js +267 -0
  184. package/src/lib/skills-manager.js +1127 -0
  185. package/src/lib/source-metadata.js +48 -0
  186. package/src/lib/sqlite-reader.js +136 -0
  187. package/src/lib/static-server.js +59 -0
  188. package/src/lib/subscriptions.js +453 -0
  189. package/src/lib/tracker-paths.js +16 -0
  190. package/src/lib/upload-throttle.js +148 -0
  191. package/src/lib/usage-limits.js +1806 -0
  192. 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
+ };