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