Skip to content

Commit dd71d11

Browse files
committed
improvement(trace): billing trace span typing
1 parent e2b3ae4 commit dd71d11

6 files changed

Lines changed: 105 additions & 15 deletions

File tree

apps/sim/lib/logs/execution/logging-factory.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,4 +466,37 @@ describe('calculateCostSummary', () => {
466466
expect(result.totalCost).toBe(0.03 + BASE_EXECUTION_CHARGE)
467467
expect(result.models['gpt-4o'].total).toBe(0.03)
468468
})
469+
470+
test('preserves parent toolCost while skipping model breakdown children', () => {
471+
const traceSpans = [
472+
{
473+
id: 'agent-span',
474+
type: 'agent',
475+
model: 'gpt-4o',
476+
cost: { input: 0.01, output: 0.02, toolCost: 0.015, total: 0.045 },
477+
tokens: { input: 1000, output: 2000, total: 3000 },
478+
children: [
479+
{
480+
id: 'agent-span-model-segment',
481+
type: 'model',
482+
model: 'gpt-4o',
483+
cost: { input: 0.01, output: 0.02, total: 0.03 },
484+
tokens: { input: 1000, output: 2000, total: 3000 },
485+
},
486+
{
487+
id: 'agent-span-tool-segment',
488+
type: 'tool',
489+
name: 'firecrawl_scrape',
490+
},
491+
],
492+
},
493+
]
494+
495+
const result = calculateCostSummary(traceSpans)
496+
497+
expect(result.modelCost).toBe(0.045)
498+
expect(result.totalCost).toBe(0.045 + BASE_EXECUTION_CHARGE)
499+
expect(result.models['gpt-4o'].total).toBe(0.045)
500+
expect(result.models['gpt-4o'].toolCost).toBe(0.015)
501+
})
469502
})

apps/sim/lib/logs/execution/logging-factory.ts

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { db, workflow } from '@sim/db'
22
import { eq } from 'drizzle-orm'
33
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
4-
import type { ExecutionEnvironment, ExecutionTrigger, WorkflowState } from '@/lib/logs/types'
4+
import type {
5+
ExecutionEnvironment,
6+
ExecutionTrigger,
7+
TraceSpan,
8+
WorkflowState,
9+
} from '@/lib/logs/types'
510
import {
611
loadDeployedWorkflowState,
712
loadWorkflowFromNormalizedTables,
@@ -80,7 +85,22 @@ export async function loadDeployedWorkflowStateForLogging(
8085
}
8186
}
8287

