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"); +});