@ipv9/tokentracker-cli 0.39.43 → 0.39.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -10
- package/dashboard/dist/assets/{Card-LPizs_gs.js → Card-C_H8B1rI.js} +1 -1
- package/dashboard/dist/assets/DashboardPage-DOa3N76u.js +60 -0
- package/dashboard/dist/assets/{FadeIn-B8aDegoD.js → FadeIn-BE8I9B4-.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-Vo2ZZXov.js → IpCheckPage-DQ9gR343.js} +1 -1
- package/dashboard/dist/assets/{LimitsPage-C5-Q9Q30.js → LimitsPage-Balfhw3-.js} +1 -1
- package/dashboard/dist/assets/LocalOnlyNotice-hxi161Wl.js +1 -0
- package/dashboard/dist/assets/{PopoverPopup-CJf61ahu.js → PopoverPopup-B4zbh-jA.js} +1 -1
- package/dashboard/dist/assets/{Select-BLGoaqgw.js → Select-DLYT4NCO.js} +1 -1
- package/dashboard/dist/assets/{SelectItemText-Bt02Fgwf.js → SelectItemText-DUyZrDnS.js} +1 -1
- package/dashboard/dist/assets/{SettingsPage-BnUJew-8.js → SettingsPage-BySJoulk.js} +1 -1
- package/dashboard/dist/assets/{SkillsPage-ImHg3Puy.js → SkillsPage-DpiMLTPo.js} +1 -1
- package/dashboard/dist/assets/{WidgetsPage-qscVE2nO.js → WidgetsPage-C6je8upG.js} +1 -1
- package/dashboard/dist/assets/{WrappedPage-qM_7aClE.js → WrappedPage-ByarX-vY.js} +1 -1
- package/dashboard/dist/assets/{arrow-up-right-CByq3BPT.js → arrow-up-right-C-WAJGZ8.js} +1 -1
- package/dashboard/dist/assets/{download-CTwO-YeA.js → download-BhOg_qb4.js} +1 -1
- package/dashboard/dist/assets/{format-4chvNBjF.js → format-Co2okzG-.js} +1 -1
- package/dashboard/dist/assets/limitDisplay-CPMQA7lm.js +1 -0
- package/dashboard/dist/assets/{main-CCPcJ7ti.js → main-q9UoIq7C.js} +12 -3
- package/dashboard/dist/assets/main-t7dbBL4x.css +1 -0
- package/dashboard/dist/assets/{mock-data-DSiJ-9lr.js → mock-data-0XiGBeUV.js} +1 -1
- package/dashboard/dist/assets/{use-limits-display-prefs-Dgd-bQBC.js → use-limits-display-prefs-DmEIJPtg.js} +1 -1
- package/dashboard/dist/assets/{use-native-settings-CjZRLdFT.js → use-native-settings-QLM3Sd2C.js} +1 -1
- package/dashboard/dist/assets/{useCurrency-BJRU0syn.js → useCurrency-Dtize6Tx.js} +1 -1
- package/dashboard/dist/index.html +2 -2
- package/package.json +5 -3
- package/src/commands/doctor.js +2 -0
- package/src/commands/init.js +1 -1
- package/src/commands/sync.js +67 -0
- package/src/lib/doctor.js +72 -0
- package/src/lib/local-api.js +306 -93
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/queue-compact.js +220 -0
- package/src/lib/rollout.js +76 -12
- package/src/lib/skills-manager.js +2 -2
- package/src/lib/usage-limits.js +20 -3
- package/dashboard/dist/assets/DashboardPage-CkhqD3x3.js +0 -60
- package/dashboard/dist/assets/LocalOnlyNotice-DXVmRcyV.js +0 -1
- package/dashboard/dist/assets/limitDisplay-CXlkWhjp.js +0 -1
- package/dashboard/dist/assets/main-ZrWkoMlr.css +0 -1
package/src/lib/doctor.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const fs = require("node:fs/promises");
|
|
2
|
+
const { findRowViolations } = require("./queue-compact");
|
|
2
3
|
const { constants } = require("node:fs");
|
|
3
4
|
const path = require("node:path");
|
|
4
5
|
|
|
@@ -28,6 +29,9 @@ async function buildDoctorReport({
|
|
|
28
29
|
if (paths.cliPath) {
|
|
29
30
|
checks.push(await checkCliEntrypoint(paths.cliPath));
|
|
30
31
|
}
|
|
32
|
+
if (paths.queuePath) {
|
|
33
|
+
checks.push(await checkQueueRows(paths.queuePath));
|
|
34
|
+
}
|
|
31
35
|
|
|
32
36
|
// No cloud reachability check: TokenTracker is local-only, so there is no
|
|
33
37
|
// remote endpoint whose availability could affect anything here.
|
|
@@ -358,8 +362,76 @@ function summarizeChecks(checks = []) {
|
|
|
358
362
|
return summary;
|
|
359
363
|
}
|
|
360
364
|
|
|
365
|
+
// CLAUDE.md states the column invariant in prose:
|
|
366
|
+
//
|
|
367
|
+
// total = input + output + cache_creation + cache_read + reasoning
|
|
368
|
+
//
|
|
369
|
+
// Nothing enforced it at runtime, so a miswritten or corrupt row was aggregated
|
|
370
|
+
// and rendered rather than flagged — and a parser bug of exactly that shape is
|
|
371
|
+
// the class CLAUDE.md records at 1.6-7x magnitude. Same conversion as the
|
|
372
|
+
// curated-expiry and version-lockstep checks: a rule that lived in a document
|
|
373
|
+
// starts running.
|
|
374
|
+
//
|
|
375
|
+
// A warn rather than a fail: the rows are already on disk and already being
|
|
376
|
+
// rendered, so failing the whole health check would be reporting a crisis the
|
|
377
|
+
// user cannot act on in the moment. What they can act on is knowing which rows,
|
|
378
|
+
// and how many.
|
|
379
|
+
const QUEUE_VIOLATIONS_SHOWN = 5;
|
|
380
|
+
|
|
381
|
+
function queueCheck(status, detail, meta) {
|
|
382
|
+
return { id: "queue.row_invariant", status, detail, critical: false, meta };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function readQueueRowsForDoctor(queuePath) {
|
|
386
|
+
const raw = await fs.readFile(queuePath, "utf8");
|
|
387
|
+
const rows = [];
|
|
388
|
+
let malformed = 0;
|
|
389
|
+
for (const line of raw.split("\n")) {
|
|
390
|
+
if (!line.trim()) continue;
|
|
391
|
+
try {
|
|
392
|
+
rows.push(JSON.parse(line));
|
|
393
|
+
} catch {
|
|
394
|
+
malformed += 1;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return { rows, malformed };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function checkQueueRows(queuePath) {
|
|
401
|
+
let rows;
|
|
402
|
+
let malformed;
|
|
403
|
+
try {
|
|
404
|
+
({ rows, malformed } = await readQueueRowsForDoctor(queuePath));
|
|
405
|
+
} catch (err) {
|
|
406
|
+
if (err && err.code === "ENOENT") {
|
|
407
|
+
return queueCheck("ok", "no queue yet", { path: queuePath });
|
|
408
|
+
}
|
|
409
|
+
return queueCheck("warn", `queue unreadable: ${err?.message || err}`, { path: queuePath });
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const violations = findRowViolations(rows);
|
|
413
|
+
if (violations.length === 0 && malformed === 0) {
|
|
414
|
+
return queueCheck("ok", `${rows.length} rows satisfy the column invariant`, {
|
|
415
|
+
path: queuePath,
|
|
416
|
+
rows: rows.length,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const parts = [];
|
|
421
|
+
if (violations.length > 0) parts.push(`${violations.length} row problem(s)`);
|
|
422
|
+
if (malformed > 0) parts.push(`${malformed} unparseable line(s)`);
|
|
423
|
+
return queueCheck("warn", `${parts.join(", ")} in ${rows.length + malformed} line(s)`, {
|
|
424
|
+
path: queuePath,
|
|
425
|
+
rows: rows.length,
|
|
426
|
+
malformed,
|
|
427
|
+
violations: violations.length,
|
|
428
|
+
examples: violations.slice(0, QUEUE_VIOLATIONS_SHOWN),
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
361
432
|
module.exports = {
|
|
362
433
|
buildDoctorReport,
|
|
434
|
+
checkQueueRows,
|
|
363
435
|
buildBrowserOpenerCheck,
|
|
364
436
|
buildNodeVersionCheck,
|
|
365
437
|
isHeadlessEnvironment,
|
package/src/lib/local-api.js
CHANGED
|
@@ -20,6 +20,83 @@ const AVATAR_PROXY_MAX_BYTES = 512 * 1024; // 512 KiB per image
|
|
|
20
20
|
const AVATAR_PROXY_MAX_ENTRIES = 64;
|
|
21
21
|
const avatarProxyCache = new Map();
|
|
22
22
|
|
|
23
|
+
// Sentinel for "a redirect left the allowlist", kept distinct from a network
|
|
24
|
+
// failure so the caller can answer 403 rather than a generic upstream error.
|
|
25
|
+
const AVATAR_REDIRECT_BLOCKED = Symbol("avatar-redirect-blocked");
|
|
26
|
+
const AVATAR_MAX_REDIRECTS = 3;
|
|
27
|
+
|
|
28
|
+
// Reads at most `maxBytes`, and STOPS READING at the limit. Returns null when the
|
|
29
|
+
// response is over it.
|
|
30
|
+
//
|
|
31
|
+
// `AVATAR_PROXY_MAX_BYTES` read like a download cap and was not one: the whole
|
|
32
|
+
// upstream body was buffered with `arrayBuffer()` first, and the constant then
|
|
33
|
+
// only decided whether the result entered the cache. An oversized response was
|
|
34
|
+
// still read into memory in full and still written to the client, so an
|
|
35
|
+
// allowlisted host serving a very large image/* drove loopback-server memory
|
|
36
|
+
// with no ceiling.
|
|
37
|
+
//
|
|
38
|
+
// Two gates, because either alone is soft: `content-length` is a claim the peer
|
|
39
|
+
// makes and can lie about or omit, and counting bytes without it means the
|
|
40
|
+
// transfer has already started. Check the claim, then count anyway.
|
|
41
|
+
async function readCappedAvatarBody(response, maxBytes) {
|
|
42
|
+
const declared = Number(response.headers.get("content-length"));
|
|
43
|
+
if (Number.isFinite(declared) && declared > maxBytes) return null;
|
|
44
|
+
// A HEAD response, or a stub without a stream. `arrayBuffer()` is bounded here
|
|
45
|
+
// by the content-length check above and by the length check below.
|
|
46
|
+
if (!response.body) {
|
|
47
|
+
const buffered = Buffer.from(await response.arrayBuffer());
|
|
48
|
+
return buffered.length > maxBytes ? null : buffered;
|
|
49
|
+
}
|
|
50
|
+
const chunks = [];
|
|
51
|
+
let total = 0;
|
|
52
|
+
for await (const chunk of response.body) {
|
|
53
|
+
total += chunk.length;
|
|
54
|
+
// Returning from a for-await calls the iterator's return(), which cancels
|
|
55
|
+
// the stream — the download stops here rather than running to completion.
|
|
56
|
+
if (total > maxBytes) return null;
|
|
57
|
+
chunks.push(Buffer.from(chunk));
|
|
58
|
+
}
|
|
59
|
+
return Buffer.concat(chunks);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// One check for the URL the caller asked for AND for every redirect hop, so the
|
|
63
|
+
// two can't drift apart. Port is part of the address: `https://gravatar.com:8443/`
|
|
64
|
+
// shares the hostname but is a different service, so leaving the port free turns
|
|
65
|
+
// this proxy into a port prober against every allowlisted host. An empty `port`
|
|
66
|
+
// is the scheme default (443 / 80) — the only one permitted.
|
|
67
|
+
function isAllowedAvatarTarget(url, allowlist) {
|
|
68
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return false;
|
|
69
|
+
if (url.port !== "") return false;
|
|
70
|
+
return allowlist.some(
|
|
71
|
+
(host) => url.hostname === host || url.hostname.endsWith(`.${host}`),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The avatar allowlist is checked once, against the URL the caller asked for.
|
|
76
|
+
// `fetch(..., { redirect: "follow" })` then goes wherever the response points and
|
|
77
|
+
// validates nothing further — so an allowlisted CDN issuing a redirect, or anyone
|
|
78
|
+
// able to place one there, turns this loopback server into a way to reach
|
|
79
|
+
// 169.254.169.254, another service on 127.0.0.1, or any internal address.
|
|
80
|
+
// Follow by hand and re-check the whole address at every hop.
|
|
81
|
+
async function fetchAvatarFollowingAllowlist(url, options, allowlist, fetchImpl = fetch) {
|
|
82
|
+
let current = url;
|
|
83
|
+
for (let hop = 0; hop <= AVATAR_MAX_REDIRECTS; hop += 1) {
|
|
84
|
+
const response = await fetchImpl(current, { ...options, redirect: "manual" });
|
|
85
|
+
if (response.status < 300 || response.status >= 400) return response;
|
|
86
|
+
const location = response.headers.get("location");
|
|
87
|
+
if (!location) return response;
|
|
88
|
+
let next;
|
|
89
|
+
try {
|
|
90
|
+
next = new URL(location, current);
|
|
91
|
+
} catch {
|
|
92
|
+
return AVATAR_REDIRECT_BLOCKED;
|
|
93
|
+
}
|
|
94
|
+
if (!isAllowedAvatarTarget(next, allowlist)) return AVATAR_REDIRECT_BLOCKED;
|
|
95
|
+
current = next.toString();
|
|
96
|
+
}
|
|
97
|
+
return AVATAR_REDIRECT_BLOCKED;
|
|
98
|
+
}
|
|
99
|
+
|
|
23
100
|
// ---------------------------------------------------------------------------
|
|
24
101
|
// Per-model pricing — delegated to src/lib/pricing/
|
|
25
102
|
// - CURATED overrides (kiro-*, hy3-*, composer-*, kimi-for-coding, etc.)
|
|
@@ -67,7 +144,12 @@ function readProjectQueueData(projectQueuePath) {
|
|
|
67
144
|
for (const line of lines) {
|
|
68
145
|
try {
|
|
69
146
|
const row = JSON.parse(line);
|
|
70
|
-
|
|
147
|
+
// Model is part of the identity now. A legacy row has no `model` and gets
|
|
148
|
+
// the same "unknown" slot the state migration gives its stranded bucket,
|
|
149
|
+
// so the two share one key instead of both surviving and double-counting.
|
|
150
|
+
const key =
|
|
151
|
+
`${row.project_key || ""}|${row.source || ""}` +
|
|
152
|
+
`|${row.model || "unknown"}|${row.hour_start || ""}`;
|
|
71
153
|
seen.set(key, row);
|
|
72
154
|
} catch {
|
|
73
155
|
// skip malformed
|
|
@@ -687,6 +769,182 @@ const HOP_BY_HOP_HEADERS = new Set([
|
|
|
687
769
|
// Main handler factory
|
|
688
770
|
// ---------------------------------------------------------------------------
|
|
689
771
|
|
|
772
|
+
// Project usage, filtered the way every other card is filtered.
|
|
773
|
+
//
|
|
774
|
+
// Extracted from the request handler so the aggregation can be tested without
|
|
775
|
+
// standing up a server — and because the handler block reached 129 lines once it
|
|
776
|
+
// stopped ignoring the query string.
|
|
777
|
+
// The client has always sent from/to/source/limit/timeZone; the project handler
|
|
778
|
+
// read none of them and aggregated the whole file unconditionally. Pick "24h"
|
|
779
|
+
// and every other card narrowed while Projects kept showing all-time totals,
|
|
780
|
+
// with nothing on screen saying so — someone comparing "this week's spend by
|
|
781
|
+
// repo" was reading lifetime numbers.
|
|
782
|
+
//
|
|
783
|
+
// Pure surfacing: hour_start is already on every row. Same shape as the other
|
|
784
|
+
// range-filtered handlers (getTimeZoneContext + rowDayKey), so a day boundary
|
|
785
|
+
// means the same thing on every card.
|
|
786
|
+
function buildProjectFilters(url) {
|
|
787
|
+
const from = url.searchParams.get("from") || "";
|
|
788
|
+
const to = url.searchParams.get("to") || "";
|
|
789
|
+
const timeZoneContext = getTimeZoneContext(url);
|
|
790
|
+
const requestedSource = (url.searchParams.get("source") || "").trim().toLowerCase();
|
|
791
|
+
const rawLimit = Number(url.searchParams.get("limit"));
|
|
792
|
+
|
|
793
|
+
return {
|
|
794
|
+
limit: Number.isFinite(rawLimit) && rawLimit > 0 ? Math.floor(rawLimit) : null,
|
|
795
|
+
// An absent bound means "no bound". The other handlers compare against ""
|
|
796
|
+
// directly, which is safe there because the client always sends both; here
|
|
797
|
+
// it does not, and `day <= ""` would silently return nothing.
|
|
798
|
+
inWindow(row) {
|
|
799
|
+
if (!from && !to) return true;
|
|
800
|
+
if (!row.hour_start) return false;
|
|
801
|
+
const day = rowDayKey(row, timeZoneContext);
|
|
802
|
+
return (!from || day >= from) && (!to || day <= to);
|
|
803
|
+
},
|
|
804
|
+
matchesSource(row) {
|
|
805
|
+
return !requestedSource || String(row.source || "").toLowerCase() === requestedSource;
|
|
806
|
+
},
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// Which sources contributed usage in this window but carry no project
|
|
811
|
+
// attribution at all. Without naming them, their absence reads as "that tool
|
|
812
|
+
// cost nothing here" — the panel under-reports and looks complete while doing
|
|
813
|
+
// it. `projectBucketsQueued` exists in 7 of the parsers; Cursor, Copilot, Zed,
|
|
814
|
+
// Goose and Kiro have no per-repo story at all.
|
|
815
|
+
function findUnattributedSources({ queuePath, allProjectRows, inWindow, matchesSource }) {
|
|
816
|
+
const attributed = new Set(
|
|
817
|
+
allProjectRows.filter(inWindow).map((row) => String(row.source || "").toLowerCase()),
|
|
818
|
+
);
|
|
819
|
+
return [
|
|
820
|
+
...new Set(
|
|
821
|
+
readQueueData(queuePath)
|
|
822
|
+
.filter((row) => inWindow(row) && matchesSource(row))
|
|
823
|
+
.map((row) => String(row.source || "").toLowerCase())
|
|
824
|
+
.filter((src) => src && !attributed.has(src)),
|
|
825
|
+
),
|
|
826
|
+
].sort();
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// No project-attributed rows at all (project attribution never synced, or only
|
|
830
|
+
// non-project-capable CLIs used). Falls back to per-source totals so the panel
|
|
831
|
+
// is not simply empty. This path once ALSO ran for the non-empty case and
|
|
832
|
+
// produced fiction — "session-file count x total tokens" gave every short-and-
|
|
833
|
+
// hot project the same weight as every long-and-cold one. Empty case only.
|
|
834
|
+
function aggregateBySource(queuePath, inWindow, matchesSource) {
|
|
835
|
+
const bySrc = new Map();
|
|
836
|
+
for (const row of readQueueData(queuePath)) {
|
|
837
|
+
if (!inWindow(row) || !matchesSource(row)) continue;
|
|
838
|
+
const src = row.source || "unknown";
|
|
839
|
+
if (!bySrc.has(src)) {
|
|
840
|
+
bySrc.set(src, {
|
|
841
|
+
project_key: src,
|
|
842
|
+
// Synthetic source-only row: project_ref stays empty rather than
|
|
843
|
+
// fabricating `https://${src}.ai`, which resolves to unrelated domains
|
|
844
|
+
// (codex.ai, cursor.ai) and was sent to the dashboard as a clickable
|
|
845
|
+
// href before v0.11.1.
|
|
846
|
+
project_ref: "",
|
|
847
|
+
total_tokens: 0,
|
|
848
|
+
billable_total_tokens: 0,
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
bySrc.get(src).total_tokens += row.total_tokens || 0;
|
|
852
|
+
bySrc.get(src).billable_total_tokens += row.total_tokens || 0;
|
|
853
|
+
}
|
|
854
|
+
return bySrc;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Per-repo cost, which the data model could not express until project rows
|
|
858
|
+
// carried a model. computeRowCost is the same function the rest of the product
|
|
859
|
+
// prices with, so a repo total and a model total cannot disagree.
|
|
860
|
+
//
|
|
861
|
+
// A row with no model prices at 0 and is counted as UNATTRIBUTED rather than
|
|
862
|
+
// folded silently into the total — that is the #94 tier doing exactly what it
|
|
863
|
+
// was added for. "Recorded before we recorded models" renders honestly instead
|
|
864
|
+
// of as a confident $0.
|
|
865
|
+
function aggregateByProject(rows) {
|
|
866
|
+
const byProject = new Map();
|
|
867
|
+
for (const row of rows) {
|
|
868
|
+
const key = row.project_key || "unknown";
|
|
869
|
+
if (!byProject.has(key)) {
|
|
870
|
+
byProject.set(key, {
|
|
871
|
+
project_key: key,
|
|
872
|
+
project_ref: row.project_ref || key,
|
|
873
|
+
total_tokens: 0,
|
|
874
|
+
billable_total_tokens: 0,
|
|
875
|
+
total_cost_usd: 0,
|
|
876
|
+
unattributed_tokens: 0,
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
const agg = byProject.get(key);
|
|
880
|
+
const tokens = Number(row.total_tokens || 0);
|
|
881
|
+
agg.total_tokens += tokens;
|
|
882
|
+
agg.billable_total_tokens += tokens;
|
|
883
|
+
const model = typeof row.model === "string" ? row.model.trim() : "";
|
|
884
|
+
if (!model || model === "unknown") {
|
|
885
|
+
agg.unattributed_tokens += tokens;
|
|
886
|
+
} else {
|
|
887
|
+
agg.total_cost_usd += computeRowCost(row);
|
|
888
|
+
}
|
|
889
|
+
if (!agg.project_ref && row.project_ref) agg.project_ref = row.project_ref;
|
|
890
|
+
}
|
|
891
|
+
return byProject;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// Rows -> ranked entries, in the shape the panel already renders (token counts
|
|
895
|
+
// as strings). Split out so buildProjectUsageSummary stays under the size rule.
|
|
896
|
+
function rankProjectEntries(byKey) {
|
|
897
|
+
return Array.from(byKey.values())
|
|
898
|
+
.sort((a, b) => b.billable_total_tokens - a.billable_total_tokens)
|
|
899
|
+
.map((e) => ({
|
|
900
|
+
...e,
|
|
901
|
+
total_tokens: String(e.total_tokens),
|
|
902
|
+
billable_total_tokens: String(e.billable_total_tokens),
|
|
903
|
+
// Strings for the same reason the token counts are: the panel formats
|
|
904
|
+
// them, and a float in JSON invites a rounding difference between the two
|
|
905
|
+
// sides of the wire.
|
|
906
|
+
total_cost_usd: (e.total_cost_usd ?? 0).toFixed(6),
|
|
907
|
+
unattributed_tokens: String(e.unattributed_tokens ?? 0),
|
|
908
|
+
}));
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function buildProjectUsageSummary({ url, queuePath, projectQueuePath }) {
|
|
912
|
+
// Use the per-project bucket log that rollout.js emits — it already
|
|
913
|
+
// carries the actual tokens attributed to each (project_key, source,
|
|
914
|
+
// hour_start). Falling back to "session-file count × total tokens"
|
|
915
|
+
// (the old behavior) produced pure fiction: every short-and-hot
|
|
916
|
+
// project got the same weight as every long-and-cold one.
|
|
917
|
+
const allProjectRows = readProjectQueueData(projectQueuePath);
|
|
918
|
+
|
|
919
|
+
const { inWindow, matchesSource, limit } = buildProjectFilters(url);
|
|
920
|
+
|
|
921
|
+
const projectRows = allProjectRows.filter((row) => inWindow(row) && matchesSource(row));
|
|
922
|
+
|
|
923
|
+
const unattributedSources = findUnattributedSources({
|
|
924
|
+
queuePath,
|
|
925
|
+
allProjectRows,
|
|
926
|
+
inWindow,
|
|
927
|
+
matchesSource,
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
const byProject = aggregateByProject(projectRows);
|
|
931
|
+
|
|
932
|
+
let entries =
|
|
933
|
+
byProject.size === 0
|
|
934
|
+
? rankProjectEntries(aggregateBySource(queuePath, inWindow, matchesSource))
|
|
935
|
+
: rankProjectEntries(byProject);
|
|
936
|
+
|
|
937
|
+
if (limit != null) entries = entries.slice(0, limit);
|
|
938
|
+
|
|
939
|
+
return {
|
|
940
|
+
generated_at: new Date().toISOString(),
|
|
941
|
+
entries,
|
|
942
|
+
// Named so the panel can say what it cannot account for, rather than
|
|
943
|
+
// letting a missing tool read as a tool that cost nothing.
|
|
944
|
+
unattributed_sources: unattributedSources,
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
|
|
690
948
|
function createLocalApiHandler({ queuePath }) {
|
|
691
949
|
const qp = queuePath || resolveQueuePath();
|
|
692
950
|
|
|
@@ -833,11 +1091,8 @@ function createLocalApiHandler({ queuePath }) {
|
|
|
833
1091
|
"abs.twimg.com",
|
|
834
1092
|
"api.dicebear.com",
|
|
835
1093
|
];
|
|
836
|
-
|
|
837
|
-
(
|
|
838
|
-
);
|
|
839
|
-
if (!hostOk) {
|
|
840
|
-
json(res, { error: "Host not allowed" }, 403);
|
|
1094
|
+
if (!isAllowedAvatarTarget(parsed, AVATAR_HOST_ALLOWLIST)) {
|
|
1095
|
+
json(res, { error: "Address not allowed" }, 403);
|
|
841
1096
|
return true;
|
|
842
1097
|
}
|
|
843
1098
|
|
|
@@ -855,15 +1110,22 @@ function createLocalApiHandler({ queuePath }) {
|
|
|
855
1110
|
}
|
|
856
1111
|
|
|
857
1112
|
try {
|
|
858
|
-
const upstream = await
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
1113
|
+
const upstream = await fetchAvatarFollowingAllowlist(
|
|
1114
|
+
cacheKey,
|
|
1115
|
+
{
|
|
1116
|
+
method,
|
|
1117
|
+
headers: {
|
|
1118
|
+
accept: req.headers["accept"] || "image/*",
|
|
1119
|
+
"accept-language": req.headers["accept-language"] || "en",
|
|
1120
|
+
"user-agent": "TokenTracker/AvatarProxy",
|
|
1121
|
+
},
|
|
865
1122
|
},
|
|
866
|
-
|
|
1123
|
+
AVATAR_HOST_ALLOWLIST,
|
|
1124
|
+
);
|
|
1125
|
+
if (upstream === AVATAR_REDIRECT_BLOCKED) {
|
|
1126
|
+
json(res, { error: "Redirect target not allowed" }, 403);
|
|
1127
|
+
return true;
|
|
1128
|
+
}
|
|
867
1129
|
if (!upstream.ok) {
|
|
868
1130
|
json(res, { error: `Upstream ${upstream.status}` }, upstream.status);
|
|
869
1131
|
return true;
|
|
@@ -873,15 +1135,21 @@ function createLocalApiHandler({ queuePath }) {
|
|
|
873
1135
|
json(res, { error: "Not an image" }, 415);
|
|
874
1136
|
return true;
|
|
875
1137
|
}
|
|
876
|
-
const body =
|
|
877
|
-
if (body
|
|
878
|
-
//
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
avatarProxyCache.set(cacheKey, { body, contentType, fetchedAt: now });
|
|
1138
|
+
const body = await readCappedAvatarBody(upstream, AVATAR_PROXY_MAX_BYTES);
|
|
1139
|
+
if (body === null) {
|
|
1140
|
+
// Refused rather than served-but-not-cached, which is what the old
|
|
1141
|
+
// length check amounted to. An avatar this size is not an avatar, and
|
|
1142
|
+
// the dashboard falls back to its local icon on a failed image load.
|
|
1143
|
+
json(res, { error: "Avatar too large" }, 413);
|
|
1144
|
+
return true;
|
|
884
1145
|
}
|
|
1146
|
+
// Within the cap by construction, so caching is unconditional.
|
|
1147
|
+
// Simple LRU: drop oldest if over capacity.
|
|
1148
|
+
if (avatarProxyCache.size >= AVATAR_PROXY_MAX_ENTRIES) {
|
|
1149
|
+
const oldestKey = avatarProxyCache.keys().next().value;
|
|
1150
|
+
if (oldestKey) avatarProxyCache.delete(oldestKey);
|
|
1151
|
+
}
|
|
1152
|
+
avatarProxyCache.set(cacheKey, { body, contentType, fetchedAt: now });
|
|
885
1153
|
res.writeHead(200, {
|
|
886
1154
|
"Content-Type": contentType,
|
|
887
1155
|
"Cache-Control": "public, max-age=3600",
|
|
@@ -1230,78 +1498,14 @@ function createLocalApiHandler({ queuePath }) {
|
|
|
1230
1498
|
|
|
1231
1499
|
// --- project-usage-summary ---
|
|
1232
1500
|
if (p === "/functions/tokentracker-project-usage-summary") {
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
"project.queue.jsonl",
|
|
1501
|
+
json(
|
|
1502
|
+
res,
|
|
1503
|
+
buildProjectUsageSummary({
|
|
1504
|
+
url,
|
|
1505
|
+
queuePath: qp,
|
|
1506
|
+
projectQueuePath: path.join(path.dirname(qp), "project.queue.jsonl"),
|
|
1507
|
+
}),
|
|
1241
1508
|
);
|
|
1242
|
-
const projectRows = readProjectQueueData(projectQueuePath);
|
|
1243
|
-
|
|
1244
|
-
const byProject = new Map();
|
|
1245
|
-
for (const row of projectRows) {
|
|
1246
|
-
const key = row.project_key || "unknown";
|
|
1247
|
-
if (!byProject.has(key)) {
|
|
1248
|
-
byProject.set(key, {
|
|
1249
|
-
project_key: key,
|
|
1250
|
-
project_ref: row.project_ref || key,
|
|
1251
|
-
total_tokens: 0,
|
|
1252
|
-
billable_total_tokens: 0,
|
|
1253
|
-
});
|
|
1254
|
-
}
|
|
1255
|
-
const agg = byProject.get(key);
|
|
1256
|
-
agg.total_tokens += Number(row.total_tokens || 0);
|
|
1257
|
-
agg.billable_total_tokens += Number(row.total_tokens || 0);
|
|
1258
|
-
if (!agg.project_ref && row.project_ref) agg.project_ref = row.project_ref;
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
|
-
// If no project-attributed rows exist yet (user hasn't synced project
|
|
1262
|
-
// attribution, or never used a project-capable CLI), fall back to
|
|
1263
|
-
// per-source aggregation over the main queue so the panel isn't
|
|
1264
|
-
// totally empty. This path used to also exist for the non-empty case
|
|
1265
|
-
// and produce wrong numbers; keep it only as the empty fallback.
|
|
1266
|
-
let entries;
|
|
1267
|
-
if (byProject.size === 0) {
|
|
1268
|
-
const rows = readQueueData(qp);
|
|
1269
|
-
const bySrc = new Map();
|
|
1270
|
-
for (const row of rows) {
|
|
1271
|
-
const src = row.source || "unknown";
|
|
1272
|
-
if (!bySrc.has(src)) {
|
|
1273
|
-
bySrc.set(src, {
|
|
1274
|
-
project_key: src,
|
|
1275
|
-
// Synthetic source-only row: leave project_ref empty rather than
|
|
1276
|
-
// fabricating `https://${src}.ai`, which resolves to unrelated
|
|
1277
|
-
// domains (e.g. codex.ai, cursor.ai) and was sent to the
|
|
1278
|
-
// dashboard as a clickable href before v0.11.1 / this commit.
|
|
1279
|
-
project_ref: "",
|
|
1280
|
-
total_tokens: 0,
|
|
1281
|
-
billable_total_tokens: 0,
|
|
1282
|
-
});
|
|
1283
|
-
}
|
|
1284
|
-
bySrc.get(src).total_tokens += row.total_tokens || 0;
|
|
1285
|
-
bySrc.get(src).billable_total_tokens += row.total_tokens || 0;
|
|
1286
|
-
}
|
|
1287
|
-
entries = Array.from(bySrc.values())
|
|
1288
|
-
.sort((a, b) => b.billable_total_tokens - a.billable_total_tokens)
|
|
1289
|
-
.map((e) => ({
|
|
1290
|
-
...e,
|
|
1291
|
-
total_tokens: String(e.total_tokens),
|
|
1292
|
-
billable_total_tokens: String(e.billable_total_tokens),
|
|
1293
|
-
}));
|
|
1294
|
-
} else {
|
|
1295
|
-
entries = Array.from(byProject.values())
|
|
1296
|
-
.sort((a, b) => b.billable_total_tokens - a.billable_total_tokens)
|
|
1297
|
-
.map((e) => ({
|
|
1298
|
-
...e,
|
|
1299
|
-
total_tokens: String(e.total_tokens),
|
|
1300
|
-
billable_total_tokens: String(e.billable_total_tokens),
|
|
1301
|
-
}));
|
|
1302
|
-
}
|
|
1303
|
-
|
|
1304
|
-
json(res, { generated_at: new Date().toISOString(), entries });
|
|
1305
1509
|
return true;
|
|
1306
1510
|
}
|
|
1307
1511
|
|
|
@@ -1534,7 +1738,16 @@ function createLocalApiHandler({ queuePath }) {
|
|
|
1534
1738
|
}
|
|
1535
1739
|
|
|
1536
1740
|
module.exports = {
|
|
1741
|
+
// Exported for tests: the aggregation runs without a server.
|
|
1742
|
+
buildProjectUsageSummary,
|
|
1537
1743
|
createLocalApiHandler,
|
|
1744
|
+
// Exported so the redirect allowlist can be tested directly: the SSRF this
|
|
1745
|
+
// closes is only observable across a redirect hop, which no handler-level
|
|
1746
|
+
// test reaches.
|
|
1747
|
+
fetchAvatarFollowingAllowlist,
|
|
1748
|
+
readCappedAvatarBody,
|
|
1749
|
+
isAllowedAvatarTarget,
|
|
1750
|
+
AVATAR_REDIRECT_BLOCKED,
|
|
1538
1751
|
resolveQueuePath,
|
|
1539
1752
|
// Exported for cross-consumer tests (pricing + native contract lock).
|
|
1540
1753
|
MODEL_PRICING,
|