From d55f0e1d543d6dc7fcada9107ad95c6a6d247230 Mon Sep 17 00:00:00 2001 From: "itarun.p" Date: Sun, 26 Jul 2026 06:16:13 +0700 Subject: [PATCH] feat(projects): per-repo cost, and the mixed-era rule that makes it safe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the cost half of issue 102. The surfacing half shipped in #118. Project rows were keyed project|source|hour and carried NO model, so per-repo cost was not merely unimplemented — it was absent from the data model. The README's "which repo, which model, and which hour" promised a repo x model join that had never existed. The key is now project|source|model|hour and the row carries the model, so computeRowCost — the same function the rest of the product prices with — works per project. A repo total and a model total cannot disagree, because they come from one function. THE TRANSITION IS THE WHOLE JOB, and it is the part that can be wrong in a way nobody notices. A legacy row (project|source|hour) and a new row (project|source|model|hour) describe the SAME bucket at different granularity. Keep both and you double-count; drop either and you lose real usage. Neither is acceptable, and neither is what happens here. The bucket state in cursors.json is re-keyed on load: an old bucket becomes the `unknown` slot for its hour, keeping its running total AND its queuedKey. It then stops receiving new usage — that goes to the model-specific bucket — so it is frozen at its pre-upgrade total and the two sum to the true number. The reader keys model-less rows to the same `unknown` slot, so a legacy row and its re-keyed bucket are one identity rather than two. Without the re-key the old bucket would strand: its key never matches again, new usage restarts from zero under the model key, and the reader still sees the old row. That is the double count, and it is why this is a state migration rather than a key change. Migration runs in normalizeProjectState, which every parser goes through, so one call covers all seven. Idempotent — a four-segment key is skipped — and it refuses to clobber an existing unattributed bucket, leaving the legacy one in place for a human rather than silently discarding either. COST IS HONEST ABOUT WHAT IT CANNOT PRICE. A row with no model prices at 0 and is counted as `unattributed_tokens` rather than folded into the total. The panel shows "no model recorded" instead of "$0.00" for a repo that is entirely pre-model, and "$3.00+" for one that is partly priced. Pricing those at zero would render "recorded before we recorded models" as "this cost nothing" — the exact tier issue 94 was added to avoid. 19 new tests. The mixed-era ones are the point: a legacy row and a model row for one bucket count exactly once each; repeated appends of one key still collapse; two models in one hour are two slots; a stranded bucket keeps its total and its dedup marker through the re-key; running the migration twice changes nothing. ci:local exit 0: 948 root tests (+15), 266 dashboard (+4). --- dashboard/src/content/copy.csv | 1 + .../ui/dashboard/components/DataDetails.jsx | 24 +++- .../components/__tests__/DataDetails.test.jsx | 46 +++++++ openwiki-facts/source-facts.json | 64 +++++----- src/lib/local-api.js | 33 +++++- src/lib/rollout.js | 88 ++++++++++++-- test/project-model-key-migration.test.js | 107 +++++++++++++++++ test/project-usage-filters.test.js | 112 ++++++++++++++++++ 8 files changed, 426 insertions(+), 49 deletions(-) create mode 100644 test/project-model-key-migration.test.js diff --git a/dashboard/src/content/copy.csv b/dashboard/src/content/copy.csv index 526fac45..884ed1d9 100644 --- a/dashboard/src/content/copy.csv +++ b/dashboard/src/content/copy.csv @@ -57,6 +57,7 @@ dashboard.projects.limit_top_6,ui,ProjectUsagePanel,ProjectUsagePanel,limit_top_ dashboard.projects.limit_top_10,ui,ProjectUsagePanel,ProjectUsagePanel,limit_top_10,TOP 10,,active dashboard.projects.empty,ui,ProjectUsagePanel,ProjectUsagePanel,empty,No public repositories,,active dashboard.projects.unattributed,ui,ProjectUsagePanel,ProjectUsagePanel,unattributed,Not counted per repo:,Prefix for the list of sources with usage but no project attribution,active +dashboard.projects.unpriced,ui,ProjectUsagePanel,ProjectUsagePanel,unpriced,no model recorded,Shown instead of a cost when every row for this repo predates per-model project attribution,active dashboard.widgets.title,dashboard,DashboardPage,WidgetOnboardingCard,title,Desktop Widgets,,active dashboard.widgets.hint,dashboard,DashboardPage,WidgetOnboardingCard,hint,"Right-click your desktop → Edit Widgets → search ""TokenTracker""",,active dashboard.widgets.dismiss_aria,dashboard,DashboardPage,WidgetOnboardingCard,dismiss_aria,Dismiss widget intro,,active diff --git a/dashboard/src/ui/dashboard/components/DataDetails.jsx b/dashboard/src/ui/dashboard/components/DataDetails.jsx index 615ea67a..fa27c93c 100644 --- a/dashboard/src/ui/dashboard/components/DataDetails.jsx +++ b/dashboard/src/ui/dashboard/components/DataDetails.jsx @@ -151,6 +151,19 @@ export function DataDetails({ const n = Number(raw); const tokenLabel = Number.isFinite(n) ? n.toLocaleString() : "—"; const projectLabel = entry?.project_key || ref.split("/").pop() || "—"; + // Cost is per-model, and project rows only started carrying a model + // recently. Rows written before that price at 0 and are counted as + // unattributed rather than folded in — so a repo that is entirely + // pre-model shows no cost line at all instead of a confident $0. + const cost = Number(entry?.total_cost_usd); + const unpriced = Number(entry?.unattributed_tokens); + const hasCost = Number.isFinite(cost) && cost > 0; + const hasUnpriced = Number.isFinite(unpriced) && unpriced > 0; + const costLabel = hasCost + ? `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(2)}${hasUnpriced ? "+" : ""}` + : hasUnpriced + ? copy("dashboard.projects.unpriced") + : ""; return (
-
- {tokenLabel} +
+
+ {tokenLabel} +
+ {costLabel ? ( +
+ {costLabel} +
+ ) : null}
); diff --git a/dashboard/src/ui/dashboard/components/__tests__/DataDetails.test.jsx b/dashboard/src/ui/dashboard/components/__tests__/DataDetails.test.jsx index df8ef2ca..45cce9dd 100644 --- a/dashboard/src/ui/dashboard/components/__tests__/DataDetails.test.jsx +++ b/dashboard/src/ui/dashboard/components/__tests__/DataDetails.test.jsx @@ -142,3 +142,49 @@ describe("DataDetails — sources with no per-repo attribution", () => { expect(screen.queryByText("dashboard.projects.unattributed")).toBeNull(); }); }); + +describe("DataDetails — per-repo cost", () => { + function projectsTab(entry) { + renderDetails({ projectEntries: [entry] }); + fireEvent.click(screen.getByText("Project Usage")); + } + + it("shows the cost when the rows carry a model", () => { + projectsTab({ + project_key: "acme/api", + total_tokens: "1000000", + total_cost_usd: "3.000000", + unattributed_tokens: "0", + }); + expect(screen.getByText("$3.00")).toBeTruthy(); + }); + + it("marks a repo whose rows all predate per-model attribution", () => { + // Pricing those at zero and showing "$0.00" would read as "this cost + // nothing" rather than "we recorded this before we recorded models". + projectsTab({ + project_key: "acme/legacy", + total_tokens: "5000", + total_cost_usd: "0.000000", + unattributed_tokens: "5000", + }); + expect(screen.queryByText("$0.00")).toBeNull(); + expect(screen.getByText("dashboard.projects.unpriced")).toBeTruthy(); + }); + + it("flags a partly-priced repo rather than implying the figure is complete", () => { + projectsTab({ + project_key: "acme/mixed", + total_tokens: "1000700", + total_cost_usd: "3.000000", + unattributed_tokens: "700", + }); + expect(screen.getByText("$3.00+")).toBeTruthy(); + }); + + it("shows no cost line at all when the server sent none", () => { + // Older server, newer dashboard: absent is not zero. + projectsTab({ project_key: "acme/api", total_tokens: "1000" }); + expect(screen.queryByText(/^\$/)).toBeNull(); + }); +}); diff --git a/openwiki-facts/source-facts.json b/openwiki-facts/source-facts.json index 058a7bac..0117e0a4 100644 --- a/openwiki-facts/source-facts.json +++ b/openwiki-facts/source-facts.json @@ -44,7 +44,7 @@ "methods": [ "POST" ], - "evidence": "src/lib/local-api.js:1139", + "evidence": "src/lib/local-api.js:1166", "mutation": true }, { @@ -52,7 +52,7 @@ "methods": [ "GET" ], - "evidence": "src/lib/local-api.js:1173", + "evidence": "src/lib/local-api.js:1200", "mutation": false }, { @@ -60,7 +60,7 @@ "methods": [ "GET" ], - "evidence": "src/lib/local-api.js:1184", + "evidence": "src/lib/local-api.js:1211", "mutation": false }, { @@ -68,7 +68,7 @@ "methods": [ "GET" ], - "evidence": "src/lib/local-api.js:1269", + "evidence": "src/lib/local-api.js:1296", "mutation": false }, { @@ -76,7 +76,7 @@ "methods": [ "GET" ], - "evidence": "src/lib/local-api.js:1280", + "evidence": "src/lib/local-api.js:1307", "mutation": false }, { @@ -84,7 +84,7 @@ "methods": [ "GET" ], - "evidence": "src/lib/local-api.js:1348", + "evidence": "src/lib/local-api.js:1375", "mutation": false }, { @@ -92,7 +92,7 @@ "methods": [ "GET" ], - "evidence": "src/lib/local-api.js:1427", + "evidence": "src/lib/local-api.js:1454", "mutation": false }, { @@ -100,7 +100,7 @@ "methods": [ "GET" ], - "evidence": "src/lib/local-api.js:1473", + "evidence": "src/lib/local-api.js:1500", "mutation": false }, { @@ -108,7 +108,7 @@ "methods": [ "GET" ], - "evidence": "src/lib/local-api.js:1486", + "evidence": "src/lib/local-api.js:1513", "mutation": false }, { @@ -116,7 +116,7 @@ "methods": [ "GET" ], - "evidence": "src/lib/local-api.js:1496", + "evidence": "src/lib/local-api.js:1523", "mutation": false }, { @@ -124,7 +124,7 @@ "methods": [ "GET" ], - "evidence": "src/lib/local-api.js:1506", + "evidence": "src/lib/local-api.js:1533", "mutation": false }, { @@ -133,7 +133,7 @@ "GET", "POST" ], - "evidence": "src/lib/local-api.js:1540", + "evidence": "src/lib/local-api.js:1567", "mutation": true }, { @@ -141,7 +141,7 @@ "methods": [ "GET" ], - "evidence": "src/lib/local-api.js:1690", + "evidence": "src/lib/local-api.js:1717", "mutation": false } ] @@ -214,79 +214,79 @@ }, { "name": "parseOpencodeDbIncremental", - "evidence": "src/lib/rollout.js:2556" + "evidence": "src/lib/rollout.js:2611" }, { "name": "parseCursorApiIncremental", - "evidence": "src/lib/rollout.js:2737" + "evidence": "src/lib/rollout.js:2795" }, { "name": "parseKiroIncremental", - "evidence": "src/lib/rollout.js:2995" + "evidence": "src/lib/rollout.js:3053" }, { "name": "parseHermesIncremental", - "evidence": "src/lib/rollout.js:3250" + "evidence": "src/lib/rollout.js:3308" }, { "name": "parseKiroCliIncremental", - "evidence": "src/lib/rollout.js:3869" + "evidence": "src/lib/rollout.js:3927" }, { "name": "parseKimiIncremental", - "evidence": "src/lib/rollout.js:4499" + "evidence": "src/lib/rollout.js:4557" }, { "name": "parseKimiCodeIncremental", - "evidence": "src/lib/rollout.js:4690" + "evidence": "src/lib/rollout.js:4748" }, { "name": "parseCodebuddyIncremental", - "evidence": "src/lib/rollout.js:4914" + "evidence": "src/lib/rollout.js:4972" }, { "name": "parseRoocodeIncremental", - "evidence": "src/lib/rollout.js:5417" + "evidence": "src/lib/rollout.js:5475" }, { "name": "parseZedIncremental", - "evidence": "src/lib/rollout.js:5734" + "evidence": "src/lib/rollout.js:5792" }, { "name": "parseGooseIncremental", - "evidence": "src/lib/rollout.js:6051" + "evidence": "src/lib/rollout.js:6109" }, { "name": "parseDroidIncremental", - "evidence": "src/lib/rollout.js:6428" + "evidence": "src/lib/rollout.js:6486" }, { "name": "parseKilocodeIncremental", - "evidence": "src/lib/rollout.js:6675" + "evidence": "src/lib/rollout.js:6733" }, { "name": "parseOmpIncremental", - "evidence": "src/lib/rollout.js:6811" + "evidence": "src/lib/rollout.js:6869" }, { "name": "parsePiIncremental", - "evidence": "src/lib/rollout.js:7064" + "evidence": "src/lib/rollout.js:7122" }, { "name": "parseCraftIncremental", - "evidence": "src/lib/rollout.js:7348" + "evidence": "src/lib/rollout.js:7406" }, { "name": "parseCopilotIncremental", - "evidence": "src/lib/rollout.js:7698" + "evidence": "src/lib/rollout.js:7756" }, { "name": "parseGrokBuildIncremental", - "evidence": "src/lib/rollout.js:8164" + "evidence": "src/lib/rollout.js:8222" }, { "name": "parseAntigravityIncremental", - "evidence": "src/lib/rollout.js:8336" + "evidence": "src/lib/rollout.js:8394" } ] } diff --git a/src/lib/local-api.js b/src/lib/local-api.js index f1fa7475..781ffedc 100644 --- a/src/lib/local-api.js +++ b/src/lib/local-api.js @@ -144,7 +144,12 @@ function readProjectQueueData(projectQueuePath) { for (const line of lines) { try { const row = JSON.parse(line); - const key = `${row.project_key || ""}|${row.source || ""}|${row.hour_start || ""}`; + // Model is part of the identity now. A legacy row has no `model` and gets + // the same "unknown" slot the state migration gives its stranded bucket, + // so the two share one key instead of both surviving and double-counting. + const key = + `${row.project_key || ""}|${row.source || ""}` + + `|${row.model || "unknown"}|${row.hour_start || ""}`; seen.set(key, row); } catch { // skip malformed @@ -849,6 +854,14 @@ function aggregateBySource(queuePath, inWindow, matchesSource) { return bySrc; } +// Per-repo cost, which the data model could not express until project rows +// carried a model. computeRowCost is the same function the rest of the product +// prices with, so a repo total and a model total cannot disagree. +// +// A row with no model prices at 0 and is counted as UNATTRIBUTED rather than +// folded silently into the total — that is the #94 tier doing exactly what it +// was added for. "Recorded before we recorded models" renders honestly instead +// of as a confident $0. function aggregateByProject(rows) { const byProject = new Map(); for (const row of rows) { @@ -859,11 +872,20 @@ function aggregateByProject(rows) { project_ref: row.project_ref || key, total_tokens: 0, billable_total_tokens: 0, + total_cost_usd: 0, + unattributed_tokens: 0, }); } const agg = byProject.get(key); - agg.total_tokens += Number(row.total_tokens || 0); - agg.billable_total_tokens += Number(row.total_tokens || 0); + const tokens = Number(row.total_tokens || 0); + agg.total_tokens += tokens; + agg.billable_total_tokens += tokens; + const model = typeof row.model === "string" ? row.model.trim() : ""; + if (!model || model === "unknown") { + agg.unattributed_tokens += tokens; + } else { + agg.total_cost_usd += computeRowCost(row); + } if (!agg.project_ref && row.project_ref) agg.project_ref = row.project_ref; } return byProject; @@ -878,6 +900,11 @@ function rankProjectEntries(byKey) { ...e, total_tokens: String(e.total_tokens), billable_total_tokens: String(e.billable_total_tokens), + // Strings for the same reason the token counts are: the panel formats + // them, and a float in JSON invites a rounding difference between the two + // sides of the wire. + total_cost_usd: (e.total_cost_usd ?? 0).toFixed(6), + unattributed_tokens: String(e.unattributed_tokens ?? 0), })); } diff --git a/src/lib/rollout.js b/src/lib/rollout.js index 38e637c8..56bb3abc 100644 --- a/src/lib/rollout.js +++ b/src/lib/rollout.js @@ -937,11 +937,12 @@ async function parseRolloutFile({ projectState, currentProjectKey, source, + model, bucketStart, currentProjectRef, ); addTotals(projectBucket.totals, delta); - projectTouchedBuckets.add(projectBucketKey(currentProjectKey, source, bucketStart)); + projectTouchedBuckets.add(projectBucketKey(currentProjectKey, source, model, bucketStart)); } eventsAggregated += 1; } @@ -1061,11 +1062,12 @@ async function parseClaudeFile({ projectState, projectKey, source, + model, bucketStart, projectRef, ); addTotals(projectBucket.totals, delta); - projectTouchedBuckets.add(projectBucketKey(projectKey, source, bucketStart)); + projectTouchedBuckets.add(projectBucketKey(projectKey, source, model, bucketStart)); } eventsAggregated += 1; } @@ -1145,11 +1147,12 @@ async function parseGeminiFile({ projectState, projectKey, source, + model, bucketStart, projectRef, ); addTotals(projectBucket.totals, delta); - projectTouchedBuckets.add(projectBucketKey(projectKey, source, bucketStart)); + projectTouchedBuckets.add(projectBucketKey(projectKey, source, model, bucketStart)); } eventsAggregated += 1; totals = currentTotals; @@ -1256,11 +1259,12 @@ async function parseOpencodeMessageFile({ projectState, projectKey, source, + model, bucketStart, projectRef, ); addTotals(projectBucket.totals, delta); - projectTouchedBuckets.add(projectBucketKey(projectKey, source, bucketStart)); + projectTouchedBuckets.add(projectBucketKey(projectKey, source, model, bucketStart)); } return { messageKey, lastTotals: currentTotals, eventsAggregated: 1, shouldUpdate: true }; } @@ -1598,6 +1602,7 @@ async function enqueueTouchedProjectBuckets({ project_ref: projectRef, project_key: projectKey, source: bucket.source, + model: bucket.model || PROJECT_MODEL_UNATTRIBUTED, hour_start: bucket.hour_start, input_tokens: totals.input_tokens, cached_input_tokens: totals.cached_input_tokens, @@ -1767,12 +1772,16 @@ function normalizeProjectState(raw) { projects[key] = { ...value }; } - return { - version: 2, + const normalized = { + version: 3, buckets, projects, updatedAt: typeof state.updatedAt === "string" ? state.updatedAt : null, }; + // Every parser reaches project state through here, so one call covers all of + // them. Idempotent: a v3 key already has four segments and is skipped. + migrateProjectBucketsToModelKey(normalized); + return normalized; } function normalizeOpencodeState(raw) { @@ -1819,10 +1828,11 @@ function getHourlyBucket(state, source, model, hourStart) { return bucket; } -function getProjectBucket(state, projectKey, source, hourStart, projectRef) { +function getProjectBucket(state, projectKey, source, model, hourStart, projectRef) { const buckets = state.buckets; const normalizedSource = normalizeSourceInput(source) || DEFAULT_SOURCE; - const key = projectBucketKey(projectKey, normalizedSource, hourStart); + const normalizedModel = normalizeModelInput(model) || PROJECT_MODEL_UNATTRIBUTED; + const key = projectBucketKey(projectKey, normalizedSource, normalizedModel, hourStart); let bucket = buckets[key]; if (!bucket || typeof bucket !== "object") { bucket = { @@ -1831,6 +1841,7 @@ function getProjectBucket(state, projectKey, source, hourStart, projectRef) { project_key: projectKey, project_ref: projectRef, source: normalizedSource, + model: normalizedModel, hour_start: hourStart, }; buckets[key] = bucket; @@ -1921,9 +1932,53 @@ function bucketKey(source, model, hourStart) { return `${safeSource}${BUCKET_SEPARATOR}${safeModel}${BUCKET_SEPARATOR}${hourStart}`; } -function projectBucketKey(projectKey, source, hourStart) { +// Per-repo COST is per-model, and the key had no model in it, so the repo x model +// join this product's README promises ("which repo, which model, and which +// hour") had never existed. Adding model is what makes computeRowCost usable +// per project. +// +// The transition hazard is the whole job. A legacy row keyed +// project|source|hour and a new row keyed project|source|model|hour describe the +// SAME bucket at different granularity, so a reader that keeps both +// double-counts. Rows written before this change are keyed with +// PROJECT_MODEL_UNATTRIBUTED, so they occupy one slot alongside the model-keyed +// rows rather than shadowing them — see migrateProjectBucketsToModelKey. +const PROJECT_MODEL_UNATTRIBUTED = "unknown"; + +function projectBucketKey(projectKey, source, model, hourStart) { const safeSource = normalizeSourceInput(source) || DEFAULT_SOURCE; - return `${projectKey}${BUCKET_SEPARATOR}${safeSource}${BUCKET_SEPARATOR}${hourStart}`; + const safeModel = normalizeModelInput(model) || PROJECT_MODEL_UNATTRIBUTED; + return ( + `${projectKey}${BUCKET_SEPARATOR}${safeSource}` + + `${BUCKET_SEPARATOR}${safeModel}${BUCKET_SEPARATOR}${hourStart}` + ); +} + +// Re-keys buckets carried in cursors.json from the pre-model key to the new one, +// tagging them PROJECT_MODEL_UNATTRIBUTED. +// +// Without this the old bucket keeps its old key, never matches again, and its +// running total is stranded: new usage in the same hour starts from zero under +// the model key while the reader still sees the old row. Sum = double count. +// +// After it, the stranded bucket becomes the `unknown` slot for that hour. It +// stops receiving new usage (that goes to the model-specific bucket), so it is +// frozen at its pre-upgrade total and the two sum to the right number — no +// double count, and nothing lost. +function migrateProjectBucketsToModelKey(projectState) { + const buckets = projectState?.buckets; + if (!buckets || typeof buckets !== "object") return 0; + let migrated = 0; + for (const [key, bucket] of Object.entries(buckets)) { + if (key.split(BUCKET_SEPARATOR).length !== 3) continue; + const [projectKey, source, hourStart] = key.split(BUCKET_SEPARATOR); + const nextKey = projectBucketKey(projectKey, source, PROJECT_MODEL_UNATTRIBUTED, hourStart); + if (buckets[nextKey]) continue; + buckets[nextKey] = { ...bucket, model: PROJECT_MODEL_UNATTRIBUTED }; + delete buckets[key]; + migrated += 1; + } + return migrated; } function groupBucketKey(source, hourStart) { @@ -2663,11 +2718,14 @@ async function parseOpencodeDbIncremental({ projectState, projectKey, defaultSource, + model, bucketStart, projectRef, ); addTotals(projectBucket.totals, delta); - projectTouchedBuckets.add(projectBucketKey(projectKey, defaultSource, bucketStart)); + projectTouchedBuckets.add( + projectBucketKey(projectKey, defaultSource, model, bucketStart), + ); } } @@ -8608,11 +8666,12 @@ async function parseAntigravityFile({ projectState, projectKey, source, + model, bucketStart, projectRef, ); addTotals(projectBucket.totals, delta); - projectTouchedBuckets.add(projectBucketKey(projectKey, source, bucketStart)); + projectTouchedBuckets.add(projectBucketKey(projectKey, source, model, bucketStart)); } eventsAggregated += 1; // Snapshot the pre-planner context first. The planner's own content+tool_calls @@ -8707,6 +8766,11 @@ function isCjkCodePoint(code) { } module.exports = { + // Exported for tests: the mixed-era rule is the risky part of this change. + projectBucketKey, + migrateProjectBucketsToModelKey, + normalizeProjectState, + PROJECT_MODEL_UNATTRIBUTED, listRolloutFiles, listClaudeProjectFiles, listGeminiSessionFiles, diff --git a/test/project-model-key-migration.test.js b/test/project-model-key-migration.test.js new file mode 100644 index 00000000..3d6ab3f3 --- /dev/null +++ b/test/project-model-key-migration.test.js @@ -0,0 +1,107 @@ +"use strict"; + +// The writer half of #102's mixed-era risk. The reader half is in +// test/project-usage-filters.test.js; this covers what happens to the bucket +// state carried in cursors.json when the key gains a model. + +const assert = require("node:assert/strict"); +const { test } = require("node:test"); + +const { + projectBucketKey, + migrateProjectBucketsToModelKey, + normalizeProjectState, + PROJECT_MODEL_UNATTRIBUTED, +} = require("../src/lib/rollout"); + +const LEGACY_KEY = "acme/api|claude|2026-05-14T09:00:00.000Z"; +const legacyState = () => ({ + version: 2, + buckets: { + [LEGACY_KEY]: { + totals: { total_tokens: 700, input_tokens: 700 }, + queuedKey: "x", + project_key: "acme/api", + project_ref: "https://github.com/acme/api", + source: "claude", + hour_start: "2026-05-14T09:00:00.000Z", + }, + }, + projects: {}, +}); + +test("the key gains a model, and a missing one is the unattributed slot", () => { + assert.equal( + projectBucketKey("acme/api", "claude", "claude-sonnet-5", "2026-05-14T09:00:00.000Z"), + "acme/api|claude|claude-sonnet-5|2026-05-14T09:00:00.000Z", + ); + assert.equal( + projectBucketKey("acme/api", "claude", null, "2026-05-14T09:00:00.000Z"), + `acme/api|claude|${PROJECT_MODEL_UNATTRIBUTED}|2026-05-14T09:00:00.000Z`, + ); +}); + +test("a stranded pre-model bucket is re-keyed rather than left behind", () => { + // Left behind, its key never matches again: new usage in that hour starts from + // zero under the model key while the reader still sees the old row, and the + // two sum to more than really happened. + const state = legacyState(); + assert.equal(migrateProjectBucketsToModelKey(state), 1); + assert.deepEqual(Object.keys(state.buckets), [ + `acme/api|claude|${PROJECT_MODEL_UNATTRIBUTED}|2026-05-14T09:00:00.000Z`, + ]); +}); + +test("the running total survives the re-key — nothing is reset", () => { + const state = legacyState(); + migrateProjectBucketsToModelKey(state); + const bucket = Object.values(state.buckets)[0]; + assert.equal(bucket.totals.total_tokens, 700, "the pre-upgrade total must carry over"); + assert.equal(bucket.model, PROJECT_MODEL_UNATTRIBUTED); + assert.equal(bucket.project_ref, "https://github.com/acme/api"); + assert.equal(bucket.queuedKey, "x", "the dedup marker must survive, or the row re-appends"); +}); + +test("running it twice changes nothing", () => { + // It runs on every normalizeProjectState, which is every sync. + const state = legacyState(); + migrateProjectBucketsToModelKey(state); + const after = JSON.stringify(state.buckets); + assert.equal(migrateProjectBucketsToModelKey(state), 0); + assert.equal(JSON.stringify(state.buckets), after); +}); + +test("a model-keyed bucket is left alone", () => { + const state = { + buckets: { + "acme/api|claude|claude-sonnet-5|2026-05-14T09:00:00.000Z": { totals: {}, model: "claude-sonnet-5" }, + }, + }; + const before = JSON.stringify(state.buckets); + assert.equal(migrateProjectBucketsToModelKey(state), 0); + assert.equal(JSON.stringify(state.buckets), before); +}); + +test("an existing unattributed bucket is not clobbered by the migration", () => { + // Both keys present at once should not silently discard the newer one. + const state = legacyState(); + const targetKey = `acme/api|claude|${PROJECT_MODEL_UNATTRIBUTED}|2026-05-14T09:00:00.000Z`; + state.buckets[targetKey] = { totals: { total_tokens: 42 }, model: PROJECT_MODEL_UNATTRIBUTED }; + migrateProjectBucketsToModelKey(state); + assert.equal(state.buckets[targetKey].totals.total_tokens, 42, "the existing bucket wins"); + assert.ok(state.buckets[LEGACY_KEY], "and the legacy one is left for a human to look at"); +}); + +test("normalizeProjectState runs the migration and bumps the version", () => { + const state = normalizeProjectState(legacyState()); + assert.equal(state.version, 3); + assert.deepEqual(Object.keys(state.buckets), [ + `acme/api|claude|${PROJECT_MODEL_UNATTRIBUTED}|2026-05-14T09:00:00.000Z`, + ]); +}); + +test("garbage state does not throw", () => { + for (const raw of [null, undefined, {}, { buckets: null }, { buckets: "nope" }]) { + assert.equal(migrateProjectBucketsToModelKey(raw), 0); + } +}); diff --git a/test/project-usage-filters.test.js b/test/project-usage-filters.test.js index bc111f3c..11206c74 100644 --- a/test/project-usage-filters.test.js +++ b/test/project-usage-filters.test.js @@ -181,3 +181,115 @@ test("an empty window returns no entries rather than falling back to everything" const result = run(paths, { from: "2026-07-01", to: "2026-07-02", timeZone: "UTC" }); assert.deepEqual(result.entries, []); }); + +// --- Mixed-era rows (#102's stated risk) -------------------------------------- +// Legacy `project|source|hour` and new `project|source|model|hour` describe the +// same bucket at different granularity. Summing both double-counts; dropping +// either loses real usage. This is the part of the change that can be wrong in a +// way nobody notices, so it gets the most tests. + +const legacyRow = (over = {}) => { + const row = projectRow(over); + delete row.model; + return row; +}; + +const modelRow = (over = {}) => projectRow({ model: "claude-sonnet-5", ...over }); + +test("a legacy row and a model row for the same bucket both count, exactly once", () => { + // The pre-upgrade total is frozen in the legacy row; new usage in that same + // hour accumulates under the model key. They are different slots on purpose, + // and the sum is the true total. + const paths = fixture({ + projectRows: [legacyRow({ total_tokens: 700 }), modelRow({ total_tokens: 300 })], + }); + const entries = run(paths, {}).entries; + assert.equal(entries.length, 1, "one repo, one row"); + assert.equal(entries[0].total_tokens, "1000", "700 pre-upgrade + 300 after"); +}); + +test("repeated appends of the SAME key still collapse to the last one", () => { + // The append-only store writes cumulative totals per bucket, so several rows + // with one key are normal and only the last is real. Adding model to the key + // must not break that. + const paths = fixture({ + projectRows: [ + modelRow({ total_tokens: 100 }), + modelRow({ total_tokens: 250 }), + modelRow({ total_tokens: 400 }), + ], + }); + assert.equal(run(paths, {}).entries[0].total_tokens, "400"); +}); + +test("two models in one hour are two slots, not one overwriting the other", () => { + const paths = fixture({ + projectRows: [ + modelRow({ model: "claude-sonnet-5", total_tokens: 100 }), + modelRow({ model: "claude-opus-5", total_tokens: 40 }), + ], + }); + assert.equal(run(paths, {}).entries[0].total_tokens, "140"); +}); + +test("legacy rows repeat-append too, and still collapse", () => { + // A user who has not synced since the upgrade has only legacy rows, several + // per bucket. They must not sum. + const paths = fixture({ + projectRows: [legacyRow({ total_tokens: 100 }), legacyRow({ total_tokens: 900 })], + }); + assert.equal(run(paths, {}).entries[0].total_tokens, "900"); +}); + +// --- Per-repo cost ------------------------------------------------------------- + +test("cost is computed per repo, from the model on the row", () => { + const paths = fixture({ + projectRows: [ + modelRow({ + model: "claude-sonnet-5", + input_tokens: 1_000_000, + output_tokens: 0, + cached_input_tokens: 0, + cache_creation_input_tokens: 0, + reasoning_output_tokens: 0, + total_tokens: 1_000_000, + }), + ], + }); + const entry = run(paths, {}).entries[0]; + assert.ok(Number(entry.total_cost_usd) > 0, `expected a priced cost, got ${entry.total_cost_usd}`); + assert.equal(entry.unattributed_tokens, "0"); +}); + +test("a legacy row is UNATTRIBUTED, not a confident $0", () => { + // The #94 tier doing what it was added for. Pricing a model-less row at zero + // and folding it into the total would render "recorded before we recorded + // models" as "this cost nothing". + const paths = fixture({ projectRows: [legacyRow({ total_tokens: 5000 })] }); + const entry = run(paths, {}).entries[0]; + assert.equal(entry.total_cost_usd, "0.000000"); + assert.equal(entry.unattributed_tokens, "5000", "the tokens must be named as unpriced"); + assert.equal(entry.total_tokens, "5000", "and still counted in the token total"); +}); + +test("a mixed repo reports the priced part and names the rest", () => { + const paths = fixture({ + projectRows: [ + legacyRow({ total_tokens: 700 }), + modelRow({ + model: "claude-sonnet-5", + input_tokens: 1_000_000, + output_tokens: 0, + cached_input_tokens: 0, + cache_creation_input_tokens: 0, + reasoning_output_tokens: 0, + total_tokens: 1_000_000, + }), + ], + }); + const entry = run(paths, {}).entries[0]; + assert.equal(entry.unattributed_tokens, "700"); + assert.ok(Number(entry.total_cost_usd) > 0); + assert.equal(entry.total_tokens, "1000700"); +});