83-
export function calculateCostSummary(traceSpans: any[]): {
88+
type CostTraceSpan = Pick<TraceSpan, 'cost' | 'model' | 'tokens'> & {
89+
type?: TraceSpan['type']
90+
children?: CostTraceSpan[]
91+
}
92+
93+
type BillableTraceSpan = CostTraceSpan & { cost: NonNullable<TraceSpan['cost']> }
94+
95+
function hasBillableCost(span: CostTraceSpan): span is BillableTraceSpan {
96+
return span.cost !== undefined
97+
}
98+
99+
function isModelBreakdownSpan(span: CostTraceSpan): boolean {
100+
return span.type === 'model'
101+
}
102+
103+
export function calculateCostSummary(traceSpans: CostTraceSpan[] | undefined): {
84104
totalCost: number
85105
totalInputCost: number
86106
totalOutputCost: number
@@ -122,17 +142,17 @@ export function calculateCostSummary(traceSpans: any[]): {
122142
* avoid double-counting. The parent cost is set by the provider response
123143
* (and is correctly zeroed by `executeProviderRequest` for BYOK calls);
124144
* model children only carry per-segment cost from the trace enrichers,
125-
* which is unaware of BYOK status. Tool children do not carry cost and
126-
* are unaffected.
145+
* which is unaware of BYOK status. Non-model children are still visited
146+
* so standalone nested costs remain billable.
127147
*
128148
* Spans without their own `cost` (e.g. parent workflow spans for
129149
* subworkflow blocks) still recurse so nested billable spans are counted.
130150
*/
131-
const collectCostSpans = (spans: any[]): any[] => {
132-
const costSpans: any[] = []
151+
const collectCostSpans = (spans: CostTraceSpan[]): BillableTraceSpan[] => {
152+
const costSpans: BillableTraceSpan[] = []
133153

134154
for (const span of spans) {
135-
const hasOwnCost = !!span.cost
155+
const hasOwnCost = hasBillableCost(span)
136156
if (hasOwnCost) {
137157
costSpans.push(span)
138158
}
@@ -142,7 +162,7 @@ export function calculateCostSummary(traceSpans: any[]): {
142162
// Parent already accounts for its model segments; only recurse into
143163
// non-model children (e.g. nested workflow spans) to find further
144164
// billable units.
145-
const nonModelChildren = span.children.filter((c: any) => c?.type !== 'model')
165+
const nonModelChildren = span.children.filter((child) => !isModelBreakdownSpan(child))
146166
costSpans.push(...collectCostSpans(nonModelChildren))
147167
} else {
148168
costSpans.push(...collectCostSpans(span.children))

apps/sim/lib/logs/execution/trace-spans/span-factory.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,13 @@ function enrichWithProviderMetadata(span: TraceSpan, log: ValidBlockLog): void {
117117
}
118118

119119
if (output.cost) {
120-
const { input, output: out, total } = output.cost
121-
span.cost = { input, output: out, total }
120+
const { input, output: out, total, toolCost } = output.cost
121+
span.cost = {
122+
input,
123+
output: out,
124+
total,
125+
...(typeof toolCost === 'number' && toolCost > 0 ? { toolCost } : {}),
126+
}
122127
}
123128

124129
if (output.tokens) {

apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2206,4 +2206,38 @@ describe('nested subflow grouping via parentIterations', () => {
22062206
expect(secondModel.errorMessage).toBe('too many requests')
22072207
expect(secondModel.status).toBe('error')
22082208
})
2209+
2210+
it.concurrent('preserves parent toolCost on trace span cost', () => {
2211+
const result: ExecutionResult = {
2212+
success: true,
2213+
output: { content: 'done' },
2214+
logs: [
2215+
{
2216+
blockId: 'agent-tool-cost',
2217+
blockName: 'Agent With Tool Cost',
2218+
blockType: 'agent',
2219+
startedAt: '2024-01-01T10:00:00.000Z',
2220+
endedAt: '2024-01-01T10:00:02.000Z',
2221+
durationMs: 2000,
2222+
success: true,
2223+
input: {},
2224+
output: {
2225+
content: 'done',
2226+
model: 'gpt-4o',
2227+
tokens: { input: 100, output: 50, total: 150 },
2228+
cost: { input: 0.001, output: 0.002, toolCost: 0.015, total: 0.018 },
2229+
},
2230+
},
2231+
],
2232+
}
2233+
2234+
const { traceSpans } = buildTraceSpans(result)
2235+
2236+
expect(traceSpans[0].cost).toEqual({
2237+
input: 0.001,
2238+
output: 0.002,
2239+
toolCost: 0.015,
2240+
total: 0.018,
2241+
})
2242+
})
22092243
})

apps/sim/lib/logs/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ export interface TraceSpan {
223223
input?: number
224224
output?: number
225225
total?: number
226+
toolCost?: number
226227
}
227228
providerTiming?: ProviderTiming
228229
loopId?: string

apps/sim/providers/index.test.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -149,9 +149,7 @@ describe('executeProviderRequest — BYOK regression', () => {
149149
startTime: 1777584457940,
150150
endTime: 1777584458000,
151151
duration: 60,
152-
// Tool segments do not currently carry `cost` (tool cost lives on
153-
// the parent's response.cost.toolCost), but if a future provider
154-
// ever wrote a tool-segment cost we must NOT zero it.
152+
cost: { total: 0.01 },
155153
},
156154
],
157155
},
@@ -166,8 +164,7 @@ describe('executeProviderRequest — BYOK regression', () => {
166164
const [model, tool] = result.timing!.timeSegments!
167165
expect(model.cost?.total).toBe(0)
168166
expect(tool.type).toBe('tool')
169-
// Helper only zeroes type==='model'; the tool segment is untouched.
170-
expect((tool as { cost?: unknown }).cost).toBeUndefined()
167+
expect(tool.cost?.total).toBe(0.01)
171168
})
172169

173170
it('zeroes per-segment cost on streaming responses for BYOK callers', async () => {

0 commit comments

Comments
 (0)