Add task cost breakdown utilities

This commit is contained in:
Toray Altas 2026-01-16 11:21:04 -05:00
parent e18382d9ff
commit 6b3ec95ce3
3 changed files with 183 additions and 13 deletions

View file

@ -21,6 +21,7 @@ import { getModelMaxOutputTokens } from "@roo/api"
import { findLastIndex } from "@roo/array"
import { formatLargeNumber } from "@src/utils/format"
import { getTaskHeaderCostTooltipData } from "@src/utils/taskCostBreakdown"
import { cn } from "@src/lib/utils"
import { StandardTooltip, Button } from "@src/components/ui"
import { useExtensionState } from "@src/context/ExtensionStateContext"
@ -129,6 +130,37 @@ const TaskHeader = ({
const hasTodos = todos && Array.isArray(todos) && todos.length > 0
const subtaskCosts = useMemo(() => {
if (!subtaskDetails || subtaskDetails.length === 0) {
return []
}
return subtaskDetails
.map((subtask) => subtask.cost)
.filter((cost): cost is number => typeof cost === "number" && Number.isFinite(cost))
}, [subtaskDetails])
const tooltipCostData = useMemo(
() =>
getTaskHeaderCostTooltipData({
ownCost: totalCost,
aggregatedCost,
hasSubtasksProp: hasSubtasks,
costBreakdownProp: costBreakdown,
subtaskCosts,
labels: {
own: t("common:costs.own"),
subtasks: t("common:costs.subtasks"),
},
}),
[totalCost, aggregatedCost, hasSubtasks, costBreakdown, subtaskCosts, t],
)
const displayTotalCost = tooltipCostData.displayTotalCost
const displayCostBreakdown = tooltipCostData.displayCostBreakdown
const shouldTreatAsHasSubtasks = tooltipCostData.hasSubtasks
const hasAnyCost = tooltipCostData.hasAnyCost
return (
<div className="group pt-2 pb-0 px-3">
{showLongRunningTaskMessage && !isTaskComplete && (
@ -257,17 +289,19 @@ const TaskHeader = ({
{formatLargeNumber(contextTokens || 0)} / {formatLargeNumber(contextWindow)}
</span>
</StandardTooltip>
{!!totalCost && (
{hasAnyCost && (
<StandardTooltip
content={
hasSubtasks ? (
shouldTreatAsHasSubtasks ? (
<div>
<div>
{t("chat:costs.totalWithSubtasks", {
cost: (aggregatedCost ?? totalCost).toFixed(2),
cost: displayTotalCost.toFixed(2),
})}
</div>
{costBreakdown && <div className="text-xs mt-1">{costBreakdown}</div>}
{displayCostBreakdown && (
<div className="text-xs mt-1">{displayCostBreakdown}</div>
)}
</div>
) : (
<div>{t("chat:costs.total", { cost: totalCost.toFixed(2) })}</div>
@ -276,8 +310,8 @@ const TaskHeader = ({
side="top"
sideOffset={8}>
<span>
${(aggregatedCost ?? totalCost).toFixed(2)}
{hasSubtasks && (
${displayTotalCost.toFixed(2)}
{shouldTreatAsHasSubtasks && (
<span className="text-xs ml-1" title={t("chat:costs.includesSubtasks")}>
*
</span>
@ -416,7 +450,7 @@ const TaskHeader = ({
</tr>
)}
{!!totalCost && (
{hasAnyCost && (
<tr>
<th className="font-medium text-left align-top w-1 whitespace-nowrap pr-3 h-[24px]">
{t("chat:task.apiCost")}
@ -424,15 +458,17 @@ const TaskHeader = ({
<td className="font-light align-top">
<StandardTooltip
content={
hasSubtasks ? (
shouldTreatAsHasSubtasks ? (
<div>
<div>
{t("chat:costs.totalWithSubtasks", {
cost: (aggregatedCost ?? totalCost).toFixed(2),
cost: displayTotalCost.toFixed(2),
})}
</div>
{costBreakdown && (
<div className="text-xs mt-1">{costBreakdown}</div>
{displayCostBreakdown && (
<div className="text-xs mt-1">
{displayCostBreakdown}
</div>
)}
</div>
) : (
@ -444,8 +480,8 @@ const TaskHeader = ({
side="top"
sideOffset={8}>
<span>
${(aggregatedCost ?? totalCost).toFixed(2)}
{hasSubtasks && (
${displayTotalCost.toFixed(2)}
{shouldTreatAsHasSubtasks && (
<span
className="text-xs ml-1"
title={t("chat:costs.includesSubtasks")}>

View file

@ -0,0 +1,30 @@
import { describe, it, expect } from "vitest"
import { computeTaskCostsIncludingSubtasks, getTaskHeaderCostTooltipData } from "@src/utils/taskCostBreakdown"
describe("taskCostBreakdown", () => {
it("sums subtask line-item costs (micros) and produces a stable total", () => {
const result = computeTaskCostsIncludingSubtasks(0.05, [0.16, 0.13])
expect(result.ownCostCents).toBe(5)
expect(result.subtasksCostCents).toBe(29)
expect(result.totalCostIncludingSubtasksCents).toBe(34)
expect(result.totalCostIncludingSubtasks).toBeCloseTo(0.34, 10)
})
it("prefers derived subtask sum over provided breakdown/aggregatedCost when details are available", () => {
const data = getTaskHeaderCostTooltipData({
ownCost: 0.05,
aggregatedCost: 0.09,
hasSubtasksProp: true,
costBreakdownProp: "Own: $0.05 + Subtasks: $0.09",
subtaskCosts: [0.16, 0.13],
labels: { own: "Own", subtasks: "Subtasks" },
})
expect(data.displayTotalCost).toBeCloseTo(0.34, 10)
expect(data.displayCostBreakdown).toBe("Own: $0.05 + Subtasks: $0.29")
expect(data.hasSubtasks).toBe(true)
expect(data.hasAnyCost).toBe(true)
})
})

View file

@ -0,0 +1,104 @@
import { formatCostBreakdown } from "@src/utils/costFormatting"
const MICROS_PER_DOLLAR = 1_000_000
const MICROS_PER_CENT = 10_000
function dollarsToMicros(amount: number): number {
if (typeof amount !== "number" || !Number.isFinite(amount) || amount <= 0) {
return 0
}
return Math.round(amount * MICROS_PER_DOLLAR)
}
function microsToDollars(micros: number): number {
return micros / MICROS_PER_DOLLAR
}
export interface TaskCostsIncludingSubtasks {
ownCost: number
ownCostMicros: number
ownCostCents: number
subtasksCost: number
subtasksCostMicros: number
subtasksCostCents: number
totalCostIncludingSubtasks: number
totalCostIncludingSubtasksMicros: number
totalCostIncludingSubtasksCents: number
}
/**
* Computes task costs using integer micros for stable aggregation.
*
* Note: we only have access to floating-dollar amounts in the webview.
* Converting to micros and summing avoids most floating point drift.
*/
export function computeTaskCostsIncludingSubtasks(ownCost: number, subtaskCosts: number[]): TaskCostsIncludingSubtasks {
const ownCostMicros = dollarsToMicros(ownCost)
const subtasksCostMicros = (subtaskCosts ?? []).reduce((sum, cost) => sum + dollarsToMicros(cost), 0)
const totalCostIncludingSubtasksMicros = ownCostMicros + subtasksCostMicros
const ownCostCents = Math.round(ownCostMicros / MICROS_PER_CENT)
const subtasksCostCents = Math.round(subtasksCostMicros / MICROS_PER_CENT)
const totalCostIncludingSubtasksCents = Math.round(totalCostIncludingSubtasksMicros / MICROS_PER_CENT)
return {
ownCost: microsToDollars(ownCostMicros),
ownCostMicros,
ownCostCents,
subtasksCost: microsToDollars(subtasksCostMicros),
subtasksCostMicros,
subtasksCostCents,
totalCostIncludingSubtasks: microsToDollars(totalCostIncludingSubtasksMicros),
totalCostIncludingSubtasksMicros,
totalCostIncludingSubtasksCents,
}
}
export interface TaskHeaderCostTooltipData {
/** Total cost to display (includes subtasks when details provided). */
displayTotalCost: number
/** Breakdown string to show in tooltip, if subtasks exist. */
displayCostBreakdown?: string
/** Whether the UI should show the "includes subtasks" marker. */
hasSubtasks: boolean
/** Whether there is any cost to render. */
hasAnyCost: boolean
}
/**
* Cost display logic for TaskHeader tooltip.
*
* When subtask details are available, this derives subtasks cost as the sum of
* subtask line-item totals (same source as the accordion list), rather than
* trusting any derived deltas.
*/
export function getTaskHeaderCostTooltipData(params: {
ownCost: number
aggregatedCost?: number
hasSubtasksProp?: boolean
costBreakdownProp?: string
subtaskCosts?: number[]
labels: { own: string; subtasks: string }
}): TaskHeaderCostTooltipData {
const { ownCost, aggregatedCost, hasSubtasksProp, costBreakdownProp, subtaskCosts, labels } = params
const computed = computeTaskCostsIncludingSubtasks(ownCost, subtaskCosts ?? [])
const hasComputedSubtasks = computed.subtasksCostCents > 0
const hasSubtasks = !!hasSubtasksProp || hasComputedSubtasks
const displayTotalCost = hasComputedSubtasks ? computed.totalCostIncludingSubtasks : (aggregatedCost ?? ownCost)
const displayCostBreakdown = hasComputedSubtasks
? formatCostBreakdown(computed.ownCost, computed.subtasksCost, labels)
: costBreakdownProp
const hasAnyCost = typeof displayTotalCost === "number" && Number.isFinite(displayTotalCost) && displayTotalCost > 0
return {
displayTotalCost,
displayCostBreakdown,
hasSubtasks,
hasAnyCost,
}
}