diff --git a/.changeset/fn-7448-token-usage-last-30-days.md b/.changeset/fn-7448-token-usage-last-30-days.md new file mode 100644 index 0000000000..6a5d995e3a --- /dev/null +++ b/.changeset/fn-7448-token-usage-last-30-days.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix Last 30 days token usage to include every model in Command Center. +category: fix +dev: Corrects Command Center token analytics range attribution for durable multi-model task usage. diff --git a/packages/core/src/__tests__/token-analytics.test.ts b/packages/core/src/__tests__/token-analytics.test.ts index 5fdbda1500..07445d6de4 100644 --- a/packages/core/src/__tests__/token-analytics.test.ts +++ b/packages/core/src/__tests__/token-analytics.test.ts @@ -156,11 +156,11 @@ describe("token-analytics", () => { expect([...modelGroups.values()].reduce((sum, group) => sum + group.nTasks, 0)).toBe(2); expect(byModel.totals.nTasks).toBe(1); - const expectedTaskCost = costFor( - { inputTokens: 950, outputTokens: 450, cachedTokens: 0, cacheWriteTokens: 0 }, - { provider: "openai", model: "gpt-5" }, + expect(byModel.cost.usd).toBeCloseTo( + (modelGroups.get("claude-sonnet-4-5")?.cost.usd ?? 0) + (modelGroups.get("gpt-5")?.cost.usd ?? 0), + 10, ); - expect(byModel.cost).toEqual(expectedTaskCost); + expect(byModel.cost.unavailable).toBe(false); const byProvider = aggregateTokenAnalytics(db, { groupBy: "provider" }); expect(byProvider.totals).toEqual(byModel.totals); @@ -171,6 +171,121 @@ describe("token-analytics", () => { expect(byProvider.totals.nTasks).toBe(1); }); + it("filters Last 30 days model groups by durable per-model bucket timestamps", () => { + const from = "2026-06-02T00:00:00.000Z"; + const to = "2026-07-02T00:00:00.000Z"; + insertTask(db, { + id: "last-30-multi", + inputTokens: 1_520, + outputTokens: 730, + cachedTokens: 110, + cacheWriteTokens: 40, + totalTokens: 2_400, + lastUsedAt: "2026-07-05T00:00:00.000Z", + tokenUsageModelProvider: "openai", + tokenUsageModelId: "gpt-5", + tokenUsagePerModel: [ + { + modelProvider: "anthropic", + modelId: "claude-sonnet-4-5", + inputTokens: 700, + outputTokens: 300, + cachedTokens: 20, + cacheWriteTokens: 0, + totalTokens: 1_020, + firstUsedAt: "2026-06-02T00:00:00.000Z", + lastUsedAt: "2026-06-02T00:00:00.000Z", + }, + { + modelProvider: "openai", + modelId: "gpt-5", + inputTokens: 250, + outputTokens: 150, + cachedTokens: 10, + cacheWriteTokens: 0, + totalTokens: 410, + firstUsedAt: "2026-06-15T00:00:00.000Z", + lastUsedAt: "2026-06-15T00:00:00.000Z", + }, + { + modelProvider: "openai", + modelId: "gpt-5", + inputTokens: 50, + outputTokens: 50, + cachedTokens: 0, + cacheWriteTokens: 0, + totalTokens: 100, + firstUsedAt: "2026-07-02T00:00:00.000Z", + lastUsedAt: "2026-07-02T00:00:00.000Z", + }, + { + inputTokens: 25, + outputTokens: 25, + cachedTokens: 0, + cacheWriteTokens: 0, + totalTokens: 50, + firstUsedAt: "2026-06-20T00:00:00.000Z", + lastUsedAt: "2026-06-20T00:00:00.000Z", + }, + { + modelProvider: "zai", + modelId: "glm-outside", + inputTokens: 495, + outputTokens: 205, + cachedTokens: 80, + cacheWriteTokens: 40, + totalTokens: 820, + firstUsedAt: "2026-07-03T00:00:00.000Z", + lastUsedAt: "2026-07-03T00:00:00.000Z", + }, + ], + }); + insertTask(db, { + id: "malformed-fallback", + inputTokens: 40, + outputTokens: 10, + totalTokens: 50, + lastUsedAt: "2026-06-18T00:00:00.000Z", + tokenUsageModelProvider: "anthropic", + tokenUsageModelId: "claude-haiku-3-5", + tokenUsagePerModel: "not-json", + }); + insertTask(db, { + id: "legacy-missing-per-model", + inputTokens: 30, + outputTokens: 20, + totalTokens: 50, + lastUsedAt: "2026-06-19T00:00:00.000Z", + tokenUsageModelProvider: "openai", + tokenUsageModelId: "gpt-4o-mini", + }); + + const byModel = aggregateTokenAnalytics(db, { from, to, groupBy: "model", granularity: "day" }); + const groups = new Map(byModel.groups.map((group) => [group.key, group])); + + expect(groups.get("claude-sonnet-4-5")).toMatchObject({ totalTokens: 1_020, inputTokens: 700, nTasks: 1 }); + expect(groups.get("gpt-5")).toMatchObject({ totalTokens: 510, inputTokens: 300, nTasks: 1 }); + expect(groups.get(null)).toMatchObject({ totalTokens: 50, inputTokens: 25, nTasks: 1 }); + expect(groups.get("claude-haiku-3-5")).toMatchObject({ totalTokens: 50, nTasks: 1 }); + expect(groups.get("gpt-4o-mini")).toMatchObject({ totalTokens: 50, nTasks: 1 }); + expect(groups.has("glm-outside")).toBe(false); + expect(byModel.totals).toMatchObject({ inputTokens: 1_095, outputTokens: 555, cachedTokens: 30, cacheWriteTokens: 0, totalTokens: 1_680, nTasks: 3 }); + expect(byModel.series?.map((point) => [point.bucket, point.totalTokens])).toEqual([ + ["2026-06-02", 1_020], + ["2026-06-15", 410], + ["2026-06-18", 50], + ["2026-06-19", 50], + ["2026-06-20", 50], + ["2026-07-02", 100], + ]); + expect(byModel.cost.unavailable).toBe(true); + + const byProvider = aggregateTokenAnalytics(db, { from, to, groupBy: "provider" }); + expect(new Map(byProvider.groups.map((group) => [group.key, group.totalTokens]))).toEqual( + new Map([["anthropic", 1_070], ["openai", 560], [null, 50]]), + ); + }); + it("marks unpriced per-model buckets as cost unavailable instead of zero", () => { insertTask(db, { id: "unpriced-bucket", @@ -199,10 +314,7 @@ describe("token-analytics", () => { expect(result.groups).toHaveLength(1); expect(result.groups[0]).toMatchObject({ key: "unknown-model", totalTokens: 100, cost: { usd: null, unavailable: true } }); - expect(result.cost).toEqual(costFor( - { inputTokens: 60, outputTokens: 40, cachedTokens: 0, cacheWriteTokens: 0 }, - { provider: "openai", model: "gpt-5" }, - )); + expect(result.cost).toEqual({ usd: null, unavailable: true, stale: false }); }); it("falls back to the legacy snapshot when per-model JSON is malformed", () => { diff --git a/packages/core/src/token-analytics.ts b/packages/core/src/token-analytics.ts index 714fedddaa..09595762a3 100644 --- a/packages/core/src/token-analytics.ts +++ b/packages/core/src/token-analytics.ts @@ -101,6 +101,7 @@ function emptyTotals(): TokenTotals { } interface TaskTokenRow { + id: string; inputTokens: number | null; outputTokens: number | null; cachedTokens: number | null; @@ -192,12 +193,17 @@ function finalizeCost(acc: CostAccumulator): CostResult { }; } -function parsePerModelRows(row: TaskTokenRow): TaskTokenRow[] { - if (!row.tokenUsagePerModel) return []; +interface ParsedPerModelRows { + valid: boolean; + rows: TaskTokenRow[]; +} + +function parsePerModelRows(row: TaskTokenRow): ParsedPerModelRows { + if (!row.tokenUsagePerModel) return { valid: false, rows: [] }; try { const parsed = JSON.parse(row.tokenUsagePerModel) as unknown; - if (!Array.isArray(parsed) || parsed.length === 0) return []; - return parsed + if (!Array.isArray(parsed) || parsed.length === 0) return { valid: false, rows: [] }; + const rows = parsed .filter((entry): entry is Partial => entry !== null && typeof entry === "object") .map((entry) => { const inputTokens = Number.isFinite(entry.inputTokens) ? Number(entry.inputTokens) : 0; @@ -216,14 +222,20 @@ function parsePerModelRows(row: TaskTokenRow): TaskTokenRow[] { totalTokens, tokenUsageModelProvider: typeof entry.modelProvider === "string" ? entry.modelProvider : null, tokenUsageModelId: typeof entry.modelId === "string" ? entry.modelId : null, + tokenUsageLastUsedAt: typeof entry.lastUsedAt === "string" ? entry.lastUsedAt : row.tokenUsageLastUsedAt, }; }); + return { valid: rows.length > 0, rows }; } catch { - return []; + return { valid: false, rows: [] }; } } -function addRow(totals: TokenTotals, row: TaskTokenRow): void { +function isWithinRange(isoTimestamp: string, from?: string, to?: string): boolean { + return (from === undefined || isoTimestamp >= from) && (to === undefined || isoTimestamp <= to); +} + +function addRow(totals: TokenTotals, row: TaskTokenRow, taskIds?: Set): void { totals.inputTokens += row.inputTokens ?? 0; totals.outputTokens += row.outputTokens ?? 0; totals.cachedTokens += row.cachedTokens ?? 0; @@ -237,7 +249,10 @@ function addRow(totals: TokenTotals, row: TaskTokenRow): void { (row.outputTokens ?? 0) + (row.cachedTokens ?? 0) + (row.cacheWriteTokens ?? 0); - totals.nTasks += 1; + if (!taskIds || !taskIds.has(row.id)) { + totals.nTasks += 1; + taskIds?.add(row.id); + } } function isoWeekBucket(isoTimestamp: string): string { @@ -277,19 +292,28 @@ export function aggregateTokenAnalytics( ): TokenAnalytics { const clauses: string[] = ["tokenUsageLastUsedAt IS NOT NULL"]; const params: string[] = []; + const rangeClauses: string[] = []; if (query.from !== undefined) { - clauses.push("tokenUsageLastUsedAt >= ?"); + rangeClauses.push("tokenUsageLastUsedAt >= ?"); params.push(query.from); } if (query.to !== undefined) { - clauses.push("tokenUsageLastUsedAt <= ?"); + rangeClauses.push("tokenUsageLastUsedAt <= ?"); params.push(query.to); } + if (rangeClauses.length > 0) { + /* + * FNXC:CommandCenterTokenRanges 2026-07-02-00:00: + * Last 30 days model analytics must evaluate durable tokenUsagePerModel bucket timestamps, not only the task-level latest usage timestamp. Include candidate multi-model rows for in-memory bucket filtering while legacy rows stay narrowed by task tokenUsageLastUsedAt. + */ + clauses.push(`((${rangeClauses.join(" AND ")}) OR tokenUsagePerModel IS NOT NULL)`); + } const where = `WHERE ${clauses.join(" AND ")}`; const rows = db .prepare( `SELECT + id, tokenUsageInputTokens AS inputTokens, tokenUsageOutputTokens AS outputTokens, tokenUsageCachedTokens AS cachedTokens, @@ -318,34 +342,46 @@ export function aggregateTokenAnalytics( const now = query.now; const pricingOverrides = query.pricingOverrides; + const totalTaskIds = new Set(); + const groupTaskIds = new Map>(); + const seriesTaskIds = new Map>(); + for (const row of rows) { - addRow(totals, row); - addRowCost(totalCost, row, now, pricingOverrides); - if (groupBy) { - const groupRows = (groupBy === "model" || groupBy === "provider") ? parsePerModelRows(row) : []; - const rowsForGroup = groupRows.length > 0 ? groupRows : [row]; - for (const groupRow of rowsForGroup) { - const key = groupKeyFor(groupRow, groupBy); + const perModel = parsePerModelRows(row); + const rowInRange = isWithinRange(row.tokenUsageLastUsedAt, query.from, query.to); + const contributionRows = perModel.valid + ? perModel.rows.filter((bucketRow) => isWithinRange(bucketRow.tokenUsageLastUsedAt, query.from, query.to)) + : rowInRange + ? [row] + : []; + + for (const contributionRow of contributionRows) { + addRow(totals, contributionRow, totalTaskIds); + addRowCost(totalCost, contributionRow, now, pricingOverrides); + if (groupBy) { + const key = groupKeyFor(contributionRow, groupBy); let group = groupMap.get(key); if (!group) { group = { key, ...emptyTotals(), cost: { usd: null, unavailable: false, stale: false } }; groupMap.set(key, group); groupCostMap.set(key, emptyCostAccumulator()); + groupTaskIds.set(key, new Set()); } - addRow(group, groupRow); - addRowCost(groupCostMap.get(key)!, groupRow, now, pricingOverrides); + addRow(group, contributionRow, groupTaskIds.get(key)!); + addRowCost(groupCostMap.get(key)!, contributionRow, now, pricingOverrides); } - } - if (granularity) { - const bucket = bucketFor(row, granularity); - let point = seriesMap.get(bucket); - if (!point) { - point = { bucket, ...emptyTotals(), cost: { usd: null, unavailable: false, stale: false } }; - seriesMap.set(bucket, point); - seriesCostMap.set(bucket, emptyCostAccumulator()); + if (granularity) { + const bucket = bucketFor(contributionRow, granularity); + let point = seriesMap.get(bucket); + if (!point) { + point = { bucket, ...emptyTotals(), cost: { usd: null, unavailable: false, stale: false } }; + seriesMap.set(bucket, point); + seriesCostMap.set(bucket, emptyCostAccumulator()); + seriesTaskIds.set(bucket, new Set()); + } + addRow(point, contributionRow, seriesTaskIds.get(bucket)!); + addRowCost(seriesCostMap.get(bucket)!, contributionRow, now, pricingOverrides); } - addRow(point, row); - addRowCost(seriesCostMap.get(bucket)!, row, now, pricingOverrides); } } diff --git a/packages/dashboard/app/components/command-center/areas/__tests__/TokensArea.test.tsx b/packages/dashboard/app/components/command-center/areas/__tests__/TokensArea.test.tsx index cbe814b809..830017adf9 100644 --- a/packages/dashboard/app/components/command-center/areas/__tests__/TokensArea.test.tsx +++ b/packages/dashboard/app/components/command-center/areas/__tests__/TokensArea.test.tsx @@ -16,6 +16,7 @@ vi.mock("../../../ProviderIcon", () => ({ })); const range7d: DateRange = { from: "2026-06-08", to: null, preset: "7d" }; +const range30d: DateRange = { from: "2026-06-02", to: null, preset: "30d" }; function makeTokenGroup(key: string | null, totalTokens: number) { const inputTokens = Math.round(totalTokens * 0.6); @@ -113,6 +114,31 @@ function glmMixedProviderTokenFixture() { }; } +function last30DaysMultiModelFixture() { + return { + from: "2026-06-02T00:00:00.000Z", + to: "2026-07-02T00:00:00.000Z", + groupBy: "model", + totals: { + inputTokens: 950, + outputTokens: 450, + cachedTokens: 0, + cacheWriteTokens: 0, + totalTokens: 1_400, + nTasks: 1, + }, + cost: { usd: 4.2, unavailable: false, stale: false }, + series: [ + { bucket: "2026-06-15", inputTokens: 700, outputTokens: 300, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 1_000, nTasks: 1, cost: { usd: 3, unavailable: false, stale: false } }, + { bucket: "2026-06-20", inputTokens: 250, outputTokens: 150, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 400, nTasks: 1, cost: { usd: 1.2, unavailable: false, stale: false } }, + ], + groups: [ + { key: "claude-sonnet-4-5", inputTokens: 700, outputTokens: 300, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 1_000, nTasks: 1, cost: { usd: 3, unavailable: false, stale: false } }, + { key: "gpt-5", inputTokens: 250, outputTokens: 150, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 400, nTasks: 1, cost: { usd: 1.2, unavailable: false, stale: false } }, + ], + }; +} + function manyModelTokenFixture() { const groups = [ makeTokenGroup("claude-sonnet-4-5", 2_000), @@ -205,6 +231,30 @@ describe("TokensArea provider model icons", () => { expect(table.querySelectorAll('.provider-icon[data-provider="zai"]').length).toBe(3); }); + it("renders Last 30 days multi-model groups across bar, pie, line, and table surfaces", async () => { + apiMock.mockResolvedValue(last30DaysMultiModelFixture()); + render(); + + const byModelChart = await screen.findByRole("list", { name: "Tokens by model" }); + const pie = screen.getByTestId("cc-tokens-pie"); + const table = screen.getByTestId("cc-tokens-table"); + const line = screen.getByTestId("cc-tokens-line"); + + expect(apiMock).toHaveBeenCalledWith( + "/command-center/tokens?groupBy=model&granularity=day&from=2026-06-02", + undefined, + ); + for (const label of ["claude-sonnet-4-5", "gpt-5"]) { + expect(within(byModelChart).getAllByText(label).length).toBeGreaterThan(0); + expect(within(pie).getAllByText(label).length).toBeGreaterThan(0); + expect(within(table).getAllByText(label).length).toBeGreaterThan(0); + } + expect(within(table).getByTestId("cc-tokens-row-claude-sonnet-4-5")).toHaveTextContent("1,000"); + expect(within(table).getByTestId("cc-tokens-row-gpt-5")).toHaveTextContent("400"); + expect(line).toHaveTextContent("Total"); + expect(screen.getByTestId("cc-tokens-total")).toHaveTextContent("1,400"); + }); + it("renders every analytics model group in detail bar, pie, and table even beyond the old cap", async () => { apiMock.mockResolvedValue(manyModelTokenFixture()); render(); diff --git a/packages/dashboard/src/__tests__/register-command-center-routes.test.ts b/packages/dashboard/src/__tests__/register-command-center-routes.test.ts index c62067d74e..08082c8505 100644 --- a/packages/dashboard/src/__tests__/register-command-center-routes.test.ts +++ b/packages/dashboard/src/__tests__/register-command-center-routes.test.ts @@ -366,6 +366,92 @@ describe("register-command-center-routes", () => { expect((body.totals as { totalTokens: number }).totalTokens).toBe(200); }); + it("returns every Last 30 days per-model bucket in JSON and CSV token analytics", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-02T00:00:00.000Z")); + dbA.prepare( + `INSERT INTO tasks + (id, description, "column", modelProvider, modelId, + tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageCachedTokens, tokenUsageCacheWriteTokens, tokenUsageTotalTokens, + tokenUsageLastUsedAt, tokenUsageModelProvider, tokenUsageModelId, tokenUsagePerModel, createdAt, updatedAt) + VALUES (?, 'desc', 'todo', NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + "FN-last-30-multi", + 950, + 450, + 0, + 0, + 1_400, + "2026-07-05T00:00:00.000Z", + "openai", + "gpt-5", + JSON.stringify([ + { + modelProvider: "anthropic", + modelId: "claude-sonnet-4-5", + inputTokens: 700, + outputTokens: 300, + cachedTokens: 0, + cacheWriteTokens: 0, + totalTokens: 1_000, + firstUsedAt: "2026-06-15T00:00:00.000Z", + lastUsedAt: "2026-06-15T00:00:00.000Z", + }, + { + modelProvider: "openai", + modelId: "gpt-5", + inputTokens: 250, + outputTokens: 150, + cachedTokens: 0, + cacheWriteTokens: 0, + totalTokens: 400, + firstUsedAt: "2026-06-20T00:00:00.000Z", + lastUsedAt: "2026-06-20T00:00:00.000Z", + }, + { + modelProvider: "zai", + modelId: "glm-outside", + inputTokens: 10, + outputTokens: 10, + cachedTokens: 0, + cacheWriteTokens: 0, + totalTokens: 20, + firstUsedAt: "2026-07-03T00:00:00.000Z", + lastUsedAt: "2026-07-03T00:00:00.000Z", + }, + ]), + "2026-06-15T00:00:00.000Z", + "2026-07-05T00:00:00.000Z", + ); + + const json = await request( + app, + "GET", + "/api/command-center/tokens?from=2026-06-02T00%3A00%3A00.000Z&groupBy=model&granularity=day&projectId=proj-a", + ); + expect(json.status).toBe(200); + expect(json.body).toMatchObject({ from: "2026-06-02T00:00:00.000Z", to: "2026-07-02T00:00:00.000Z", groupBy: "model" }); + const groups = new Map((json.body as { groups: { key: string | null; totalTokens: number }[] }).groups.map((group) => [group.key, group.totalTokens])); + expect(groups.get("claude-sonnet-4-5")).toBe(1_000); + expect(groups.get("gpt-5")).toBe(400); + expect(groups.has("glm-outside")).toBe(false); + expect((json.body as { totals: { totalTokens: number; nTasks: number } }).totals).toMatchObject({ totalTokens: 1_400, nTasks: 1 }); + expect((json.body as { series: { bucket: string; totalTokens: number }[] }).series.map((point) => [point.bucket, point.totalTokens])).toEqual([ + ["2026-06-15", 1_000], + ["2026-06-20", 400], + ]); + + const csv = await request( + app, + "GET", + "/api/command-center/tokens?from=2026-06-02T00%3A00%3A00.000Z&groupBy=model&projectId=proj-a&format=csv", + ); + expect(csv.status).toBe(200); + expect(csv.body as string).toContain("claude-sonnet-4-5"); + expect(csv.body as string).toContain("gpt-5"); + expect(csv.body as string).not.toContain("glm-outside"); + }); + it("returns token time-series buckets when granularity is requested", async () => { const res = await request( app,