From cc8b1b69e0bfb926c75ae679e6ee709876b66cbf Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 10 Jul 2026 08:33:42 -0700 Subject: [PATCH] FN-7786: price analytics from token-usage model snapshot to fix empty estimated cost Estimated cost on the dashboard could render empty because team/workflow analytics priced rows using the legacy task.modelProvider/modelId columns, which can be NULL even when a durable tokenUsageModelProvider/tokenUsageModelId snapshot exists on the row. - Select tokenUsageModelProvider/tokenUsageModelId alongside the legacy model columns in the team-analytics and workflow-analytics task token queries - Price each row using the token-usage model snapshot first, falling back to the legacy model columns, so cost survives model-resolution drift and empty legacy columns - Add regression coverage in team-analytics, workflow-analytics, and model-pricing tests, plus a dashboard TaskDetailModal summary-tab test asserting estimated cost renders - Add a patch changeset documenting the fix for @runfusion/fusion Files changed: .changeset/fn-7786-estimated-cost.md | 7 +++++ packages/core/src/__tests__/model-pricing.test.ts | 10 +++++++ packages/core/src/__tests__/team-analytics.test.ts | 34 ++++++++++++++++++++-- .../core/src/__tests__/workflow-analytics.test.ts | 33 +++++++++++++++++++-- packages/core/src/team-analytics.ts | 15 ++++++++-- packages/core/src/workflow-analytics.ts | 15 ++++++++-- .../__tests__/TaskDetailModal.summary-tab.test.tsx | 34 ++++++++++++++++++++++ 7 files changed, 140 insertions(+), 8 deletions(-) Fusion-Task-Id: FN-7786 Fusion-Task-Lineage: dab32809-4bb8-42c0-aed0-a0c1131ec641 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7786-estimated-cost.md | 7 ++++ .../core/src/__tests__/model-pricing.test.ts | 10 ++++++ .../core/src/__tests__/team-analytics.test.ts | 34 +++++++++++++++++-- .../src/__tests__/workflow-analytics.test.ts | 33 ++++++++++++++++-- packages/core/src/team-analytics.ts | 15 ++++++-- packages/core/src/workflow-analytics.ts | 15 ++++++-- .../TaskDetailModal.summary-tab.test.tsx | 34 +++++++++++++++++++ 7 files changed, 140 insertions(+), 8 deletions(-) create mode 100644 .changeset/fn-7786-estimated-cost.md diff --git a/.changeset/fn-7786-estimated-cost.md b/.changeset/fn-7786-estimated-cost.md new file mode 100644 index 0000000000..190e42265a --- /dev/null +++ b/.changeset/fn-7786-estimated-cost.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Estimated cost on the dashboard stays populated as runtime model catalogs drift. +category: fix +dev: Durable token-usage snapshot pricing for Team/Workflow analytics so cost survives empty legacy task model columns; cost stays read-time derived in costFor/token-analytics. diff --git a/packages/core/src/__tests__/model-pricing.test.ts b/packages/core/src/__tests__/model-pricing.test.ts index 760cbdaff2..33738363b9 100644 --- a/packages/core/src/__tests__/model-pricing.test.ts +++ b/packages/core/src/__tests__/model-pricing.test.ts @@ -36,6 +36,16 @@ describe("model-pricing", () => { expect(result.usd).toBeCloseTo(10.0, 2); }); + it("prices the current runtime Anthropic Claude Sonnet 5 identity captured from token usage", () => { + const result = costFor( + { ...ZERO, inputTokens: 1_000_000, outputTokens: 200_000 }, + { provider: "anthropic", model: "claude-sonnet-5" }, + ); + + expect(result).toMatchObject({ unavailable: false, stale: false }); + expect(result.usd).toBeCloseTo(4, 2); + }); + it("prices direct Anthropic Claude Sonnet 5 from the restored static catalog row", () => { const usage = { inputTokens: 1_000_000, diff --git a/packages/core/src/__tests__/team-analytics.test.ts b/packages/core/src/__tests__/team-analytics.test.ts index 0950224bac..f8dc419d35 100644 --- a/packages/core/src/__tests__/team-analytics.test.ts +++ b/packages/core/src/__tests__/team-analytics.test.ts @@ -22,6 +22,8 @@ interface TaskSeed { tokenUsageLastUsedAt?: string | null; modelProvider?: string | null; modelId?: string | null; + tokenUsageModelProvider?: string | null; + tokenUsageModelId?: string | null; } function insertAgent(db: Database, id: string, name: string, role = "executor", state = "idle"): void { @@ -44,8 +46,9 @@ function insertTask(db: Database, task: TaskSeed): void { `INSERT INTO tasks (id, description, "column", createdAt, updatedAt, columnMovedAt, assignedAgentId, modifiedFiles, tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageCachedTokens, - tokenUsageCacheWriteTokens, tokenUsageTotalTokens, tokenUsageLastUsedAt, modelProvider, modelId) - VALUES (?, 'desc', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + tokenUsageCacheWriteTokens, tokenUsageTotalTokens, tokenUsageLastUsedAt, modelProvider, modelId, + tokenUsageModelProvider, tokenUsageModelId) + VALUES (?, 'desc', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( task.id, task.column ?? "todo", @@ -62,6 +65,8 @@ function insertTask(db: Database, task: TaskSeed): void { task.tokenUsageLastUsedAt ?? null, task.modelProvider ?? null, task.modelId ?? null, + task.tokenUsageModelProvider ?? null, + task.tokenUsageModelId ?? null, ); } @@ -160,6 +165,31 @@ describe("team-analytics", () => { expect(result.totals.tasksInReview).toBe(1); }); + + it("prices token usage from the actually-used model snapshot when task model columns are empty", () => { + insertAgent(db, "agent-a", "Alpha"); + insertTask(db, { + id: "snapshot-priced", + agentId: "agent-a", + inputTokens: 1_000_000, + outputTokens: 200_000, + cachedTokens: 0, + cacheWriteTokens: 0, + totalTokens: 1_200_000, + tokenUsageLastUsedAt: "2026-07-10T15:22:50.837Z", + modelProvider: null, + modelId: null, + tokenUsageModelProvider: "anthropic", + tokenUsageModelId: "claude-sonnet-5", + }); + + const result = aggregateTeamAnalytics(db, {}); + + expect(result.agents[0].cost).toMatchObject({ unavailable: false, stale: false }); + expect(result.agents[0].cost.usd).toBeCloseTo(4, 2); + expect(result.totals.cost.usd).toBeCloseTo(4, 2); + }); + it("returns zeroed totals and an empty agent array for an empty database", () => { const result = aggregateTeamAnalytics(db, {}); diff --git a/packages/core/src/__tests__/workflow-analytics.test.ts b/packages/core/src/__tests__/workflow-analytics.test.ts index 6f239b5230..e65cd0fd7c 100644 --- a/packages/core/src/__tests__/workflow-analytics.test.ts +++ b/packages/core/src/__tests__/workflow-analytics.test.ts @@ -22,6 +22,8 @@ interface TaskSeed { tokenUsageLastUsedAt?: string | null; modelProvider?: string | null; modelId?: string | null; + tokenUsageModelProvider?: string | null; + tokenUsageModelId?: string | null; } function modifiedFilesValue(value: unknown): string | null { @@ -44,8 +46,9 @@ function insertTask(db: Database, task: TaskSeed): void { `INSERT INTO tasks (id, description, "column", createdAt, updatedAt, columnMovedAt, modifiedFiles, tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageCachedTokens, - tokenUsageCacheWriteTokens, tokenUsageTotalTokens, tokenUsageLastUsedAt, modelProvider, modelId) - VALUES (?, 'desc', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + tokenUsageCacheWriteTokens, tokenUsageTotalTokens, tokenUsageLastUsedAt, modelProvider, modelId, + tokenUsageModelProvider, tokenUsageModelId) + VALUES (?, 'desc', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( task.id, task.column ?? "todo", @@ -61,6 +64,8 @@ function insertTask(db: Database, task: TaskSeed): void { task.tokenUsageLastUsedAt ?? null, task.modelProvider ?? null, task.modelId ?? null, + task.tokenUsageModelProvider ?? null, + task.tokenUsageModelId ?? null, ); if (task.workflowId !== undefined && task.workflowId !== null) { @@ -170,6 +175,30 @@ describe("workflow-analytics", () => { expect(result.totals.tasksInReview).toBe(1); }); + + it("prices token usage from the actually-used model snapshot when task model columns are empty", () => { + insertTask(db, { + id: "snapshot-priced", + workflowId: "builtin:coding", + inputTokens: 1_000_000, + outputTokens: 200_000, + cachedTokens: 0, + cacheWriteTokens: 0, + totalTokens: 1_200_000, + tokenUsageLastUsedAt: "2026-07-10T15:22:50.837Z", + modelProvider: null, + modelId: null, + tokenUsageModelProvider: "anthropic", + tokenUsageModelId: "claude-sonnet-5", + }); + + const result = aggregateWorkflowAnalytics(db, { defaultWorkflowId: "builtin:coding" }); + + expect(result.workflows[0].cost).toMatchObject({ unavailable: false, stale: false }); + expect(result.workflows[0].cost.usd).toBeCloseTo(4, 2); + expect(result.totals.cost.usd).toBeCloseTo(4, 2); + }); + it("marks unpriced workflow costs unavailable instead of treating them as zero", () => { insertTask(db, { id: "unknown-model", diff --git a/packages/core/src/team-analytics.ts b/packages/core/src/team-analytics.ts index a5c9b08b79..883ad050d1 100644 --- a/packages/core/src/team-analytics.ts +++ b/packages/core/src/team-analytics.ts @@ -52,6 +52,8 @@ interface TaskTokenRow { totalTokens: number | null; modelProvider: string | null; modelId: string | null; + tokenUsageModelProvider: string | null; + tokenUsageModelId: string | null; } interface CountByAgentRow { @@ -121,7 +123,14 @@ function addRowCost( cachedTokens: row.cachedTokens ?? 0, cacheWriteTokens: row.cacheWriteTokens ?? 0, }, - { provider: row.modelProvider, model: row.modelId }, + { + /* + * FNXC:CommandCenter 2026-07-10-08:25: + * Current runtime usage rows persist the actually-used model in tokenUsageModelProvider/tokenUsageModelId while task-level modelProvider/modelId can stay empty. Team cost analytics must price the usage snapshot first so estimated cost survives model-resolution hierarchy and catalog drift instead of reverting to the unavailable sentinel. + */ + provider: row.tokenUsageModelProvider ?? row.modelProvider, + model: row.tokenUsageModelId ?? row.modelId, + }, now, pricingOverrides, ); @@ -228,7 +237,9 @@ export function aggregateTeamAnalytics( tokenUsageCacheWriteTokens AS cacheWriteTokens, tokenUsageTotalTokens AS totalTokens, modelProvider, - modelId + modelId, + tokenUsageModelProvider, + tokenUsageModelId FROM tasks WHERE ${tokenClauses.join(" AND ")}`, ) diff --git a/packages/core/src/workflow-analytics.ts b/packages/core/src/workflow-analytics.ts index d74f9ec8b6..ac8e576819 100644 --- a/packages/core/src/workflow-analytics.ts +++ b/packages/core/src/workflow-analytics.ts @@ -54,6 +54,8 @@ interface TaskTokenRow { totalTokens: number | null; modelProvider: string | null; modelId: string | null; + tokenUsageModelProvider: string | null; + tokenUsageModelId: string | null; } interface CountByWorkflowRow { @@ -123,7 +125,14 @@ function addRowCost( cachedTokens: row.cachedTokens ?? 0, cacheWriteTokens: row.cacheWriteTokens ?? 0, }, - { provider: row.modelProvider, model: row.modelId }, + { + /* + * FNXC:CommandCenter 2026-07-10-08:25: + * Workflow cost analytics must mirror token analytics by pricing the actually-used token-usage model snapshot before legacy task model columns. FN-7757's static catalog rows could not help rows whose legacy model columns are NULL, so this fixes the durable resolution path without guessing prices for unknown models. + */ + provider: row.tokenUsageModelProvider ?? row.modelProvider, + model: row.tokenUsageModelId ?? row.modelId, + }, now, pricingOverrides, ); @@ -235,7 +244,9 @@ export function aggregateWorkflowAnalytics( t.tokenUsageCacheWriteTokens AS cacheWriteTokens, t.tokenUsageTotalTokens AS totalTokens, t.modelProvider, - t.modelId + t.modelId, + t.tokenUsageModelProvider, + t.tokenUsageModelId FROM tasks t LEFT JOIN task_workflow_selection s ON s.taskId = t.id WHERE ${tokenClauses.join(" AND ")}`, diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.summary-tab.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.summary-tab.test.tsx index 8e059b84f4..5e90646a34 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.summary-tab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.summary-tab.test.tsx @@ -312,6 +312,40 @@ describe("TaskDetailModal Summary tab", () => { expect(within(rows[0]).getByText("7,014")).toBeTruthy(); }); + it("renders a dollar amount for the current runtime token-usage model snapshot", () => { + render( + , + ); + + expect(screen.getAllByText("$4.00").length).toBeGreaterThanOrEqual(1); + expect(screen.getByText("claude-sonnet-5")).toBeTruthy(); + }); + it("applies pricing overrides passed into the summary component", () => { const pricingOverrides: ModelPricingOverrides = { "custom:override-model": {