@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,1861 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const os = require("node:os");
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
const { spawn } = require("node:child_process");
|
|
5
|
+
const crypto = require("node:crypto");
|
|
6
|
+
const { DEFAULT_BASE_URL, resolveRuntimeConfig } = require("./runtime-config");
|
|
7
|
+
const {
|
|
8
|
+
filterRowsByUsageScope,
|
|
9
|
+
getSourceScope,
|
|
10
|
+
listExcludedSources,
|
|
11
|
+
normalizeUsageScope,
|
|
12
|
+
} = require("./source-metadata");
|
|
13
|
+
|
|
14
|
+
const SYNC_TIMEOUT_MS = 120_000;
|
|
15
|
+
const TRACKER_BIN = path.resolve(__dirname, "../../bin/tracker.js");
|
|
16
|
+
|
|
17
|
+
// Avatar proxy (see /api/avatar-proxy below). In-memory LRU; survives the
|
|
18
|
+
// CLI server lifetime, which is good enough — the dashboard reloads cheaply.
|
|
19
|
+
const AVATAR_PROXY_TTL_MS = 60 * 60 * 1000; // 1h
|
|
20
|
+
const AVATAR_PROXY_MAX_BYTES = 512 * 1024; // 512 KiB per image
|
|
21
|
+
const AVATAR_PROXY_MAX_ENTRIES = 64;
|
|
22
|
+
const avatarProxyCache = new Map();
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Per-model pricing — delegated to src/lib/pricing/
|
|
26
|
+
// - CURATED overrides (kiro-*, hy3-*, composer-*, kimi-for-coding, etc.)
|
|
27
|
+
// - LiteLLM live data (mainstream claude / gpt-5 / gemini), 24h disk-cached
|
|
28
|
+
// - Bundled seed snapshot for first-install / offline fallback
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
const {
|
|
32
|
+
MODEL_PRICING,
|
|
33
|
+
getModelPricing,
|
|
34
|
+
computeRowCost,
|
|
35
|
+
ensurePricingLoaded,
|
|
36
|
+
} = require("./pricing");
|
|
37
|
+
|
|
38
|
+
const {
|
|
39
|
+
computeClaudeCategoryBreakdown,
|
|
40
|
+
unsupportedSourcePayload: unsupportedCategoryPayload,
|
|
41
|
+
} = require("./claude-categorizer");
|
|
42
|
+
|
|
43
|
+
const { computeCodexContextBreakdown } = require("./codex-context-breakdown");
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Queue data helpers
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
function resolveQueuePath() {
|
|
50
|
+
const home = os.homedir();
|
|
51
|
+
return path.join(home, ".tokentracker", "tracker", "queue.jsonl");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readProjectQueueData(projectQueuePath) {
|
|
55
|
+
let raw;
|
|
56
|
+
try {
|
|
57
|
+
raw = fs.readFileSync(projectQueuePath, "utf8");
|
|
58
|
+
} catch (e) {
|
|
59
|
+
if (e?.code !== "ENOENT") {
|
|
60
|
+
console.error("[LocalAPI] readProjectQueueData: failed to read:", e?.message || e);
|
|
61
|
+
}
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
const lines = raw.split("\n").filter((l) => l.trim());
|
|
65
|
+
const seen = new Map();
|
|
66
|
+
for (const line of lines) {
|
|
67
|
+
try {
|
|
68
|
+
const row = JSON.parse(line);
|
|
69
|
+
const key = `${row.project_key || ""}|${row.source || ""}|${row.hour_start || ""}`;
|
|
70
|
+
seen.set(key, row);
|
|
71
|
+
} catch {
|
|
72
|
+
// skip malformed
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return Array.from(seen.values());
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isLegacyInclusiveCodexRow(row) {
|
|
79
|
+
if (!row || (row.source !== "codex" && row.source !== "every-code")) return false;
|
|
80
|
+
const inputTokens = Number(row.input_tokens || 0);
|
|
81
|
+
const cachedInputTokens = Number(row.cached_input_tokens || 0);
|
|
82
|
+
const outputTokens = Number(row.output_tokens || 0);
|
|
83
|
+
const totalTokens = Number(row.total_tokens || 0);
|
|
84
|
+
if (!Number.isFinite(inputTokens) || !Number.isFinite(cachedInputTokens)) return false;
|
|
85
|
+
if (cachedInputTokens <= 0 || inputTokens < cachedInputTokens) return false;
|
|
86
|
+
// Legacy Codex queue rows stored input inclusive of cache reads, while
|
|
87
|
+
// total_tokens remained input + output. Canonical rows keep input as pure
|
|
88
|
+
// non-cached input, so cache-heavy legacy rows can be identified by this
|
|
89
|
+
// exact invariant.
|
|
90
|
+
return totalTokens === inputTokens + outputTokens;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function normalizeQueueRow(row) {
|
|
94
|
+
let normalized = row;
|
|
95
|
+
if (isLegacyInclusiveCodexRow(normalized)) {
|
|
96
|
+
normalized = {
|
|
97
|
+
...normalized,
|
|
98
|
+
input_tokens:
|
|
99
|
+
Number(normalized.input_tokens || 0) - Number(normalized.cached_input_tokens || 0),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
// Legacy Cursor rows from versions ≤ 0.26.5 wrote billable_total_tokens = 0
|
|
103
|
+
// for "Included in Pro" / "Enterprise" / "no charge" records (kind-based
|
|
104
|
+
// gating in cursor-config.js#normalizeCursorUsage). The dashboard headline
|
|
105
|
+
// sums billable_total_tokens across sources, so those rows silently
|
|
106
|
+
// disappeared from the displayed total once any other source contributed
|
|
107
|
+
// non-zero billable usage (GitHub issue #106). Treat billing and usage as
|
|
108
|
+
// orthogonal: bump billable up to total_tokens at read time so historical
|
|
109
|
+
// queue.jsonl entries render correctly without requiring a file rewrite.
|
|
110
|
+
const sourceName = String(normalized.source || "").toLowerCase();
|
|
111
|
+
if (sourceName === "cursor") {
|
|
112
|
+
const totalTokens = Number(normalized.total_tokens || 0);
|
|
113
|
+
const billable = Number(normalized.billable_total_tokens || 0);
|
|
114
|
+
if (totalTokens > 0 && billable < totalTokens) {
|
|
115
|
+
normalized = { ...normalized, billable_total_tokens: totalTokens };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return normalized;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function readQueueData(queuePath) {
|
|
122
|
+
let raw;
|
|
123
|
+
try {
|
|
124
|
+
raw = fs.readFileSync(queuePath, "utf8");
|
|
125
|
+
} catch (e) {
|
|
126
|
+
// ENOENT is legitimate (never synced yet); anything else is a signal we
|
|
127
|
+
// don't want to hide behind an empty array forever — the dashboard would
|
|
128
|
+
// otherwise render "0 tokens" with no clue the queue was unreadable.
|
|
129
|
+
if (e?.code !== "ENOENT") {
|
|
130
|
+
console.error("[LocalAPI] readQueueData: failed to read queue:", e?.message || e);
|
|
131
|
+
}
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
const lines = raw.split("\n").filter((l) => l.trim());
|
|
135
|
+
// Parse row-by-row so a single corrupted line (partial write, disk-full
|
|
136
|
+
// truncation, …) does not wipe out every other row with it.
|
|
137
|
+
const parsed = [];
|
|
138
|
+
let malformed = 0;
|
|
139
|
+
for (const line of lines) {
|
|
140
|
+
try {
|
|
141
|
+
parsed.push(JSON.parse(line));
|
|
142
|
+
} catch {
|
|
143
|
+
malformed += 1;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (malformed > 0) {
|
|
147
|
+
console.error(
|
|
148
|
+
`[LocalAPI] readQueueData: skipped ${malformed}/${lines.length} malformed line(s) in ${queuePath}`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
// Deduplicate: each sync appends cumulative totals per bucket, so for
|
|
152
|
+
// each (source, model, hour_start) keep only the latest (last) entry.
|
|
153
|
+
const seen = new Map();
|
|
154
|
+
for (const row of parsed) {
|
|
155
|
+
const key = `${row.source || ""}|${row.model || ""}|${row.hour_start || ""}`;
|
|
156
|
+
seen.set(key, normalizeQueueRow(row));
|
|
157
|
+
}
|
|
158
|
+
return Array.from(seen.values());
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function rowDayKey(row, timeZoneContext) {
|
|
162
|
+
const hs = row.hour_start;
|
|
163
|
+
if (!hs) return "";
|
|
164
|
+
if (
|
|
165
|
+
timeZoneContext &&
|
|
166
|
+
(timeZoneContext.timeZone || Number.isFinite(timeZoneContext.offsetMinutes))
|
|
167
|
+
) {
|
|
168
|
+
const parts = getZonedParts(new Date(hs), timeZoneContext);
|
|
169
|
+
const key = formatPartsDayKey(parts);
|
|
170
|
+
if (key) return key;
|
|
171
|
+
}
|
|
172
|
+
return hs.slice(0, 10);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function aggregateByDay(rows, timeZoneContext = null) {
|
|
176
|
+
const byDay = new Map();
|
|
177
|
+
for (const row of rows) {
|
|
178
|
+
if (!row.hour_start) continue;
|
|
179
|
+
const day = rowDayKey(row, timeZoneContext);
|
|
180
|
+
if (!day) continue;
|
|
181
|
+
if (!byDay.has(day)) {
|
|
182
|
+
byDay.set(day, {
|
|
183
|
+
day,
|
|
184
|
+
total_tokens: 0,
|
|
185
|
+
billable_total_tokens: 0,
|
|
186
|
+
total_cost_usd: 0,
|
|
187
|
+
input_tokens: 0,
|
|
188
|
+
output_tokens: 0,
|
|
189
|
+
cached_input_tokens: 0,
|
|
190
|
+
cache_creation_input_tokens: 0,
|
|
191
|
+
reasoning_output_tokens: 0,
|
|
192
|
+
conversation_count: 0,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
const a = byDay.get(day);
|
|
196
|
+
a.total_tokens += row.total_tokens || 0;
|
|
197
|
+
a.billable_total_tokens += row.billable_total_tokens ?? row.total_tokens ?? 0;
|
|
198
|
+
a.total_cost_usd += computeRowCost(row);
|
|
199
|
+
a.input_tokens += row.input_tokens || 0;
|
|
200
|
+
a.output_tokens += row.output_tokens || 0;
|
|
201
|
+
a.cached_input_tokens += row.cached_input_tokens || 0;
|
|
202
|
+
a.cache_creation_input_tokens += row.cache_creation_input_tokens || 0;
|
|
203
|
+
a.reasoning_output_tokens += row.reasoning_output_tokens || 0;
|
|
204
|
+
a.conversation_count += row.conversation_count || 0;
|
|
205
|
+
|
|
206
|
+
if (!a.models) {
|
|
207
|
+
a.models = {};
|
|
208
|
+
}
|
|
209
|
+
const model = row.model || "unknown";
|
|
210
|
+
a.models[model] = (a.models[model] || 0) + (row.total_tokens || 0);
|
|
211
|
+
}
|
|
212
|
+
return Array.from(byDay.values()).sort((a, b) => a.day.localeCompare(b.day));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function buildCodexCategoryFallbackFromQueue(queueRows, { from, to, timeZoneContext }) {
|
|
216
|
+
const totals = {
|
|
217
|
+
input_tokens: 0,
|
|
218
|
+
cached_input_tokens: 0,
|
|
219
|
+
cache_creation_input_tokens: 0,
|
|
220
|
+
output_tokens: 0,
|
|
221
|
+
reasoning_output_tokens: 0,
|
|
222
|
+
total_tokens: 0,
|
|
223
|
+
};
|
|
224
|
+
let conversationCount = 0;
|
|
225
|
+
|
|
226
|
+
for (const row of queueRows || []) {
|
|
227
|
+
if ((row?.source || "") !== "codex") continue;
|
|
228
|
+
if (!row.hour_start) continue;
|
|
229
|
+
const day = rowDayKey(row, timeZoneContext);
|
|
230
|
+
if (from && day < from) continue;
|
|
231
|
+
if (to && day > to) continue;
|
|
232
|
+
totals.input_tokens += Number(row.input_tokens || 0);
|
|
233
|
+
totals.cached_input_tokens += Number(row.cached_input_tokens || 0);
|
|
234
|
+
totals.cache_creation_input_tokens += Number(row.cache_creation_input_tokens || 0);
|
|
235
|
+
totals.output_tokens += Number(row.output_tokens || 0);
|
|
236
|
+
totals.reasoning_output_tokens += Number(row.reasoning_output_tokens || 0);
|
|
237
|
+
totals.total_tokens += Number(row.total_tokens || 0);
|
|
238
|
+
conversationCount += Number(row.conversation_count || 0);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
source: "codex",
|
|
243
|
+
scope: "supported",
|
|
244
|
+
breakdown_status: "queue_fallback",
|
|
245
|
+
totals,
|
|
246
|
+
session_count: 0,
|
|
247
|
+
message_count: conversationCount,
|
|
248
|
+
fallback: "queue_totals",
|
|
249
|
+
message_breakdown: {
|
|
250
|
+
categories: [
|
|
251
|
+
{
|
|
252
|
+
key: "user_input",
|
|
253
|
+
name: "User input",
|
|
254
|
+
totals: {
|
|
255
|
+
input_tokens: totals.input_tokens,
|
|
256
|
+
cached_input_tokens: 0,
|
|
257
|
+
cache_creation_input_tokens: 0,
|
|
258
|
+
output_tokens: 0,
|
|
259
|
+
reasoning_output_tokens: 0,
|
|
260
|
+
total_tokens: totals.input_tokens,
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
key: "conversation_history",
|
|
265
|
+
name: "Conversation history",
|
|
266
|
+
totals: {
|
|
267
|
+
input_tokens: 0,
|
|
268
|
+
cached_input_tokens: totals.cached_input_tokens,
|
|
269
|
+
cache_creation_input_tokens: totals.cache_creation_input_tokens,
|
|
270
|
+
output_tokens: 0,
|
|
271
|
+
reasoning_output_tokens: 0,
|
|
272
|
+
total_tokens: totals.cached_input_tokens + totals.cache_creation_input_tokens,
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
key: "assistant_response",
|
|
277
|
+
name: "Assistant response",
|
|
278
|
+
totals: {
|
|
279
|
+
input_tokens: 0,
|
|
280
|
+
cached_input_tokens: 0,
|
|
281
|
+
cache_creation_input_tokens: 0,
|
|
282
|
+
output_tokens: Math.max(0, totals.output_tokens - totals.reasoning_output_tokens),
|
|
283
|
+
reasoning_output_tokens: 0,
|
|
284
|
+
total_tokens: Math.max(0, totals.output_tokens - totals.reasoning_output_tokens),
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
].sort((a, b) => Number(b.totals.total_tokens || 0) - Number(a.totals.total_tokens || 0)),
|
|
288
|
+
privacy: {
|
|
289
|
+
includes_content: false,
|
|
290
|
+
note: "Queue fallback includes aggregated token categories only; message text is never returned.",
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
tool_calls_breakdown: {
|
|
294
|
+
total_calls: 0,
|
|
295
|
+
tools: [],
|
|
296
|
+
categories: [],
|
|
297
|
+
tools_total: 0,
|
|
298
|
+
privacy: {
|
|
299
|
+
includes_inputs: false,
|
|
300
|
+
note: "Codex rollout sessions were unavailable; totals come from TokenTracker queue rows.",
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
exec_command_breakdown: {
|
|
304
|
+
by_type: [],
|
|
305
|
+
by_exit: [],
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function getRequestedUsageScope(url) {
|
|
311
|
+
if (url.searchParams.get("include_account_level") === "1") return "all";
|
|
312
|
+
return normalizeUsageScope(url.searchParams.get("scope"));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function scopedQueueRows(queuePath, url) {
|
|
316
|
+
const scope = getRequestedUsageScope(url);
|
|
317
|
+
const allRows = readQueueData(queuePath);
|
|
318
|
+
return {
|
|
319
|
+
scope,
|
|
320
|
+
allRows,
|
|
321
|
+
rows: filterRowsByUsageScope(allRows, scope),
|
|
322
|
+
excludedSources: listExcludedSources(allRows, scope),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function getTimeZoneContext(url) {
|
|
327
|
+
const tz = String(url.searchParams.get("tz") || "").trim();
|
|
328
|
+
const rawOffset = Number(url.searchParams.get("tz_offset_minutes"));
|
|
329
|
+
return {
|
|
330
|
+
timeZone: tz || null,
|
|
331
|
+
offsetMinutes: Number.isFinite(rawOffset) ? Math.trunc(rawOffset) : null,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function getZonedParts(date, { timeZone, offsetMinutes } = {}) {
|
|
336
|
+
const dt = date instanceof Date ? date : new Date(date);
|
|
337
|
+
if (!Number.isFinite(dt.getTime())) return null;
|
|
338
|
+
|
|
339
|
+
if (timeZone && typeof Intl !== "undefined" && Intl.DateTimeFormat) {
|
|
340
|
+
try {
|
|
341
|
+
const formatter = new Intl.DateTimeFormat("en-CA", {
|
|
342
|
+
timeZone,
|
|
343
|
+
year: "numeric",
|
|
344
|
+
month: "2-digit",
|
|
345
|
+
day: "2-digit",
|
|
346
|
+
hour: "2-digit",
|
|
347
|
+
minute: "2-digit",
|
|
348
|
+
second: "2-digit",
|
|
349
|
+
hourCycle: "h23",
|
|
350
|
+
});
|
|
351
|
+
const parts = formatter.formatToParts(dt);
|
|
352
|
+
const values = parts.reduce((acc, part) => {
|
|
353
|
+
if (part.type && part.value) acc[part.type] = part.value;
|
|
354
|
+
return acc;
|
|
355
|
+
}, {});
|
|
356
|
+
const year = Number(values.year);
|
|
357
|
+
const month = Number(values.month);
|
|
358
|
+
const day = Number(values.day);
|
|
359
|
+
const hour = Number(values.hour);
|
|
360
|
+
const minute = Number(values.minute);
|
|
361
|
+
const second = Number(values.second);
|
|
362
|
+
if ([year, month, day, hour, minute, second].every(Number.isFinite)) {
|
|
363
|
+
return { year, month, day, hour, minute, second };
|
|
364
|
+
}
|
|
365
|
+
} catch (_e) {
|
|
366
|
+
// fall through
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (Number.isFinite(offsetMinutes)) {
|
|
371
|
+
const shifted = new Date(dt.getTime() + offsetMinutes * 60 * 1000);
|
|
372
|
+
return {
|
|
373
|
+
year: shifted.getUTCFullYear(),
|
|
374
|
+
month: shifted.getUTCMonth() + 1,
|
|
375
|
+
day: shifted.getUTCDate(),
|
|
376
|
+
hour: shifted.getUTCHours(),
|
|
377
|
+
minute: shifted.getUTCMinutes(),
|
|
378
|
+
second: shifted.getUTCSeconds(),
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
year: dt.getFullYear(),
|
|
384
|
+
month: dt.getMonth() + 1,
|
|
385
|
+
day: dt.getDate(),
|
|
386
|
+
hour: dt.getHours(),
|
|
387
|
+
minute: dt.getMinutes(),
|
|
388
|
+
second: dt.getSeconds(),
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function formatPartsDayKey(parts) {
|
|
393
|
+
if (!parts) return "";
|
|
394
|
+
return `${parts.year}-${String(parts.month).padStart(2, "0")}-${String(parts.day).padStart(2, "0")}`;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function aggregateHourlyByDay(rows, dayKey, timeZoneContext) {
|
|
398
|
+
const byHour = new Map();
|
|
399
|
+
for (const row of rows) {
|
|
400
|
+
if (!row.hour_start) continue;
|
|
401
|
+
const parts = getZonedParts(new Date(row.hour_start), timeZoneContext);
|
|
402
|
+
if (!parts) continue;
|
|
403
|
+
if (formatPartsDayKey(parts) !== dayKey) continue;
|
|
404
|
+
const hourKey = `${dayKey}T${String(parts.hour).padStart(2, "0")}:00:00`;
|
|
405
|
+
if (!byHour.has(hourKey)) {
|
|
406
|
+
byHour.set(hourKey, {
|
|
407
|
+
hour: hourKey,
|
|
408
|
+
total_tokens: 0,
|
|
409
|
+
billable_total_tokens: 0,
|
|
410
|
+
input_tokens: 0,
|
|
411
|
+
output_tokens: 0,
|
|
412
|
+
cached_input_tokens: 0,
|
|
413
|
+
cache_creation_input_tokens: 0,
|
|
414
|
+
reasoning_output_tokens: 0,
|
|
415
|
+
conversation_count: 0,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
const bucket = byHour.get(hourKey);
|
|
419
|
+
bucket.total_tokens += row.total_tokens || 0;
|
|
420
|
+
bucket.billable_total_tokens += row.total_tokens || 0;
|
|
421
|
+
bucket.input_tokens += row.input_tokens || 0;
|
|
422
|
+
bucket.output_tokens += row.output_tokens || 0;
|
|
423
|
+
bucket.cached_input_tokens += row.cached_input_tokens || 0;
|
|
424
|
+
bucket.cache_creation_input_tokens += row.cache_creation_input_tokens || 0;
|
|
425
|
+
bucket.reasoning_output_tokens += row.reasoning_output_tokens || 0;
|
|
426
|
+
bucket.conversation_count += row.conversation_count || 0;
|
|
427
|
+
|
|
428
|
+
if (!bucket.models) {
|
|
429
|
+
bucket.models = {};
|
|
430
|
+
}
|
|
431
|
+
const model = row.model || "unknown";
|
|
432
|
+
bucket.models[model] = (bucket.models[model] || 0) + (row.total_tokens || 0);
|
|
433
|
+
}
|
|
434
|
+
return Array.from(byHour.values()).sort((a, b) => a.hour.localeCompare(b.hour));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
// Sync helper
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
|
|
441
|
+
function trimOutput(value, max = 4000) {
|
|
442
|
+
const t = String(value || "");
|
|
443
|
+
return t.length <= max ? t : t.slice(t.length - max);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function normalizeRemoteHttpBaseUrl(value) {
|
|
447
|
+
if (typeof value !== "string") return null;
|
|
448
|
+
const trimmed = value.trim();
|
|
449
|
+
if (!trimmed) return null;
|
|
450
|
+
try {
|
|
451
|
+
const url = new URL(trimmed);
|
|
452
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
453
|
+
url.username = "";
|
|
454
|
+
url.password = "";
|
|
455
|
+
url.hash = "";
|
|
456
|
+
return url.toString().replace(/\/$/, "");
|
|
457
|
+
} catch (_e) {
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function resolveAllowedInsforgeBaseUrl(value) {
|
|
463
|
+
const requested = normalizeRemoteHttpBaseUrl(value);
|
|
464
|
+
if (!requested) return null;
|
|
465
|
+
|
|
466
|
+
const runtime = resolveRuntimeConfig();
|
|
467
|
+
const allowed = new Set(
|
|
468
|
+
[runtime.baseUrl, DEFAULT_BASE_URL]
|
|
469
|
+
.map((entry) => normalizeRemoteHttpBaseUrl(entry))
|
|
470
|
+
.filter(Boolean),
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
return allowed.has(requested) ? requested : null;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function parseCookieHeader(value) {
|
|
477
|
+
const out = new Map();
|
|
478
|
+
if (typeof value !== "string" || !value.trim()) return out;
|
|
479
|
+
for (const part of value.split(";")) {
|
|
480
|
+
const idx = part.indexOf("=");
|
|
481
|
+
if (idx < 1) continue;
|
|
482
|
+
const key = part.slice(0, idx).trim();
|
|
483
|
+
const rawValue = part.slice(idx + 1).trim();
|
|
484
|
+
if (key) out.set(key, rawValue);
|
|
485
|
+
}
|
|
486
|
+
return out;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function isLoopbackHostname(hostname) {
|
|
490
|
+
return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1" || hostname === "[::1]";
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function hasAllowedLoopbackOrigin(headers = {}) {
|
|
494
|
+
const candidates = [headers.origin, headers.referer];
|
|
495
|
+
for (const raw of candidates) {
|
|
496
|
+
if (raw == null || raw === "") continue;
|
|
497
|
+
try {
|
|
498
|
+
const url = new URL(String(raw));
|
|
499
|
+
if (url.protocol !== "http:" || !isLoopbackHostname(url.hostname)) return false;
|
|
500
|
+
} catch (_e) {
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return true;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function readJsonBody(req) {
|
|
508
|
+
return new Promise((resolve, reject) => {
|
|
509
|
+
const chunks = [];
|
|
510
|
+
req.on("data", (c) => chunks.push(c));
|
|
511
|
+
req.on("end", () => {
|
|
512
|
+
try {
|
|
513
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
514
|
+
if (!raw.trim()) return resolve({});
|
|
515
|
+
resolve(JSON.parse(raw));
|
|
516
|
+
} catch (e) {
|
|
517
|
+
reject(e);
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
req.on("error", reject);
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function runSyncCommand(extraEnv = {}) {
|
|
525
|
+
return new Promise((resolve, reject) => {
|
|
526
|
+
const child = spawn(process.execPath, [TRACKER_BIN, "sync"], {
|
|
527
|
+
env: { ...process.env, ...extraEnv },
|
|
528
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
529
|
+
});
|
|
530
|
+
let stdout = "";
|
|
531
|
+
let stderr = "";
|
|
532
|
+
let settled = false;
|
|
533
|
+
const finish = (fn, v) => {
|
|
534
|
+
if (settled) return;
|
|
535
|
+
settled = true;
|
|
536
|
+
clearTimeout(tid);
|
|
537
|
+
fn(v);
|
|
538
|
+
};
|
|
539
|
+
const tid = setTimeout(() => {
|
|
540
|
+
child.kill("SIGTERM");
|
|
541
|
+
finish(
|
|
542
|
+
reject,
|
|
543
|
+
Object.assign(new Error("Sync timed out"), {
|
|
544
|
+
code: "SYNC_TIMEOUT",
|
|
545
|
+
stdout: trimOutput(stdout),
|
|
546
|
+
stderr: trimOutput(stderr),
|
|
547
|
+
}),
|
|
548
|
+
);
|
|
549
|
+
}, SYNC_TIMEOUT_MS);
|
|
550
|
+
child.stdout?.on("data", (c) => {
|
|
551
|
+
stdout += c;
|
|
552
|
+
});
|
|
553
|
+
child.stderr?.on("data", (c) => {
|
|
554
|
+
stderr += c;
|
|
555
|
+
});
|
|
556
|
+
child.on("error", (e) => {
|
|
557
|
+
finish(reject, Object.assign(e, { stdout: trimOutput(stdout), stderr: trimOutput(stderr) }));
|
|
558
|
+
});
|
|
559
|
+
child.on("close", (code) => {
|
|
560
|
+
const r = { code: code ?? 1, stdout: trimOutput(stdout), stderr: trimOutput(stderr) };
|
|
561
|
+
code === 0
|
|
562
|
+
? finish(resolve, r)
|
|
563
|
+
: finish(reject, Object.assign(new Error(r.stderr || r.stdout || `exit ${r.code}`), r));
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// ---------------------------------------------------------------------------
|
|
569
|
+
// Project detection helpers
|
|
570
|
+
// ---------------------------------------------------------------------------
|
|
571
|
+
|
|
572
|
+
function parseGitUrl(url) {
|
|
573
|
+
if (!url) return null;
|
|
574
|
+
const ssh = url.match(/git@[^:]+:([^/]+)\/(.+?)(?:\.git)?$/);
|
|
575
|
+
if (ssh) return { owner: ssh[1], repo: ssh[2] };
|
|
576
|
+
const http = url.match(/https?:\/\/[^/]+\/([^/]+)\/(.+?)(?:\.git)?$/);
|
|
577
|
+
if (http) return { owner: http[1], repo: http[2] };
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function extractProjectFromCwd(cwd) {
|
|
582
|
+
const home = os.homedir();
|
|
583
|
+
if (!cwd || cwd === home) return null;
|
|
584
|
+
const rel = cwd.replace(home + "/", "");
|
|
585
|
+
const parts = rel.split("/").filter((p) => p && !p.startsWith(".") && p !== "ext-global");
|
|
586
|
+
return parts.length > 0 ? parts[0] : null;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function scanCodexProjects(projectMap) {
|
|
590
|
+
const dir = path.join(os.homedir(), ".codex", "sessions");
|
|
591
|
+
try {
|
|
592
|
+
for (const year of fs.readdirSync(dir)) {
|
|
593
|
+
const yp = path.join(dir, year);
|
|
594
|
+
if (!fs.statSync(yp).isDirectory()) continue;
|
|
595
|
+
for (const month of fs.readdirSync(yp)) {
|
|
596
|
+
const mp = path.join(yp, month);
|
|
597
|
+
if (!fs.statSync(mp).isDirectory()) continue;
|
|
598
|
+
for (const day of fs.readdirSync(mp)) {
|
|
599
|
+
const dp = path.join(mp, day);
|
|
600
|
+
if (!fs.statSync(dp).isDirectory()) continue;
|
|
601
|
+
const files = fs.readdirSync(dp).filter((f) => f.endsWith(".jsonl"));
|
|
602
|
+
for (const file of files.slice(0, 200)) {
|
|
603
|
+
try {
|
|
604
|
+
const first = fs.readFileSync(path.join(dp, file), "utf8").split("\n")[0];
|
|
605
|
+
const d = JSON.parse(first);
|
|
606
|
+
if (d.git?.repository_url) {
|
|
607
|
+
const p = parseGitUrl(d.git.repository_url);
|
|
608
|
+
if (p) {
|
|
609
|
+
const key = `${p.owner}/${p.repo}`;
|
|
610
|
+
if (!projectMap.has(key))
|
|
611
|
+
projectMap.set(key, {
|
|
612
|
+
project_key: key,
|
|
613
|
+
project_ref: d.git.repository_url,
|
|
614
|
+
count: 0,
|
|
615
|
+
});
|
|
616
|
+
projectMap.get(key).count++;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
} catch (_e) {}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
} catch (_e) {}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function findSubagentsDirs(dir, depth) {
|
|
628
|
+
const out = [];
|
|
629
|
+
if (depth > 3) return out;
|
|
630
|
+
try {
|
|
631
|
+
for (const item of fs.readdirSync(dir)) {
|
|
632
|
+
const fp = path.join(dir, item);
|
|
633
|
+
if (!fs.statSync(fp).isDirectory()) continue;
|
|
634
|
+
if (item === "subagents") out.push(fp);
|
|
635
|
+
else out.push(...findSubagentsDirs(fp, depth + 1));
|
|
636
|
+
}
|
|
637
|
+
} catch (_e) {}
|
|
638
|
+
return out;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function scanClaudeProjects(projectMap) {
|
|
642
|
+
const dir = path.join(os.homedir(), ".claude", "projects");
|
|
643
|
+
try {
|
|
644
|
+
for (const subDir of findSubagentsDirs(dir, 0)) {
|
|
645
|
+
const files = fs.readdirSync(subDir).filter((f) => f.endsWith(".jsonl"));
|
|
646
|
+
for (const file of files.slice(0, 100)) {
|
|
647
|
+
try {
|
|
648
|
+
const first = fs.readFileSync(path.join(subDir, file), "utf8").split("\n")[0];
|
|
649
|
+
if (!first) continue;
|
|
650
|
+
const d = JSON.parse(first);
|
|
651
|
+
const name = extractProjectFromCwd(d.cwd);
|
|
652
|
+
if (name) {
|
|
653
|
+
if (!projectMap.has(name))
|
|
654
|
+
projectMap.set(name, {
|
|
655
|
+
project_key: name,
|
|
656
|
+
project_ref: `file://${d.cwd}`,
|
|
657
|
+
count: 0,
|
|
658
|
+
});
|
|
659
|
+
projectMap.get(name).count++;
|
|
660
|
+
}
|
|
661
|
+
} catch (_e) {}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
} catch (_e) {}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// ---------------------------------------------------------------------------
|
|
668
|
+
// JSON response helper
|
|
669
|
+
// ---------------------------------------------------------------------------
|
|
670
|
+
|
|
671
|
+
function json(res, data, status) {
|
|
672
|
+
res.writeHead(status || 200, { "Content-Type": "application/json" });
|
|
673
|
+
res.end(JSON.stringify(data));
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// ---------------------------------------------------------------------------
|
|
677
|
+
// IP check API proxy: dashboard/src/pages/IpCheckPage.jsx is a native React
|
|
678
|
+
// page that calls ip.net.coffee's data endpoints (/api/iprisk, /api/geoip,
|
|
679
|
+
// /api/dns/result, /favicons, /claude/status.json). Browser-side fetch can't
|
|
680
|
+
// hit them cross-origin from the dashboard, so we reverse-proxy /proxy/ipcheck/*
|
|
681
|
+
// to https://ip.net.coffee/* and strip embedding-hostile headers.
|
|
682
|
+
// (Previously this proxy also served the upstream HTML page for an iframe;
|
|
683
|
+
// the iframe and its HTML-rewrite path have been removed.)
|
|
684
|
+
// ---------------------------------------------------------------------------
|
|
685
|
+
|
|
686
|
+
const IP_CHECK_PROXY_PREFIX = "/proxy/ipcheck";
|
|
687
|
+
const IP_CHECK_TARGET = "https://ip.net.coffee";
|
|
688
|
+
|
|
689
|
+
// HTTP hop-by-hop headers (RFC 7230 §6.1) plus headers undici/fetch manages
|
|
690
|
+
// internally. Forwarding any of these to `fetch(...)` either silently breaks
|
|
691
|
+
// the request (host being wrong) or, on stricter undici versions like the
|
|
692
|
+
// 6.24.1 shipped with Node 22.22.2, throws UND_ERR_INVALID_ARG and turns
|
|
693
|
+
// every proxied POST into a 502. Keep this set authoritative for every
|
|
694
|
+
// reverse-proxy site in this module.
|
|
695
|
+
const HOP_BY_HOP_HEADERS = new Set([
|
|
696
|
+
"host",
|
|
697
|
+
"connection",
|
|
698
|
+
"keep-alive",
|
|
699
|
+
"content-length",
|
|
700
|
+
"transfer-encoding",
|
|
701
|
+
"upgrade",
|
|
702
|
+
"proxy-authorization",
|
|
703
|
+
"proxy-authenticate",
|
|
704
|
+
"proxy-connection",
|
|
705
|
+
"te",
|
|
706
|
+
"trailer",
|
|
707
|
+
"trailers",
|
|
708
|
+
]);
|
|
709
|
+
|
|
710
|
+
// Strip forbidden + hop-by-hop headers when forwarding an inbound request to
|
|
711
|
+
// fetch(). Honours the Connection header's named-headers list (RFC 7230 §6.1)
|
|
712
|
+
// so values like `Connection: keep-alive, x-custom` also drop x-custom.
|
|
713
|
+
function buildProxyHeaders(headers) {
|
|
714
|
+
const entries =
|
|
715
|
+
headers && typeof headers.entries === "function"
|
|
716
|
+
? Array.from(headers.entries())
|
|
717
|
+
: Object.entries(headers || {});
|
|
718
|
+
|
|
719
|
+
const connectionNamed = new Set();
|
|
720
|
+
const normalized = [];
|
|
721
|
+
for (const [rawKey, rawValue] of entries) {
|
|
722
|
+
if (rawValue == null) continue;
|
|
723
|
+
const key = String(rawKey).toLowerCase();
|
|
724
|
+
normalized.push([key, rawValue]);
|
|
725
|
+
if (key === "connection") {
|
|
726
|
+
const values = Array.isArray(rawValue) ? rawValue : [rawValue];
|
|
727
|
+
for (const v of values) {
|
|
728
|
+
String(v)
|
|
729
|
+
.split(",")
|
|
730
|
+
.map((part) => part.trim().toLowerCase())
|
|
731
|
+
.filter(Boolean)
|
|
732
|
+
.forEach((part) => connectionNamed.add(part));
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const out = {};
|
|
738
|
+
for (const [key, rawValue] of normalized) {
|
|
739
|
+
if (HOP_BY_HOP_HEADERS.has(key) || connectionNamed.has(key)) continue;
|
|
740
|
+
if (Array.isArray(rawValue)) {
|
|
741
|
+
const joined = rawValue.filter((e) => e != null).map(String).join(", ");
|
|
742
|
+
if (joined) out[key] = joined;
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
out[key] = String(rawValue);
|
|
746
|
+
}
|
|
747
|
+
return out;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// ---------------------------------------------------------------------------
|
|
751
|
+
// Main handler factory
|
|
752
|
+
// ---------------------------------------------------------------------------
|
|
753
|
+
|
|
754
|
+
function createLocalApiHandler({ queuePath }) {
|
|
755
|
+
const qp = queuePath || resolveQueuePath();
|
|
756
|
+
|
|
757
|
+
// Server-side cookie relay: captures auth cookies from InsForge cloud responses
|
|
758
|
+
// so that both browser and WKWebView share the same login session via the proxy.
|
|
759
|
+
// Persisted to disk so cookies survive server restarts.
|
|
760
|
+
const csrfRelayCookieName = "insforge_csrf_token";
|
|
761
|
+
let relayCookies = new Map();
|
|
762
|
+
const localAuthToken = crypto.randomBytes(24).toString("hex");
|
|
763
|
+
const trackerDataDir = path.join(os.homedir(), ".tokentracker", "tracker");
|
|
764
|
+
const cookiePath = path.join(trackerDataDir, "relay-cookies.json");
|
|
765
|
+
|
|
766
|
+
// Load persisted cookies on startup
|
|
767
|
+
try {
|
|
768
|
+
if (!fs.existsSync(trackerDataDir)) fs.mkdirSync(trackerDataDir, { recursive: true });
|
|
769
|
+
if (fs.existsSync(cookiePath)) {
|
|
770
|
+
const content = fs.readFileSync(cookiePath, "utf8");
|
|
771
|
+
const saved = JSON.parse(content);
|
|
772
|
+
if (saved && typeof saved === "object") {
|
|
773
|
+
let count = 0;
|
|
774
|
+
for (const [k, v] of Object.entries(saved)) {
|
|
775
|
+
relayCookies.set(k, v);
|
|
776
|
+
count++;
|
|
777
|
+
}
|
|
778
|
+
if (count > 0) console.log(`[LocalAPI] Loaded ${count} relay cookies from ${cookiePath}`);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
} catch (e) {
|
|
782
|
+
console.error("[LocalAPI] Failed to load relay cookies:", e.message);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function persistRelayCookies() {
|
|
786
|
+
try {
|
|
787
|
+
// Sticky semantics: never replace an existing on-disk session with an empty cookie map.
|
|
788
|
+
if (relayCookies.size === 0) return;
|
|
789
|
+
|
|
790
|
+
const json = JSON.stringify(Object.fromEntries(relayCookies));
|
|
791
|
+
fs.writeFileSync(cookiePath, json, { encoding: "utf8", mode: 0o600 });
|
|
792
|
+
} catch (e) {
|
|
793
|
+
console.error("[LocalAPI] Failed to persist relay cookies:", e.message);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function clearRelayCookies(reason) {
|
|
798
|
+
if (relayCookies.size === 0) return;
|
|
799
|
+
relayCookies.clear();
|
|
800
|
+
try {
|
|
801
|
+
if (fs.existsSync(cookiePath)) fs.unlinkSync(cookiePath);
|
|
802
|
+
} catch (e) {
|
|
803
|
+
console.error("[LocalAPI] Failed to clear relay cookies:", e.message);
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
if (reason) console.warn(`[LocalAPI] Cleared relay cookies: ${reason}`);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function captureSetCookies(headerValue) {
|
|
810
|
+
if (!headerValue) return;
|
|
811
|
+
const parts = headerValue.split(/,(?=\s*\w+=)/);
|
|
812
|
+
let changed = false;
|
|
813
|
+
for (const raw of parts) {
|
|
814
|
+
const eqIdx = raw.indexOf("=");
|
|
815
|
+
if (eqIdx < 1) continue;
|
|
816
|
+
const name = raw.substring(0, eqIdx).trim();
|
|
817
|
+
if (!name) continue;
|
|
818
|
+
|
|
819
|
+
// Basic sticky logic: if it's a deletion cookie (Max-Age=0 or past date),
|
|
820
|
+
// we only remove it if we have it.
|
|
821
|
+
const lower = raw.toLowerCase();
|
|
822
|
+
const isDeletion = lower.includes("max-age=0") || lower.includes("expires=thu, 01 jan 1970");
|
|
823
|
+
|
|
824
|
+
if (isDeletion) {
|
|
825
|
+
if (relayCookies.has(name)) {
|
|
826
|
+
relayCookies.delete(name);
|
|
827
|
+
changed = true;
|
|
828
|
+
console.log(`[LocalAPI] Cookie deleted: ${name}`);
|
|
829
|
+
}
|
|
830
|
+
} else {
|
|
831
|
+
const oldVal = relayCookies.get(name);
|
|
832
|
+
if (oldVal !== raw.trim()) {
|
|
833
|
+
relayCookies.set(name, raw.trim());
|
|
834
|
+
changed = true;
|
|
835
|
+
console.log(`[LocalAPI] Cookie captured: ${name}`);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
if (changed) persistRelayCookies();
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function getRelayCookieValue(name, { decode = false } = {}) {
|
|
843
|
+
const raw = relayCookies.get(name);
|
|
844
|
+
if (!raw || typeof raw !== "string") return "";
|
|
845
|
+
const pair = raw.split(";")[0] || "";
|
|
846
|
+
const eqIdx = pair.indexOf("=");
|
|
847
|
+
if (eqIdx < 1) return "";
|
|
848
|
+
const value = pair.substring(eqIdx + 1).trim();
|
|
849
|
+
if (!decode) return value;
|
|
850
|
+
try {
|
|
851
|
+
return decodeURIComponent(value);
|
|
852
|
+
} catch {
|
|
853
|
+
return value;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function captureAuthTokensFromBody(bodyBuffer, contentType) {
|
|
858
|
+
if (!bodyBuffer || !String(contentType || "").toLowerCase().includes("application/json")) return;
|
|
859
|
+
let parsed = null;
|
|
860
|
+
try {
|
|
861
|
+
parsed = JSON.parse(bodyBuffer.toString("utf8"));
|
|
862
|
+
} catch {
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
let changed = false;
|
|
866
|
+
const token = typeof parsed?.csrfToken === "string" ? parsed.csrfToken.trim() : "";
|
|
867
|
+
if (token) {
|
|
868
|
+
const cookie = `${csrfRelayCookieName}=${encodeURIComponent(token)}; Path=/; SameSite=Lax`;
|
|
869
|
+
if (relayCookies.get(csrfRelayCookieName) !== cookie) {
|
|
870
|
+
relayCookies.set(csrfRelayCookieName, cookie);
|
|
871
|
+
changed = true;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
const refreshToken = typeof parsed?.refreshToken === "string" ? parsed.refreshToken.trim() : "";
|
|
875
|
+
if (refreshToken) {
|
|
876
|
+
const cookie = `insforge_refresh_token=${encodeURIComponent(refreshToken)}; Path=/; HttpOnly; SameSite=Lax`;
|
|
877
|
+
if (relayCookies.get("insforge_refresh_token") !== cookie) {
|
|
878
|
+
relayCookies.set("insforge_refresh_token", cookie);
|
|
879
|
+
changed = true;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
if (changed) persistRelayCookies();
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function normalizeCookieHeader(value) {
|
|
886
|
+
if (Array.isArray(value)) return value.filter(Boolean).join("; ");
|
|
887
|
+
return typeof value === "string" ? value : "";
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function buildRelayCookieHeader(clientCookieHeader, { relayPrecedenceNames = [] } = {}) {
|
|
891
|
+
const normalizedClientCookieHeader = normalizeCookieHeader(clientCookieHeader);
|
|
892
|
+
if (relayCookies.size === 0) return normalizedClientCookieHeader;
|
|
893
|
+
const relayPrecedence = new Set(relayPrecedenceNames);
|
|
894
|
+
const clientPairs = new Map();
|
|
895
|
+
if (normalizedClientCookieHeader) {
|
|
896
|
+
for (const part of normalizedClientCookieHeader.split(";")) {
|
|
897
|
+
const eqIdx = part.indexOf("=");
|
|
898
|
+
if (eqIdx < 1) continue;
|
|
899
|
+
const n = part.substring(0, eqIdx).trim();
|
|
900
|
+
if (n) clientPairs.set(n, part.trim());
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
// Merge relay cookies. Normal requests keep client precedence; refresh
|
|
904
|
+
// recovery can opt relay cookies into precedence over stale WebView cookies.
|
|
905
|
+
for (const [name, raw] of relayCookies) {
|
|
906
|
+
if (clientPairs.has(name) && !relayPrecedence.has(name)) continue;
|
|
907
|
+
const scIdx = raw.indexOf(";");
|
|
908
|
+
const pair = scIdx > 0 ? raw.substring(0, scIdx).trim() : raw;
|
|
909
|
+
clientPairs.set(name, pair);
|
|
910
|
+
}
|
|
911
|
+
return [...clientPairs.values()].join("; ");
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// Ephemeral auth bridge: WebView sets a "native" flag before opening system browser
|
|
915
|
+
// for OAuth. The callback page (in browser) checks this flag to decide whether to
|
|
916
|
+
// relay the code back to the app or handle it as a normal web flow.
|
|
917
|
+
let _nativeAuthPending = false;
|
|
918
|
+
let _nativeAuthExpiry = 0;
|
|
919
|
+
|
|
920
|
+
function isAuthorizedLocalMutation(req) {
|
|
921
|
+
const headerToken = req?.headers?.["x-tokentracker-local-auth"];
|
|
922
|
+
const cookieToken = parseCookieHeader(req?.headers?.cookie).get("tokentracker_local_auth");
|
|
923
|
+
const token = typeof headerToken === "string" && headerToken.trim()
|
|
924
|
+
? headerToken.trim()
|
|
925
|
+
: cookieToken || "";
|
|
926
|
+
if (!token || token !== localAuthToken) return false;
|
|
927
|
+
return hasAllowedLoopbackOrigin(req?.headers || {});
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
return async function handleLocalApi(req, res, url) {
|
|
931
|
+
const p = url.pathname;
|
|
932
|
+
|
|
933
|
+
if (p === "/api/local-auth") {
|
|
934
|
+
if (String(req.method || "GET").toUpperCase() !== "GET") {
|
|
935
|
+
json(res, { error: "Method Not Allowed" }, 405);
|
|
936
|
+
return true;
|
|
937
|
+
}
|
|
938
|
+
res.writeHead(200, {
|
|
939
|
+
"Content-Type": "application/json",
|
|
940
|
+
"Cache-Control": "no-store",
|
|
941
|
+
});
|
|
942
|
+
res.end(JSON.stringify({ token: localAuthToken }));
|
|
943
|
+
return true;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// --- Auth bridge: native OAuth flag (WebView ↔ system browser) ---
|
|
947
|
+
if (p === "/api/auth-bridge/verifier") {
|
|
948
|
+
const method = String(req.method || "GET").toUpperCase();
|
|
949
|
+
if (method === "PUT" || method === "POST") {
|
|
950
|
+
if (!isAuthorizedLocalMutation(req)) {
|
|
951
|
+
json(res, { error: "Unauthorized" }, 401);
|
|
952
|
+
return true;
|
|
953
|
+
}
|
|
954
|
+
const body = await readJsonBody(req);
|
|
955
|
+
_nativeAuthPending = Boolean(body?.native);
|
|
956
|
+
_nativeAuthExpiry = Date.now() + 5 * 60 * 1000; // 5 min TTL
|
|
957
|
+
json(res, { ok: true });
|
|
958
|
+
return true;
|
|
959
|
+
}
|
|
960
|
+
if (method === "GET") {
|
|
961
|
+
const isNative = _nativeAuthPending && Date.now() < _nativeAuthExpiry;
|
|
962
|
+
_nativeAuthPending = false; // one-time read
|
|
963
|
+
_nativeAuthExpiry = 0;
|
|
964
|
+
json(res, { native: isNative });
|
|
965
|
+
return true;
|
|
966
|
+
}
|
|
967
|
+
json(res, { error: "Method Not Allowed" }, 405);
|
|
968
|
+
return true;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// --- auth proxy: forward /api/auth/* to InsForge cloud ---
|
|
972
|
+
if (p.startsWith("/api/auth/")) {
|
|
973
|
+
const runtime = resolveRuntimeConfig();
|
|
974
|
+
const insforgeBase = runtime.baseUrl || DEFAULT_BASE_URL;
|
|
975
|
+
try {
|
|
976
|
+
const targetUrl = `${insforgeBase.replace(/\/$/, "")}${p}${url.search || ""}`;
|
|
977
|
+
const proxyHeaders = buildProxyHeaders(req.headers);
|
|
978
|
+
const hasClientCookie = normalizeCookieHeader(proxyHeaders["cookie"]).trim().length > 0;
|
|
979
|
+
const hasCsrfHeader = typeof proxyHeaders["x-csrf-token"] === "string" && proxyHeaders["x-csrf-token"].trim().length > 0;
|
|
980
|
+
const relayCsrfToken = getRelayCookieValue(csrfRelayCookieName);
|
|
981
|
+
if (p === "/api/auth/refresh" && relayCsrfToken) {
|
|
982
|
+
proxyHeaders["x-csrf-token"] = relayCsrfToken;
|
|
983
|
+
}
|
|
984
|
+
const hasEffectiveCsrfHeader =
|
|
985
|
+
hasCsrfHeader ||
|
|
986
|
+
(typeof proxyHeaders["x-csrf-token"] === "string" && proxyHeaders["x-csrf-token"].trim().length > 0);
|
|
987
|
+
let shouldInjectRelayCookies =
|
|
988
|
+
p !== "/api/auth/refresh" || hasClientCookie || hasEffectiveCsrfHeader;
|
|
989
|
+
const relayRefreshToken = getRelayCookieValue("insforge_refresh_token", { decode: true });
|
|
990
|
+
const shouldUseRelayRefreshFallback =
|
|
991
|
+
p === "/api/auth/refresh" && !hasClientCookie && !hasEffectiveCsrfHeader && relayRefreshToken;
|
|
992
|
+
if (shouldUseRelayRefreshFallback) {
|
|
993
|
+
shouldInjectRelayCookies = false;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// Inject relay cookies so WebView benefits from browser's login session.
|
|
997
|
+
// Refresh requests need either a browser cookie or an explicit CSRF token;
|
|
998
|
+
// otherwise replaying a stale persisted refresh cookie just manufactures
|
|
999
|
+
// Invalid CSRF errors on startup.
|
|
1000
|
+
const originalCookieHeader = normalizeCookieHeader(proxyHeaders["cookie"]);
|
|
1001
|
+
const mergedCookie = shouldInjectRelayCookies
|
|
1002
|
+
? buildRelayCookieHeader(originalCookieHeader, {
|
|
1003
|
+
relayPrecedenceNames: p === "/api/auth/refresh"
|
|
1004
|
+
? [csrfRelayCookieName, "insforge_refresh_token"]
|
|
1005
|
+
: [],
|
|
1006
|
+
})
|
|
1007
|
+
: originalCookieHeader;
|
|
1008
|
+
const injectedRelayCookies =
|
|
1009
|
+
shouldInjectRelayCookies && relayCookies.size > 0 && mergedCookie !== originalCookieHeader;
|
|
1010
|
+
if (mergedCookie) proxyHeaders["cookie"] = mergedCookie;
|
|
1011
|
+
|
|
1012
|
+
const bodyChunks = [];
|
|
1013
|
+
for await (const chunk of req) bodyChunks.push(chunk);
|
|
1014
|
+
let proxyBody = bodyChunks.length > 0 ? Buffer.concat(bodyChunks) : undefined;
|
|
1015
|
+
let effectiveTargetUrl = targetUrl;
|
|
1016
|
+
if (shouldUseRelayRefreshFallback) {
|
|
1017
|
+
effectiveTargetUrl = `${insforgeBase.replace(/\/$/, "")}/api/auth/refresh?client_type=mobile`;
|
|
1018
|
+
proxyHeaders["content-type"] = "application/json";
|
|
1019
|
+
delete proxyHeaders["content-length"];
|
|
1020
|
+
proxyBody = Buffer.from(JSON.stringify({ refresh_token: relayRefreshToken }), "utf8");
|
|
1021
|
+
}
|
|
1022
|
+
const proxyRes = await fetch(effectiveTargetUrl, {
|
|
1023
|
+
method: req.method || "GET",
|
|
1024
|
+
headers: proxyHeaders,
|
|
1025
|
+
body: proxyBody,
|
|
1026
|
+
credentials: "include",
|
|
1027
|
+
redirect: "manual",
|
|
1028
|
+
});
|
|
1029
|
+
const responseHeaders = [...proxyRes.headers.entries()]
|
|
1030
|
+
.filter(([k]) => !["transfer-encoding", "connection"].includes(k.toLowerCase()))
|
|
1031
|
+
.map(([k, v]) => {
|
|
1032
|
+
if (k.toLowerCase() === "set-cookie") {
|
|
1033
|
+
const rewritten = v.replace(/;\s*[Dd]omain=[^;]*/g, "; Domain=localhost");
|
|
1034
|
+
captureSetCookies(rewritten);
|
|
1035
|
+
return [k, rewritten];
|
|
1036
|
+
}
|
|
1037
|
+
return [k, v];
|
|
1038
|
+
});
|
|
1039
|
+
res.writeHead(proxyRes.status, Object.fromEntries(responseHeaders));
|
|
1040
|
+
const resBody = Buffer.from(await proxyRes.arrayBuffer());
|
|
1041
|
+
if (proxyRes.status >= 200 && proxyRes.status < 300) {
|
|
1042
|
+
if (p === "/api/auth/logout") {
|
|
1043
|
+
clearRelayCookies("sign out");
|
|
1044
|
+
} else {
|
|
1045
|
+
captureAuthTokensFromBody(resBody, proxyRes.headers.get("content-type"));
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
if (
|
|
1049
|
+
p === "/api/auth/refresh"
|
|
1050
|
+
&& proxyRes.status === 403
|
|
1051
|
+
&& injectedRelayCookies
|
|
1052
|
+
&& !hasClientCookie
|
|
1053
|
+
&& /invalid csrf token/i.test(resBody.toString("utf8"))
|
|
1054
|
+
) {
|
|
1055
|
+
clearRelayCookies("stale refresh cookie without local CSRF context");
|
|
1056
|
+
}
|
|
1057
|
+
res.end(resBody);
|
|
1058
|
+
} catch (e) {
|
|
1059
|
+
json(res, { error: `Auth proxy error: ${e?.message || e}` }, 502);
|
|
1060
|
+
}
|
|
1061
|
+
return true;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// --- ip-check proxy: reverse-proxy ip.net.coffee (issue #81) ---
|
|
1065
|
+
// Lock-down: GET/HEAD only, restricted path prefixes, do not forward
|
|
1066
|
+
// browser credentials or fingerprintable headers. Without these limits
|
|
1067
|
+
// /proxy/ipcheck is an open reverse-proxy any local process can abuse
|
|
1068
|
+
// (exfiltrate dashboard cookies, anonymously POST through user IP).
|
|
1069
|
+
if (p.startsWith(`${IP_CHECK_PROXY_PREFIX}/`) || p === IP_CHECK_PROXY_PREFIX) {
|
|
1070
|
+
const method = String(req.method || "GET").toUpperCase();
|
|
1071
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
1072
|
+
json(res, { error: "Method Not Allowed" }, 405);
|
|
1073
|
+
return true;
|
|
1074
|
+
}
|
|
1075
|
+
const targetPath = p === IP_CHECK_PROXY_PREFIX
|
|
1076
|
+
? "/"
|
|
1077
|
+
: p.slice(IP_CHECK_PROXY_PREFIX.length) || "/";
|
|
1078
|
+
const ALLOWED_PREFIXES = [
|
|
1079
|
+
"/api/geoip/",
|
|
1080
|
+
"/api/geoip-batch",
|
|
1081
|
+
"/api/iprisk/",
|
|
1082
|
+
"/api/dns/result/",
|
|
1083
|
+
"/claude/status.json",
|
|
1084
|
+
"/favicons/",
|
|
1085
|
+
"/ip/",
|
|
1086
|
+
];
|
|
1087
|
+
if (!ALLOWED_PREFIXES.some((prefix) => targetPath.startsWith(prefix))) {
|
|
1088
|
+
json(res, { error: "Path not allowed" }, 403);
|
|
1089
|
+
return true;
|
|
1090
|
+
}
|
|
1091
|
+
const targetUrl = `${IP_CHECK_TARGET}${targetPath}${url.search || ""}`;
|
|
1092
|
+
try {
|
|
1093
|
+
// Whitelist forwarded headers — no cookies, no auth, no fingerprintable
|
|
1094
|
+
// identity. Only what the upstream needs to negotiate content. Do not
|
|
1095
|
+
// set `host` explicitly: undici derives it from the URL, and some
|
|
1096
|
+
// versions reject a manual host header on fetch() (same forbidden-
|
|
1097
|
+
// header family that broke /api/auth/* in 5/13).
|
|
1098
|
+
const proxyHeaders = {
|
|
1099
|
+
accept: req.headers["accept"] || "*/*",
|
|
1100
|
+
"accept-language": req.headers["accept-language"] || "en",
|
|
1101
|
+
"accept-encoding": req.headers["accept-encoding"] || "gzip",
|
|
1102
|
+
"user-agent": "TokenTracker/IPCheck (https://www.tokentracker.cc)",
|
|
1103
|
+
referer: `${IP_CHECK_TARGET}${targetPath}`,
|
|
1104
|
+
};
|
|
1105
|
+
|
|
1106
|
+
const proxyRes = await fetch(targetUrl, {
|
|
1107
|
+
method,
|
|
1108
|
+
headers: proxyHeaders,
|
|
1109
|
+
redirect: "manual",
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
const stripped = new Set([
|
|
1113
|
+
"transfer-encoding",
|
|
1114
|
+
"connection",
|
|
1115
|
+
"content-length",
|
|
1116
|
+
"content-encoding",
|
|
1117
|
+
"x-frame-options",
|
|
1118
|
+
"content-security-policy",
|
|
1119
|
+
"cross-origin-opener-policy",
|
|
1120
|
+
"cross-origin-embedder-policy",
|
|
1121
|
+
"cross-origin-resource-policy",
|
|
1122
|
+
]);
|
|
1123
|
+
const responseHeaders = [...proxyRes.headers.entries()].filter(
|
|
1124
|
+
([k]) => !stripped.has(k.toLowerCase()),
|
|
1125
|
+
);
|
|
1126
|
+
|
|
1127
|
+
const resBody = Buffer.from(await proxyRes.arrayBuffer());
|
|
1128
|
+
res.writeHead(proxyRes.status, Object.fromEntries(responseHeaders));
|
|
1129
|
+
res.end(resBody);
|
|
1130
|
+
} catch (e) {
|
|
1131
|
+
json(res, { error: `IP check proxy error: ${e?.message || e}` }, 502);
|
|
1132
|
+
}
|
|
1133
|
+
return true;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// --- avatar proxy: fetch third-party avatars server-side ---
|
|
1137
|
+
// Why: WKWebView in TokenTrackerBar fails to load some users' Google /
|
|
1138
|
+
// GitHub avatars directly (network-stack / proxy / TLS quirks vary by
|
|
1139
|
+
// environment), even when the same URL renders fine in Safari. Proxying
|
|
1140
|
+
// through Node's fetch — which honors system proxy + cookies-of-none —
|
|
1141
|
+
// produces a same-origin <img> the WKWebView always accepts.
|
|
1142
|
+
// Lock-down: GET only; host allowlist of well-known avatar CDNs (no open
|
|
1143
|
+
// proxy); strip cookies/auth; small in-memory cache.
|
|
1144
|
+
if (p === "/api/avatar-proxy") {
|
|
1145
|
+
const method = String(req.method || "GET").toUpperCase();
|
|
1146
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
1147
|
+
json(res, { error: "Method Not Allowed" }, 405);
|
|
1148
|
+
return true;
|
|
1149
|
+
}
|
|
1150
|
+
const target = url.searchParams.get("url");
|
|
1151
|
+
if (!target) {
|
|
1152
|
+
json(res, { error: "Missing url" }, 400);
|
|
1153
|
+
return true;
|
|
1154
|
+
}
|
|
1155
|
+
let parsed;
|
|
1156
|
+
try {
|
|
1157
|
+
parsed = new URL(target);
|
|
1158
|
+
} catch {
|
|
1159
|
+
json(res, { error: "Invalid url" }, 400);
|
|
1160
|
+
return true;
|
|
1161
|
+
}
|
|
1162
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
1163
|
+
json(res, { error: "Only http(s) allowed" }, 400);
|
|
1164
|
+
return true;
|
|
1165
|
+
}
|
|
1166
|
+
const AVATAR_HOST_ALLOWLIST = [
|
|
1167
|
+
"lh3.googleusercontent.com",
|
|
1168
|
+
"lh4.googleusercontent.com",
|
|
1169
|
+
"lh5.googleusercontent.com",
|
|
1170
|
+
"lh6.googleusercontent.com",
|
|
1171
|
+
"avatars.githubusercontent.com",
|
|
1172
|
+
"secure.gravatar.com",
|
|
1173
|
+
"www.gravatar.com",
|
|
1174
|
+
"gravatar.com",
|
|
1175
|
+
"cdn.discordapp.com",
|
|
1176
|
+
"pbs.twimg.com",
|
|
1177
|
+
"abs.twimg.com",
|
|
1178
|
+
"api.dicebear.com",
|
|
1179
|
+
];
|
|
1180
|
+
const hostOk = AVATAR_HOST_ALLOWLIST.some(
|
|
1181
|
+
(h) => parsed.hostname === h || parsed.hostname.endsWith(`.${h}`),
|
|
1182
|
+
);
|
|
1183
|
+
if (!hostOk) {
|
|
1184
|
+
json(res, { error: "Host not allowed" }, 403);
|
|
1185
|
+
return true;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
const cacheKey = parsed.toString();
|
|
1189
|
+
const now = Date.now();
|
|
1190
|
+
const cached = avatarProxyCache.get(cacheKey);
|
|
1191
|
+
if (cached && now - cached.fetchedAt < AVATAR_PROXY_TTL_MS) {
|
|
1192
|
+
res.writeHead(200, {
|
|
1193
|
+
"Content-Type": cached.contentType,
|
|
1194
|
+
"Cache-Control": "public, max-age=3600",
|
|
1195
|
+
"X-Avatar-Cache": "HIT",
|
|
1196
|
+
});
|
|
1197
|
+
res.end(method === "HEAD" ? undefined : cached.body);
|
|
1198
|
+
return true;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
try {
|
|
1202
|
+
const upstream = await fetch(cacheKey, {
|
|
1203
|
+
method,
|
|
1204
|
+
redirect: "follow",
|
|
1205
|
+
headers: {
|
|
1206
|
+
accept: req.headers["accept"] || "image/*",
|
|
1207
|
+
"accept-language": req.headers["accept-language"] || "en",
|
|
1208
|
+
"user-agent": "TokenTracker/AvatarProxy (https://www.tokentracker.cc)",
|
|
1209
|
+
},
|
|
1210
|
+
});
|
|
1211
|
+
if (!upstream.ok) {
|
|
1212
|
+
json(res, { error: `Upstream ${upstream.status}` }, upstream.status);
|
|
1213
|
+
return true;
|
|
1214
|
+
}
|
|
1215
|
+
const contentType = upstream.headers.get("content-type") || "image/png";
|
|
1216
|
+
if (!contentType.toLowerCase().startsWith("image/")) {
|
|
1217
|
+
json(res, { error: "Not an image" }, 415);
|
|
1218
|
+
return true;
|
|
1219
|
+
}
|
|
1220
|
+
const body = Buffer.from(await upstream.arrayBuffer());
|
|
1221
|
+
if (body.length <= AVATAR_PROXY_MAX_BYTES) {
|
|
1222
|
+
// Simple LRU: drop oldest if over capacity.
|
|
1223
|
+
if (avatarProxyCache.size >= AVATAR_PROXY_MAX_ENTRIES) {
|
|
1224
|
+
const oldestKey = avatarProxyCache.keys().next().value;
|
|
1225
|
+
if (oldestKey) avatarProxyCache.delete(oldestKey);
|
|
1226
|
+
}
|
|
1227
|
+
avatarProxyCache.set(cacheKey, { body, contentType, fetchedAt: now });
|
|
1228
|
+
}
|
|
1229
|
+
res.writeHead(200, {
|
|
1230
|
+
"Content-Type": contentType,
|
|
1231
|
+
"Cache-Control": "public, max-age=3600",
|
|
1232
|
+
"X-Avatar-Cache": "MISS",
|
|
1233
|
+
});
|
|
1234
|
+
res.end(method === "HEAD" ? undefined : body);
|
|
1235
|
+
} catch (e) {
|
|
1236
|
+
json(res, { error: `Avatar proxy error: ${e?.message || e}` }, 502);
|
|
1237
|
+
}
|
|
1238
|
+
return true;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// --- local-sync (POST) ---
|
|
1242
|
+
if (p === "/functions/tokentracker-local-sync") {
|
|
1243
|
+
if (String(req.method || "GET").toUpperCase() !== "POST") {
|
|
1244
|
+
json(res, { ok: false, error: "Method Not Allowed" }, 405);
|
|
1245
|
+
return true;
|
|
1246
|
+
}
|
|
1247
|
+
if (!isAuthorizedLocalMutation(req)) {
|
|
1248
|
+
json(res, { ok: false, error: "Unauthorized" }, 401);
|
|
1249
|
+
return true;
|
|
1250
|
+
}
|
|
1251
|
+
try {
|
|
1252
|
+
let body = {};
|
|
1253
|
+
try {
|
|
1254
|
+
body = await readJsonBody(req);
|
|
1255
|
+
} catch {
|
|
1256
|
+
body = {};
|
|
1257
|
+
}
|
|
1258
|
+
const extraEnv = {};
|
|
1259
|
+
if (typeof body.deviceToken === "string" && body.deviceToken.trim()) {
|
|
1260
|
+
extraEnv.TOKENTRACKER_DEVICE_TOKEN = body.deviceToken.trim();
|
|
1261
|
+
}
|
|
1262
|
+
if (body.insforgeBaseUrl != null) {
|
|
1263
|
+
const allowedBaseUrl = resolveAllowedInsforgeBaseUrl(body.insforgeBaseUrl);
|
|
1264
|
+
if (!allowedBaseUrl) {
|
|
1265
|
+
json(res, { ok: false, error: "Unsupported insforgeBaseUrl override" }, 400);
|
|
1266
|
+
return true;
|
|
1267
|
+
}
|
|
1268
|
+
extraEnv.TOKENTRACKER_INSFORGE_BASE_URL = allowedBaseUrl;
|
|
1269
|
+
}
|
|
1270
|
+
const result = await runSyncCommand(extraEnv);
|
|
1271
|
+
try {
|
|
1272
|
+
const { resetUsageLimitsCache } = require("./usage-limits");
|
|
1273
|
+
resetUsageLimitsCache();
|
|
1274
|
+
} catch (_e) {
|
|
1275
|
+
// ignore if module load fails
|
|
1276
|
+
}
|
|
1277
|
+
json(res, { ok: true, ...result });
|
|
1278
|
+
} catch (e) {
|
|
1279
|
+
json(res, { ok: false, error: e?.message, code: e?.code ?? null, stdout: e?.stdout || "", stderr: e?.stderr || "" }, 500);
|
|
1280
|
+
}
|
|
1281
|
+
return true;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// --- wrapped (year-end summary, à la Spotify Wrapped) ---
|
|
1285
|
+
if (p === "/functions/tokentracker-wrapped") {
|
|
1286
|
+
const yearParam = url.searchParams.get("year");
|
|
1287
|
+
const year = yearParam ? Number(yearParam) : null;
|
|
1288
|
+
const { rows, scope, excludedSources } = scopedQueueRows(qp, url);
|
|
1289
|
+
const { aggregateWrapped } = require("./wrapped-aggregator");
|
|
1290
|
+
const summary = aggregateWrapped(rows, year ? { year } : {});
|
|
1291
|
+
json(res, { scope, excluded_sources: excludedSources, ...summary });
|
|
1292
|
+
return true;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// --- usage-summary ---
|
|
1296
|
+
if (p === "/functions/tokentracker-usage-summary") {
|
|
1297
|
+
const from = url.searchParams.get("from") || "";
|
|
1298
|
+
const to = url.searchParams.get("to") || "";
|
|
1299
|
+
const timeZoneContext = getTimeZoneContext(url);
|
|
1300
|
+
const { rows, scope, excludedSources } = scopedQueueRows(qp, url);
|
|
1301
|
+
const daily = aggregateByDay(rows, timeZoneContext).filter((d) => d.day >= from && d.day <= to);
|
|
1302
|
+
const totals = daily.reduce(
|
|
1303
|
+
(acc, r) => {
|
|
1304
|
+
acc.total_tokens += r.total_tokens;
|
|
1305
|
+
acc.billable_total_tokens += r.billable_total_tokens;
|
|
1306
|
+
acc.total_cost_usd += r.total_cost_usd || 0;
|
|
1307
|
+
acc.input_tokens += r.input_tokens;
|
|
1308
|
+
acc.output_tokens += r.output_tokens;
|
|
1309
|
+
acc.cached_input_tokens += r.cached_input_tokens;
|
|
1310
|
+
acc.cache_creation_input_tokens += r.cache_creation_input_tokens;
|
|
1311
|
+
acc.reasoning_output_tokens += r.reasoning_output_tokens;
|
|
1312
|
+
acc.conversation_count += r.conversation_count;
|
|
1313
|
+
return acc;
|
|
1314
|
+
},
|
|
1315
|
+
{ total_tokens: 0, billable_total_tokens: 0, total_cost_usd: 0, input_tokens: 0, output_tokens: 0, cached_input_tokens: 0, cache_creation_input_tokens: 0, reasoning_output_tokens: 0, conversation_count: 0 },
|
|
1316
|
+
);
|
|
1317
|
+
const totalCost = totals.total_cost_usd;
|
|
1318
|
+
|
|
1319
|
+
const todayParts = getZonedParts(new Date(), timeZoneContext);
|
|
1320
|
+
const todayStr = formatPartsDayKey(todayParts) || new Date().toISOString().slice(0, 10);
|
|
1321
|
+
const allDaily = aggregateByDay(rows, timeZoneContext);
|
|
1322
|
+
|
|
1323
|
+
const shiftDay = (dayStr, delta) => {
|
|
1324
|
+
const d = new Date(`${dayStr}T00:00:00Z`);
|
|
1325
|
+
d.setUTCDate(d.getUTCDate() + delta);
|
|
1326
|
+
return d.toISOString().slice(0, 10);
|
|
1327
|
+
};
|
|
1328
|
+
const collectDays = (n) => {
|
|
1329
|
+
const out = [];
|
|
1330
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
1331
|
+
const ds = shiftDay(todayStr, -i);
|
|
1332
|
+
const dd = allDaily.find((x) => x.day === ds);
|
|
1333
|
+
if (dd) out.push(dd);
|
|
1334
|
+
}
|
|
1335
|
+
return out;
|
|
1336
|
+
};
|
|
1337
|
+
const sumDays = (days) =>
|
|
1338
|
+
days.reduce((a, r) => {
|
|
1339
|
+
a.billable_total_tokens += r.billable_total_tokens;
|
|
1340
|
+
a.conversation_count += r.conversation_count;
|
|
1341
|
+
return a;
|
|
1342
|
+
}, { billable_total_tokens: 0, conversation_count: 0 });
|
|
1343
|
+
|
|
1344
|
+
const l7 = collectDays(7);
|
|
1345
|
+
const l30 = collectDays(30);
|
|
1346
|
+
const l7t = sumDays(l7);
|
|
1347
|
+
const l30t = sumDays(l30);
|
|
1348
|
+
const l7fromStr = shiftDay(todayStr, -6);
|
|
1349
|
+
const l30fromStr = shiftDay(todayStr, -29);
|
|
1350
|
+
|
|
1351
|
+
json(res, {
|
|
1352
|
+
from, to, days: daily.length, scope, excluded_sources: excludedSources,
|
|
1353
|
+
totals: { ...totals, total_cost_usd: totalCost.toFixed(6) },
|
|
1354
|
+
rolling: {
|
|
1355
|
+
last_7d: { from: l7fromStr, to: todayStr, active_days: l7.length, totals: l7t },
|
|
1356
|
+
last_30d: { from: l30fromStr, to: todayStr, active_days: l30.length, totals: l30t, avg_per_active_day: l30.length > 0 ? Math.round(l30t.billable_total_tokens / l30.length) : 0 },
|
|
1357
|
+
},
|
|
1358
|
+
});
|
|
1359
|
+
return true;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// --- usage-daily ---
|
|
1363
|
+
if (p === "/functions/tokentracker-usage-daily") {
|
|
1364
|
+
const from = url.searchParams.get("from") || "";
|
|
1365
|
+
const to = url.searchParams.get("to") || "";
|
|
1366
|
+
const timeZoneContext = getTimeZoneContext(url);
|
|
1367
|
+
const { rows, scope, excludedSources } = scopedQueueRows(qp, url);
|
|
1368
|
+
const daily = aggregateByDay(rows, timeZoneContext).filter((d) => d.day >= from && d.day <= to);
|
|
1369
|
+
json(res, { from, to, scope, excluded_sources: excludedSources, data: daily });
|
|
1370
|
+
return true;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
// --- usage-heatmap ---
|
|
1374
|
+
if (p === "/functions/tokentracker-usage-heatmap") {
|
|
1375
|
+
const weeks = parseInt(url.searchParams.get("weeks") || "52", 10);
|
|
1376
|
+
const timeZoneContext = getTimeZoneContext(url);
|
|
1377
|
+
const { rows, scope, excludedSources } = scopedQueueRows(qp, url);
|
|
1378
|
+
const daily = aggregateByDay(rows, timeZoneContext);
|
|
1379
|
+
const todayParts = getZonedParts(new Date(), timeZoneContext);
|
|
1380
|
+
const todayStr = formatPartsDayKey(todayParts) || new Date().toISOString().slice(0, 10);
|
|
1381
|
+
const end = new Date(`${todayStr}T00:00:00Z`);
|
|
1382
|
+
const start = new Date(end);
|
|
1383
|
+
start.setUTCDate(start.getUTCDate() - weeks * 7 + 1);
|
|
1384
|
+
const from = start.toISOString().slice(0, 10);
|
|
1385
|
+
const to = end.toISOString().slice(0, 10);
|
|
1386
|
+
const byDay = new Map(daily.map((d) => [d.day, d]));
|
|
1387
|
+
|
|
1388
|
+
const allValues = daily.map((d) => d.billable_total_tokens).filter((v) => v > 0);
|
|
1389
|
+
const maxValue = allValues.length > 0 ? Math.max(...allValues) : 0;
|
|
1390
|
+
const calcLevel = (v) => {
|
|
1391
|
+
if (v <= 0) return 0;
|
|
1392
|
+
if (maxValue === 0) return 1;
|
|
1393
|
+
const r = v / maxValue;
|
|
1394
|
+
if (r <= 0.25) return 1;
|
|
1395
|
+
if (r <= 0.5) return 2;
|
|
1396
|
+
if (r <= 0.75) return 3;
|
|
1397
|
+
return 4;
|
|
1398
|
+
};
|
|
1399
|
+
|
|
1400
|
+
// Build cells and group into weeks (array of 7-cell arrays) for the dashboard
|
|
1401
|
+
const cells = [];
|
|
1402
|
+
const cursor = new Date(start);
|
|
1403
|
+
while (cursor <= end) {
|
|
1404
|
+
const day = cursor.toISOString().slice(0, 10);
|
|
1405
|
+
const data = byDay.get(day);
|
|
1406
|
+
const billable = data?.billable_total_tokens || 0;
|
|
1407
|
+
cells.push({ day, total_tokens: data?.total_tokens || 0, billable_total_tokens: billable, level: calcLevel(billable), models: data?.models || null });
|
|
1408
|
+
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
|
1409
|
+
}
|
|
1410
|
+
const weeksArr = [];
|
|
1411
|
+
for (let i = 0; i < cells.length; i += 7) {
|
|
1412
|
+
weeksArr.push(cells.slice(i, i + 7));
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
let totalCostUsd = 0;
|
|
1416
|
+
for (const d of daily) {
|
|
1417
|
+
if (d.day >= from && d.day <= to) {
|
|
1418
|
+
totalCostUsd += d.total_cost_usd || 0;
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
json(res, {
|
|
1423
|
+
from,
|
|
1424
|
+
to,
|
|
1425
|
+
scope,
|
|
1426
|
+
excluded_sources: excludedSources,
|
|
1427
|
+
week_starts_on: "sun",
|
|
1428
|
+
active_days: cells.filter((c) => c.billable_total_tokens > 0).length,
|
|
1429
|
+
streak_days: 0,
|
|
1430
|
+
weeks: weeksArr,
|
|
1431
|
+
total_cost_usd: totalCostUsd
|
|
1432
|
+
});
|
|
1433
|
+
return true;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
// --- usage-model-breakdown ---
|
|
1437
|
+
if (p === "/functions/tokentracker-usage-model-breakdown") {
|
|
1438
|
+
const from = url.searchParams.get("from") || "";
|
|
1439
|
+
const to = url.searchParams.get("to") || "";
|
|
1440
|
+
const timeZoneContext = getTimeZoneContext(url);
|
|
1441
|
+
const { rows: scopedRows, scope, excludedSources } = scopedQueueRows(qp, url);
|
|
1442
|
+
const rows = scopedRows.filter((r) => {
|
|
1443
|
+
if (!r.hour_start) return false;
|
|
1444
|
+
const d = rowDayKey(r, timeZoneContext);
|
|
1445
|
+
return d >= from && d <= to;
|
|
1446
|
+
});
|
|
1447
|
+
|
|
1448
|
+
const bySource = new Map();
|
|
1449
|
+
for (const row of rows) {
|
|
1450
|
+
const src = row.source || "unknown";
|
|
1451
|
+
const mdl = row.model || "unknown";
|
|
1452
|
+
if (!bySource.has(src))
|
|
1453
|
+
bySource.set(src, { source: src, source_scope: getSourceScope(src), totals: { total_tokens: 0, billable_total_tokens: 0, input_tokens: 0, output_tokens: 0, cached_input_tokens: 0, cache_creation_input_tokens: 0, reasoning_output_tokens: 0, total_cost_usd: "0" }, models: new Map() });
|
|
1454
|
+
const sa = bySource.get(src);
|
|
1455
|
+
sa.totals.total_tokens += row.total_tokens || 0;
|
|
1456
|
+
sa.totals.billable_total_tokens += row.billable_total_tokens ?? row.total_tokens ?? 0;
|
|
1457
|
+
sa.totals.input_tokens += row.input_tokens || 0;
|
|
1458
|
+
sa.totals.output_tokens += row.output_tokens || 0;
|
|
1459
|
+
sa.totals.cached_input_tokens += row.cached_input_tokens || 0;
|
|
1460
|
+
sa.totals.cache_creation_input_tokens += row.cache_creation_input_tokens || 0;
|
|
1461
|
+
sa.totals.reasoning_output_tokens += row.reasoning_output_tokens || 0;
|
|
1462
|
+
if (!sa.models.has(mdl))
|
|
1463
|
+
sa.models.set(mdl, { model: mdl, model_id: mdl, totals: { total_tokens: 0, billable_total_tokens: 0, input_tokens: 0, output_tokens: 0, cached_input_tokens: 0, cache_creation_input_tokens: 0, reasoning_output_tokens: 0, total_cost_usd: "0" } });
|
|
1464
|
+
const ma = sa.models.get(mdl);
|
|
1465
|
+
ma.totals.total_tokens += row.total_tokens || 0;
|
|
1466
|
+
ma.totals.billable_total_tokens += row.billable_total_tokens ?? row.total_tokens ?? 0;
|
|
1467
|
+
ma.totals.input_tokens += row.input_tokens || 0;
|
|
1468
|
+
ma.totals.output_tokens += row.output_tokens || 0;
|
|
1469
|
+
ma.totals.cached_input_tokens += row.cached_input_tokens || 0;
|
|
1470
|
+
ma.totals.cache_creation_input_tokens += row.cache_creation_input_tokens || 0;
|
|
1471
|
+
ma.totals.reasoning_output_tokens += row.reasoning_output_tokens || 0;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
const sources = Array.from(bySource.values()).map((s) => {
|
|
1475
|
+
s.models = Array.from(s.models.values())
|
|
1476
|
+
.map((m) => {
|
|
1477
|
+
const cost = computeRowCost({
|
|
1478
|
+
...m.totals,
|
|
1479
|
+
model: m.model,
|
|
1480
|
+
source: s.source,
|
|
1481
|
+
});
|
|
1482
|
+
return { ...m, totals: { ...m.totals, total_cost_usd: cost.toFixed(6) } };
|
|
1483
|
+
})
|
|
1484
|
+
.sort((a, b) => b.totals.total_tokens - a.totals.total_tokens);
|
|
1485
|
+
const sourceCost = s.models.reduce((sum, m) => sum + Number(m.totals.total_cost_usd), 0);
|
|
1486
|
+
s.totals.total_cost_usd = sourceCost.toFixed(6);
|
|
1487
|
+
return s;
|
|
1488
|
+
});
|
|
1489
|
+
|
|
1490
|
+
json(res, {
|
|
1491
|
+
from, to, days: 0, scope, excluded_sources: excludedSources, sources,
|
|
1492
|
+
pricing: { model: "per-model", pricing_mode: "per_token_type", source: "litellm", effective_from: new Date().toISOString().slice(0, 10) },
|
|
1493
|
+
});
|
|
1494
|
+
return true;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
// --- usage-category-breakdown (Claude + Codex) ---
|
|
1498
|
+
// Claude: splits historical Claude usage into seven semantic categories
|
|
1499
|
+
// mirroring Claude Code's /context view (approx).
|
|
1500
|
+
// Codex: provides a tool-oriented breakdown, attributing per-turn token
|
|
1501
|
+
// deltas to observed tool calls (heuristic).
|
|
1502
|
+
if (p === "/functions/tokentracker-usage-category-breakdown") {
|
|
1503
|
+
const from = url.searchParams.get("from") || "";
|
|
1504
|
+
const to = url.searchParams.get("to") || "";
|
|
1505
|
+
const requestedSource = (url.searchParams.get("source") || "claude").trim().toLowerCase();
|
|
1506
|
+
if (requestedSource === "claude") {
|
|
1507
|
+
try {
|
|
1508
|
+
const result = await computeClaudeCategoryBreakdown({ from, to, projectDir: process.cwd() });
|
|
1509
|
+
json(res, { from, to, ...result });
|
|
1510
|
+
} catch (e) {
|
|
1511
|
+
console.error("[LocalAPI] usage-category-breakdown:", e?.message || e);
|
|
1512
|
+
json(res, { from, to, ...unsupportedCategoryPayload("claude"), error: "compute_failed" }, 500);
|
|
1513
|
+
}
|
|
1514
|
+
return true;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
if (requestedSource === "codex") {
|
|
1518
|
+
try {
|
|
1519
|
+
const timeZoneContext = getTimeZoneContext(url);
|
|
1520
|
+
const result = await computeCodexContextBreakdown({
|
|
1521
|
+
from,
|
|
1522
|
+
to,
|
|
1523
|
+
top: 50,
|
|
1524
|
+
timeZoneContext,
|
|
1525
|
+
});
|
|
1526
|
+
if (!Number(result?.totals?.total_tokens || 0)) {
|
|
1527
|
+
const fallback = buildCodexCategoryFallbackFromQueue(readQueueData(qp), {
|
|
1528
|
+
from,
|
|
1529
|
+
to,
|
|
1530
|
+
timeZoneContext,
|
|
1531
|
+
});
|
|
1532
|
+
json(res, { from, to, ...fallback });
|
|
1533
|
+
return true;
|
|
1534
|
+
}
|
|
1535
|
+
json(res, { from, to, ...result });
|
|
1536
|
+
} catch (e) {
|
|
1537
|
+
console.error("[LocalAPI] usage-category-breakdown(codex):", e?.message || e);
|
|
1538
|
+
json(res, { from, to, ...unsupportedCategoryPayload("codex"), error: "compute_failed" }, 500);
|
|
1539
|
+
}
|
|
1540
|
+
return true;
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
json(res, { from, to, ...unsupportedCategoryPayload(requestedSource) });
|
|
1544
|
+
return true;
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
// --- project-usage-summary ---
|
|
1548
|
+
if (p === "/functions/tokentracker-project-usage-summary") {
|
|
1549
|
+
// Use the per-project bucket log that rollout.js emits — it already
|
|
1550
|
+
// carries the actual tokens attributed to each (project_key, source,
|
|
1551
|
+
// hour_start). Falling back to "session-file count × total tokens"
|
|
1552
|
+
// (the old behavior) produced pure fiction: every short-and-hot
|
|
1553
|
+
// project got the same weight as every long-and-cold one.
|
|
1554
|
+
const projectQueuePath = path.join(
|
|
1555
|
+
path.dirname(qp),
|
|
1556
|
+
"project.queue.jsonl",
|
|
1557
|
+
);
|
|
1558
|
+
const projectRows = readProjectQueueData(projectQueuePath);
|
|
1559
|
+
|
|
1560
|
+
const byProject = new Map();
|
|
1561
|
+
for (const row of projectRows) {
|
|
1562
|
+
const key = row.project_key || "unknown";
|
|
1563
|
+
if (!byProject.has(key)) {
|
|
1564
|
+
byProject.set(key, {
|
|
1565
|
+
project_key: key,
|
|
1566
|
+
project_ref: row.project_ref || key,
|
|
1567
|
+
total_tokens: 0,
|
|
1568
|
+
billable_total_tokens: 0,
|
|
1569
|
+
});
|
|
1570
|
+
}
|
|
1571
|
+
const agg = byProject.get(key);
|
|
1572
|
+
agg.total_tokens += Number(row.total_tokens || 0);
|
|
1573
|
+
agg.billable_total_tokens += Number(row.total_tokens || 0);
|
|
1574
|
+
if (!agg.project_ref && row.project_ref) agg.project_ref = row.project_ref;
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
// If no project-attributed rows exist yet (user hasn't synced project
|
|
1578
|
+
// attribution, or never used a project-capable CLI), fall back to
|
|
1579
|
+
// per-source aggregation over the main queue so the panel isn't
|
|
1580
|
+
// totally empty. This path used to also exist for the non-empty case
|
|
1581
|
+
// and produce wrong numbers; keep it only as the empty fallback.
|
|
1582
|
+
let entries;
|
|
1583
|
+
if (byProject.size === 0) {
|
|
1584
|
+
const rows = readQueueData(qp);
|
|
1585
|
+
const bySrc = new Map();
|
|
1586
|
+
for (const row of rows) {
|
|
1587
|
+
const src = row.source || "unknown";
|
|
1588
|
+
if (!bySrc.has(src)) {
|
|
1589
|
+
bySrc.set(src, {
|
|
1590
|
+
project_key: src,
|
|
1591
|
+
// Synthetic source-only row: leave project_ref empty rather than
|
|
1592
|
+
// fabricating `https://${src}.ai`, which resolves to unrelated
|
|
1593
|
+
// domains (e.g. codex.ai, cursor.ai) and was sent to the
|
|
1594
|
+
// dashboard as a clickable href before v0.11.1 / this commit.
|
|
1595
|
+
project_ref: "",
|
|
1596
|
+
total_tokens: 0,
|
|
1597
|
+
billable_total_tokens: 0,
|
|
1598
|
+
});
|
|
1599
|
+
}
|
|
1600
|
+
bySrc.get(src).total_tokens += row.total_tokens || 0;
|
|
1601
|
+
bySrc.get(src).billable_total_tokens += row.total_tokens || 0;
|
|
1602
|
+
}
|
|
1603
|
+
entries = Array.from(bySrc.values())
|
|
1604
|
+
.sort((a, b) => b.billable_total_tokens - a.billable_total_tokens)
|
|
1605
|
+
.map((e) => ({
|
|
1606
|
+
...e,
|
|
1607
|
+
total_tokens: String(e.total_tokens),
|
|
1608
|
+
billable_total_tokens: String(e.billable_total_tokens),
|
|
1609
|
+
}));
|
|
1610
|
+
} else {
|
|
1611
|
+
entries = Array.from(byProject.values())
|
|
1612
|
+
.sort((a, b) => b.billable_total_tokens - a.billable_total_tokens)
|
|
1613
|
+
.map((e) => ({
|
|
1614
|
+
...e,
|
|
1615
|
+
total_tokens: String(e.total_tokens),
|
|
1616
|
+
billable_total_tokens: String(e.billable_total_tokens),
|
|
1617
|
+
}));
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
json(res, { generated_at: new Date().toISOString(), entries });
|
|
1621
|
+
return true;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
// --- user-status (stub) ---
|
|
1625
|
+
if (p === "/functions/tokentracker-user-status") {
|
|
1626
|
+
json(res, {
|
|
1627
|
+
user_id: "local-user", email: "local@localhost", name: "Local User", is_public: false,
|
|
1628
|
+
created_at: new Date().toISOString(),
|
|
1629
|
+
pro: { active: true, sources: ["local"], expires_at: null, partial: false, as_of: new Date().toISOString() },
|
|
1630
|
+
});
|
|
1631
|
+
return true;
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
// --- usage-hourly (stub for day-view) ---
|
|
1635
|
+
if (p === "/functions/tokentracker-usage-hourly") {
|
|
1636
|
+
const day = url.searchParams.get("day") || new Date().toISOString().slice(0, 10);
|
|
1637
|
+
const timeZoneContext = getTimeZoneContext(url);
|
|
1638
|
+
const { rows, scope, excludedSources } = scopedQueueRows(qp, url);
|
|
1639
|
+
const data = aggregateHourlyByDay(rows, day, timeZoneContext);
|
|
1640
|
+
json(res, { day, scope, excluded_sources: excludedSources, data });
|
|
1641
|
+
return true;
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
// --- usage-monthly (stub for trend view) ---
|
|
1645
|
+
if (p === "/functions/tokentracker-usage-monthly") {
|
|
1646
|
+
const from = url.searchParams.get("from") || "";
|
|
1647
|
+
const to = url.searchParams.get("to") || "";
|
|
1648
|
+
const timeZoneContext = getTimeZoneContext(url);
|
|
1649
|
+
const { rows, scope, excludedSources } = scopedQueueRows(qp, url);
|
|
1650
|
+
const byMonth = new Map();
|
|
1651
|
+
for (const row of rows) {
|
|
1652
|
+
if (!row.hour_start) continue;
|
|
1653
|
+
const day = rowDayKey(row, timeZoneContext);
|
|
1654
|
+
if (!day || day < from || day > to) continue;
|
|
1655
|
+
const month = day.slice(0, 7);
|
|
1656
|
+
if (!byMonth.has(month))
|
|
1657
|
+
byMonth.set(month, { month, total_tokens: 0, billable_total_tokens: 0, input_tokens: 0, output_tokens: 0, cached_input_tokens: 0, cache_creation_input_tokens: 0, reasoning_output_tokens: 0, conversation_count: 0 });
|
|
1658
|
+
const a = byMonth.get(month);
|
|
1659
|
+
a.total_tokens += row.total_tokens || 0;
|
|
1660
|
+
a.billable_total_tokens += row.billable_total_tokens ?? row.total_tokens ?? 0;
|
|
1661
|
+
a.input_tokens += row.input_tokens || 0;
|
|
1662
|
+
a.output_tokens += row.output_tokens || 0;
|
|
1663
|
+
a.cached_input_tokens += row.cached_input_tokens || 0;
|
|
1664
|
+
a.cache_creation_input_tokens += row.cache_creation_input_tokens || 0;
|
|
1665
|
+
a.reasoning_output_tokens += row.reasoning_output_tokens || 0;
|
|
1666
|
+
a.conversation_count += row.conversation_count || 0;
|
|
1667
|
+
|
|
1668
|
+
if (!a.models) {
|
|
1669
|
+
a.models = {};
|
|
1670
|
+
}
|
|
1671
|
+
const model = row.model || "unknown";
|
|
1672
|
+
a.models[model] = (a.models[model] || 0) + (row.total_tokens || 0);
|
|
1673
|
+
}
|
|
1674
|
+
json(res, { from, to, scope, excluded_sources: excludedSources, data: Array.from(byMonth.values()).sort((a, b) => a.month.localeCompare(b.month)) });
|
|
1675
|
+
return true;
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
// --- skills manager ---
|
|
1679
|
+
if (p === "/functions/tokentracker-skills") {
|
|
1680
|
+
const method = String(req.method || "GET").toUpperCase();
|
|
1681
|
+
const skills = require("./skills-manager");
|
|
1682
|
+
try {
|
|
1683
|
+
if (method === "GET") {
|
|
1684
|
+
const mode = url.searchParams.get("mode") || "installed";
|
|
1685
|
+
if (mode === "installed") {
|
|
1686
|
+
json(res, { targets: skills.targetList(), skills: skills.listInstalledSkills() });
|
|
1687
|
+
return true;
|
|
1688
|
+
}
|
|
1689
|
+
if (mode === "repos") {
|
|
1690
|
+
json(res, { repos: skills.listRepos() });
|
|
1691
|
+
return true;
|
|
1692
|
+
}
|
|
1693
|
+
if (mode === "discover") {
|
|
1694
|
+
const force = url.searchParams.get("force") === "1";
|
|
1695
|
+
json(res, await skills.discoverSkills({ force }));
|
|
1696
|
+
return true;
|
|
1697
|
+
}
|
|
1698
|
+
if (mode === "search") {
|
|
1699
|
+
const data = await skills.searchSkillsSh(
|
|
1700
|
+
url.searchParams.get("q") || "",
|
|
1701
|
+
Number(url.searchParams.get("limit") || 20),
|
|
1702
|
+
Number(url.searchParams.get("offset") || 0),
|
|
1703
|
+
);
|
|
1704
|
+
json(res, data);
|
|
1705
|
+
return true;
|
|
1706
|
+
}
|
|
1707
|
+
if (mode === "popular") {
|
|
1708
|
+
const force = url.searchParams.get("force") === "1";
|
|
1709
|
+
json(res, await skills.fetchPopularSkillsSh({ force }));
|
|
1710
|
+
return true;
|
|
1711
|
+
}
|
|
1712
|
+
if (mode === "updates") {
|
|
1713
|
+
const force = url.searchParams.get("force") === "1";
|
|
1714
|
+
json(res, await skills.checkUpdates({ force }));
|
|
1715
|
+
return true;
|
|
1716
|
+
}
|
|
1717
|
+
if (mode === "activity") {
|
|
1718
|
+
const limit = Number(url.searchParams.get("limit") || 50);
|
|
1719
|
+
json(res, { activity: skills.readActivity(limit) });
|
|
1720
|
+
return true;
|
|
1721
|
+
}
|
|
1722
|
+
if (mode === "skill_usage") {
|
|
1723
|
+
const force = url.searchParams.get("force") === "1";
|
|
1724
|
+
const usage = await require("./skill-usage").scanSkillUsage({ force });
|
|
1725
|
+
await ensurePricingLoaded();
|
|
1726
|
+
// Join raw per-skill aggregates against installed skills so we can
|
|
1727
|
+
// separate user-installed skills (where "dead weight" = unused) from
|
|
1728
|
+
// Claude Code built-in tools (bash/agent/…), which dominate the logs
|
|
1729
|
+
// and are NOT uninstallable. Cost is priced per-model (source=claude).
|
|
1730
|
+
const installed = skills.listInstalledSkills();
|
|
1731
|
+
const installedByName = new Map();
|
|
1732
|
+
for (const s of installed) {
|
|
1733
|
+
for (const key of [s.directory, s.name]) {
|
|
1734
|
+
const norm = String(key || "").trim().toLowerCase();
|
|
1735
|
+
if (norm) installedByName.set(norm, s);
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
const priced = usage.skills.map((entry) => {
|
|
1739
|
+
let cost = 0;
|
|
1740
|
+
for (const [model, tokens] of Object.entries(entry.models || {})) {
|
|
1741
|
+
cost += computeRowCost({ ...tokens, model, source: "claude" });
|
|
1742
|
+
}
|
|
1743
|
+
const match = installedByName.get(String(entry.skill || "").trim().toLowerCase());
|
|
1744
|
+
return {
|
|
1745
|
+
skill: entry.skill,
|
|
1746
|
+
invocations: entry.invocations,
|
|
1747
|
+
lastUsedAt: entry.lastUsedAt,
|
|
1748
|
+
tokens: entry.tokens,
|
|
1749
|
+
cost,
|
|
1750
|
+
installed: Boolean(match),
|
|
1751
|
+
skillId: match?.id || null,
|
|
1752
|
+
directory: match?.directory || null,
|
|
1753
|
+
};
|
|
1754
|
+
});
|
|
1755
|
+
// Installed skills with zero invocations = dead-weight candidates.
|
|
1756
|
+
const usedNames = new Set(usage.skills.map((s) => String(s.skill || "").trim().toLowerCase()));
|
|
1757
|
+
const unusedInstalled = installed
|
|
1758
|
+
.filter((s) => {
|
|
1759
|
+
const dir = String(s.directory || "").trim().toLowerCase();
|
|
1760
|
+
const name = String(s.name || "").trim().toLowerCase();
|
|
1761
|
+
return !usedNames.has(dir) && !usedNames.has(name);
|
|
1762
|
+
})
|
|
1763
|
+
.map((s) => ({ skillId: s.id, directory: s.directory, name: s.name }));
|
|
1764
|
+
json(res, {
|
|
1765
|
+
generatedAt: usage.generatedAt,
|
|
1766
|
+
scannedFiles: usage.scannedFiles,
|
|
1767
|
+
totalInvocations: usage.totalInvocations,
|
|
1768
|
+
cached: usage.cached,
|
|
1769
|
+
skills: priced,
|
|
1770
|
+
unusedInstalled,
|
|
1771
|
+
});
|
|
1772
|
+
return true;
|
|
1773
|
+
}
|
|
1774
|
+
json(res, { error: "Unknown skills mode" }, 400);
|
|
1775
|
+
return true;
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
if (method === "POST") {
|
|
1779
|
+
if (!isAuthorizedLocalMutation(req)) {
|
|
1780
|
+
json(res, { ok: false, error: "Unauthorized" }, 401);
|
|
1781
|
+
return true;
|
|
1782
|
+
}
|
|
1783
|
+
const body = await readJsonBody(req);
|
|
1784
|
+
const action = String(body?.action || "");
|
|
1785
|
+
if (action === "install") {
|
|
1786
|
+
json(res, { ok: true, skill: await skills.installSkill(body.skill, body.targets || ["claude", "codex"]) });
|
|
1787
|
+
return true;
|
|
1788
|
+
}
|
|
1789
|
+
if (action === "uninstall") {
|
|
1790
|
+
json(res, { ok: true, ...(skills.uninstallSkill(body.id) || {}) });
|
|
1791
|
+
return true;
|
|
1792
|
+
}
|
|
1793
|
+
if (action === "restore") {
|
|
1794
|
+
json(res, { ok: true, skill: skills.restoreSkill(body.id) });
|
|
1795
|
+
return true;
|
|
1796
|
+
}
|
|
1797
|
+
if (action === "set_targets") {
|
|
1798
|
+
json(res, { ok: true, skill: skills.setSkillTargets(body.id, body.targets || []) });
|
|
1799
|
+
return true;
|
|
1800
|
+
}
|
|
1801
|
+
if (action === "import_local") {
|
|
1802
|
+
json(res, { ok: true, skill: skills.importLocalSkill(body.directory, body.targets || []) });
|
|
1803
|
+
return true;
|
|
1804
|
+
}
|
|
1805
|
+
if (action === "delete_local") {
|
|
1806
|
+
json(res, { ok: true, ...(skills.deleteLocalSkill(body.directory, body.targets || []) || {}) });
|
|
1807
|
+
return true;
|
|
1808
|
+
}
|
|
1809
|
+
if (action === "add_repo") {
|
|
1810
|
+
json(res, { ok: true, repo: skills.addRepo(body.repo) });
|
|
1811
|
+
return true;
|
|
1812
|
+
}
|
|
1813
|
+
if (action === "remove_repo") {
|
|
1814
|
+
json(res, { ok: true, ...(skills.removeRepo(body.owner, body.name) || {}) });
|
|
1815
|
+
return true;
|
|
1816
|
+
}
|
|
1817
|
+
json(res, { ok: false, error: "Unknown skills action" }, 400);
|
|
1818
|
+
return true;
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
json(res, { ok: false, error: "Method Not Allowed" }, 405);
|
|
1822
|
+
} catch (e) {
|
|
1823
|
+
json(res, { ok: false, error: e?.message || "Unknown skills error" }, 500);
|
|
1824
|
+
}
|
|
1825
|
+
return true;
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
// --- usage-limits ---
|
|
1829
|
+
if (p === "/functions/tokentracker-usage-limits") {
|
|
1830
|
+
const { getUsageLimits, resetUsageLimitsCache } = require("./usage-limits");
|
|
1831
|
+
try {
|
|
1832
|
+
const forceRefresh = url.searchParams.get("refresh");
|
|
1833
|
+
if (forceRefresh === "1" || forceRefresh === "true") {
|
|
1834
|
+
resetUsageLimitsCache();
|
|
1835
|
+
}
|
|
1836
|
+
const data = await getUsageLimits({
|
|
1837
|
+
home: os.homedir(),
|
|
1838
|
+
env: process.env,
|
|
1839
|
+
platform: process.platform,
|
|
1840
|
+
});
|
|
1841
|
+
json(res, data);
|
|
1842
|
+
} catch (e) {
|
|
1843
|
+
json(res, { error: e?.message || "Unknown error" }, 500);
|
|
1844
|
+
}
|
|
1845
|
+
return true;
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
return false;
|
|
1849
|
+
};
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
module.exports = {
|
|
1853
|
+
createLocalApiHandler,
|
|
1854
|
+
resolveAllowedInsforgeBaseUrl,
|
|
1855
|
+
resolveQueuePath,
|
|
1856
|
+
// Exported for cross-consumer tests (pricing + native contract lock).
|
|
1857
|
+
MODEL_PRICING,
|
|
1858
|
+
getModelPricing,
|
|
1859
|
+
computeRowCost,
|
|
1860
|
+
ensurePricingLoaded,
|
|
1861
|
+
};
|