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) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7786-estimated-cost.md
Normal file
7
.changeset/fn-7786-estimated-cost.md
Normal file
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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, {});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ")}`,
|
||||
)
|
||||
|
||||
@@ -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 ")}`,
|
||||
|
||||
@@ -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(
|
||||
<TaskSummaryTab
|
||||
task={doneTask({
|
||||
tokenUsage: tokenUsage({
|
||||
inputTokens: 1_000_000,
|
||||
outputTokens: 200_000,
|
||||
cachedTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalTokens: 1_200_000,
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-5",
|
||||
perModel: [
|
||||
{
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-5",
|
||||
inputTokens: 1_000_000,
|
||||
outputTokens: 200_000,
|
||||
cachedTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalTokens: 1_200_000,
|
||||
firstUsedAt: "2026-07-10T15:22:50.837Z",
|
||||
lastUsedAt: "2026-07-10T15:22:50.837Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
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": {
|
||||
|
||||
Reference in New Issue
Block a user