Skip to content

Commit fbad5f6

Browse files
committed
fix
1 parent 06d840a commit fbad5f6

7 files changed

Lines changed: 284 additions & 13 deletions

File tree

apps/sim/executor/handlers/agent/agent-handler.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1282,6 +1282,7 @@ export class AgentBlockHandler implements BlockHandler {
12821282
capabilityPolicy: providerRequest.capabilityPolicy,
12831283
credentialMode: providerRequest.credentialMode,
12841284
providerOptions: providerRequest.providerOptions,
1285+
providerModel: providerRequest.providerModel,
12851286
// Stable per-block identity; providers use it to route cache lookups.
12861287
blockId: block.id,
12871288
previousInteractionId: providerRequest.previousInteractionId,

apps/sim/lib/workflows/blocks/block-outputs.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { describe, expect, it } from 'vitest'
1+
import { describe, expect, it, vi } from 'vitest'
2+
3+
vi.unmock('@/blocks/registry')
4+
25
import {
36
getEffectiveBlockOutputPaths,
47
getEffectiveBlockOutputs,

apps/sim/providers/fireworks/index.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/
66
import type { StreamingExecution } from '@/executor/types'
77
import { MAX_TOOL_ITERATIONS } from '@/providers'
88
import { formatMessagesForProvider } from '@/providers/attachments'
9-
import { addFireworksUsage, priceFireworksUsage } from '@/providers/fireworks/usage'
9+
import {
10+
addFireworksUsage,
11+
createFireworksUsageTotals,
12+
priceFireworksUsage,
13+
} from '@/providers/fireworks/usage'
1014
import {
1115
checkForForcedToolUsage,
1216
createReadableStreamFromOpenAIStream,
@@ -210,7 +214,7 @@ export const fireworksProvider: ProviderConfig = {
210214

211215
let content = currentResponse.choices[0]?.message?.content || ''
212216
let currentTurnUsage = priceFireworksUsage(request.model, currentResponse.usage, serviceTier)
213-
const usageTotals = priceFireworksUsage(request.model, undefined, serviceTier)
217+
const usageTotals = createFireworksUsageTotals(request.model, serviceTier)
214218
addFireworksUsage(usageTotals, currentTurnUsage)
215219
const { tokens, cost: modelCost } = usageTotals
216220
const toolCalls: FunctionCallResponse[] = []

apps/sim/providers/fireworks/usage.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,5 +76,9 @@ export function createFireworksUsageTotals(
7676
model: string,
7777
serviceTier: 'default' | 'priority' = 'default'
7878
): FireworksUsageTotals {
79-
return priceFireworksUsage(model, undefined, serviceTier)
79+
const empty = priceFireworksUsage(model, undefined, serviceTier)
80+
return {
81+
tokens: { input: 0, output: 0, total: 0 },
82+
cost: { ...empty.cost, input: 0, output: 0, total: 0 },
83+
}
8084
}

apps/sim/providers/settled-tool-streams.test.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,7 @@ describe('settled provider tool streams', () => {
386386

387387
it.each(PROVIDERS)(
388388
'$name projects the existing final answer without another provider call',
389-
async ({ provider, model }) => {
389+
async ({ name, provider, model }) => {
390390
mockCreate
391391
.mockResolvedValueOnce(response(null, [toolCall('call-1')]))
392392
.mockResolvedValueOnce(response('final answer'))
@@ -402,7 +402,10 @@ describe('settled provider tool streams', () => {
402402
expect(result.execution.output).toMatchObject({
403403
content: 'final answer',
404404
tokens: { input: 10, output: 6, total: 16 },
405-
cost: { input: 1, output: 2, toolCost: 4, total: 7 },
405+
cost:
406+
name === 'xAI'
407+
? { input: 2, output: 4, toolCost: 4, total: 10 }
408+
: { input: 1, output: 2, toolCost: 4, total: 7 },
406409
toolCalls: { count: 1 },
407410
})
408411
expectModelIterations(result, 2)
@@ -411,7 +414,7 @@ describe('settled provider tool streams', () => {
411414

412415
it.each(STRUCTURED_OUTPUT_PROVIDERS)(
413416
'$name performs deferred structured extraction before projecting a settled stream',
414-
async ({ provider, model, responseFormatType, disablesTools }) => {
417+
async ({ name, provider, model, responseFormatType, disablesTools }) => {
415418
mockCreate
416419
.mockResolvedValueOnce(response(null, [toolCall('call-1')]))
417420
.mockResolvedValueOnce(response('intermediate answer'))
@@ -435,7 +438,10 @@ describe('settled provider tool streams', () => {
435438
expect(result.execution.output).toMatchObject({
436439
content: '{"value":"found"}',
437440
tokens: { input: 15, output: 9, total: 24 },
438-
cost: { input: 1, output: 2, toolCost: 4, total: 7 },
441+
cost:
442+
name === 'Fireworks'
443+
? { input: 3, output: 6, toolCost: 4, total: 13 }
444+
: { input: 1, output: 2, toolCost: 4, total: 7 },
439445
toolCalls: { count: 1 },
440446
})
441447
expectModelIterations(result, 3)
@@ -444,7 +450,7 @@ describe('settled provider tool streams', () => {
444450

445451
it.each(STRUCTURED_OUTPUT_PROVIDERS)(
446452
'$name makes only one schema-bearing final call when the tool loop reaches its cap',
447-
async ({ provider, model, responseFormatType }) => {
453+
async ({ name, provider, model, responseFormatType }) => {
448454
mockCreate
449455
.mockResolvedValueOnce(response(null, [toolCall('call-1')]))
450456
.mockResolvedValueOnce(response(null, [toolCall('call-2')]))
@@ -462,7 +468,10 @@ describe('settled provider tool streams', () => {
462468
expect(result.execution.output).toMatchObject({
463469
content: '{"value":"capped"}',
464470
tokens: { input: 15, output: 9, total: 24 },
465-
cost: { input: 1, output: 2, toolCost: 4, total: 7 },
471+
cost:
472+
name === 'Fireworks'
473+
? { input: 3, output: 6, toolCost: 4, total: 13 }
474+
: { input: 1, output: 2, toolCost: 4, total: 7 },
466475
})
467476
expectModelIterations(result, 3)
468477
}
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
import type { ChatCompletionChunk } from 'openai/resources/chat/completions'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const { mockCreate, mockExecuteProviderTool } = vi.hoisted(() => ({
9+
mockCreate: vi.fn(),
10+
mockExecuteProviderTool: vi.fn(),
11+
}))
12+
13+
vi.mock('openai', () => ({
14+
default: vi.fn().mockImplementation(
15+
class {
16+
chat = { completions: { create: mockCreate } }
17+
}
18+
),
19+
}))
20+
21+
vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 10 }))
22+
23+
vi.mock('@/providers/runtime-context', () => ({
24+
executeProviderTool: mockExecuteProviderTool,
25+
}))
26+
27+
import type { StreamingExecution } from '@/executor/types'
28+
import type { ProviderRequest, ProviderResponse, ProviderToolConfig } from '@/providers/types'
29+
import { xAIProvider } from '@/providers/xai'
30+
31+
interface XAITestUsage {
32+
prompt_tokens: number
33+
completion_tokens: number
34+
total_tokens: number
35+
prompt_tokens_details?: { cached_tokens: number }
36+
completion_tokens_details?: { reasoning_tokens: number }
37+
cost_in_usd_ticks?: number
38+
}
39+
40+
function textResponse(
41+
content: string,
42+
usage: XAITestUsage,
43+
serviceTier: 'default' | 'priority' = 'default'
44+
) {
45+
return {
46+
choices: [{ message: { content, tool_calls: undefined }, finish_reason: 'stop' }],
47+
usage,
48+
service_tier: serviceTier,
49+
}
50+
}
51+
52+
function toolCallResponse(name: string, args: Record<string, unknown>, usage: XAITestUsage) {
53+
return {
54+
choices: [
55+
{
56+
message: {
57+
content: null,
58+
tool_calls: [
59+
{
60+
id: 'call_1',
61+
type: 'function',
62+
function: { name, arguments: JSON.stringify(args) },
63+
},
64+
],
65+
},
66+
finish_reason: 'tool_calls',
67+
},
68+
],
69+
usage,
70+
service_tier: 'default',
71+
}
72+
}
73+
74+
function tool(id: string): ProviderToolConfig {
75+
return {
76+
id,
77+
name: id,
78+
description: 'test tool',
79+
params: {},
80+
parameters: { type: 'object', properties: {}, required: [] },
81+
}
82+
}
83+
84+
async function drainStream(stream: ReadableStream<unknown>): Promise<void> {
85+
const reader = stream.getReader()
86+
while (!(await reader.read()).done) {}
87+
}
88+
89+
const baseRequest: ProviderRequest = {
90+
apiKey: 'xai-test-key',
91+
model: 'grok-4.5',
92+
messages: [{ role: 'user', content: 'Hello' }],
93+
}
94+
95+
describe('xAIProvider usage accounting', () => {
96+
beforeEach(() => {
97+
vi.clearAllMocks()
98+
mockCreate.mockReset()
99+
mockExecuteProviderTool.mockReset()
100+
})
101+
102+
it('returns exact cost and detailed normalized tokens for nonstreaming requests', async () => {
103+
mockCreate.mockResolvedValueOnce(
104+
textResponse(
105+
'Hello back',
106+
{
107+
prompt_tokens: 32,
108+
completion_tokens: 9,
109+
total_tokens: 135,
110+
prompt_tokens_details: { cached_tokens: 6 },
111+
completion_tokens_details: { reasoning_tokens: 94 },
112+
cost_in_usd_ticks: 12_345_678,
113+
},
114+
'priority'
115+
)
116+
)
117+
118+
const response = (await xAIProvider.executeRequest(baseRequest)) as ProviderResponse
119+
120+
expect(response.tokens).toEqual({
121+
input: 26,
122+
output: 103,
123+
total: 135,
124+
cacheRead: 6,
125+
reasoning: 94,
126+
})
127+
expect(response.cost?.total).toBe(0.0012345678)
128+
expect((response.cost?.input ?? 0) + (response.cost?.output ?? 0)).toBeCloseTo(0.0012345678, 10)
129+
expect(response.timing?.timeSegments?.[0]).toMatchObject({
130+
tokens: response.tokens,
131+
cost: { total: 0.0012345678 },
132+
provider: 'xai',
133+
})
134+
})
135+
136+
it('accumulates exact cost and detailed tokens across tool-loop turns', async () => {
137+
mockCreate
138+
.mockResolvedValueOnce(
139+
toolCallResponse(
140+
'lookup',
141+
{ id: 7 },
142+
{
143+
prompt_tokens: 10,
144+
completion_tokens: 3,
145+
total_tokens: 14,
146+
prompt_tokens_details: { cached_tokens: 2 },
147+
completion_tokens_details: { reasoning_tokens: 1 },
148+
cost_in_usd_ticks: 1_000_000,
149+
}
150+
)
151+
)
152+
.mockResolvedValueOnce(
153+
textResponse('Found it', {
154+
prompt_tokens: 20,
155+
completion_tokens: 4,
156+
total_tokens: 26,
157+
prompt_tokens_details: { cached_tokens: 5 },
158+
completion_tokens_details: { reasoning_tokens: 2 },
159+
cost_in_usd_ticks: 2_000_000,
160+
})
161+
)
162+
mockExecuteProviderTool.mockResolvedValueOnce({ success: true, output: { value: 42 } })
163+
164+
const response = (await xAIProvider.executeRequest({
165+
...baseRequest,
166+
tools: [tool('lookup')],
167+
})) as ProviderResponse
168+
169+
expect(mockExecuteProviderTool).toHaveBeenCalledWith(
170+
'lookup',
171+
expect.objectContaining({ id: 7 }),
172+
expect.anything()
173+
)
174+
expect(response.content).toBe('Found it')
175+
expect(response.tokens).toEqual({
176+
input: 23,
177+
output: 10,
178+
total: 40,
179+
cacheRead: 7,
180+
reasoning: 3,
181+
})
182+
expect(response.cost?.total).toBe(0.0003)
183+
expect(response.toolResults).toEqual([{ value: 42 }])
184+
expect(
185+
response.timing?.timeSegments?.filter((segment) => segment.type === 'model')
186+
).toHaveLength(2)
187+
for (const segment of response.timing?.timeSegments?.filter(
188+
(candidate) => candidate.type === 'model'
189+
) ?? []) {
190+
expect(segment.tokens).toBeDefined()
191+
expect(segment.cost?.total).toBeGreaterThan(0)
192+
}
193+
})
194+
195+
it('settles exact cost and detailed tokens after draining a direct stream', async () => {
196+
const usage: XAITestUsage = {
197+
prompt_tokens: 32,
198+
completion_tokens: 9,
199+
total_tokens: 135,
200+
prompt_tokens_details: { cached_tokens: 6 },
201+
completion_tokens_details: { reasoning_tokens: 94 },
202+
cost_in_usd_ticks: 12_345_678,
203+
}
204+
const chunks = (async function* (): AsyncGenerator<ChatCompletionChunk> {
205+
yield {
206+
id: 'xai-1',
207+
choices: [{ index: 0, delta: { content: 'Streamed' }, finish_reason: null }],
208+
created: 0,
209+
model: 'grok-4.5',
210+
object: 'chat.completion.chunk',
211+
}
212+
yield {
213+
id: 'xai-1',
214+
choices: [],
215+
created: 0,
216+
model: 'grok-4.5',
217+
object: 'chat.completion.chunk',
218+
usage,
219+
service_tier: 'priority',
220+
} as unknown as ChatCompletionChunk
221+
})()
222+
mockCreate.mockResolvedValueOnce(chunks)
223+
224+
const result = (await xAIProvider.executeRequest({
225+
...baseRequest,
226+
stream: true,
227+
})) as StreamingExecution
228+
await drainStream(result.stream)
229+
230+
expect(result.execution.output.content).toBe('Streamed')
231+
expect(result.execution.output.tokens).toEqual({
232+
input: 26,
233+
output: 103,
234+
total: 135,
235+
cacheRead: 6,
236+
reasoning: 94,
237+
})
238+
expect(result.execution.output.cost?.total).toBe(0.0012345678)
239+
expect(result.execution.output.providerTiming?.timeSegments?.[0]).toMatchObject({
240+
assistantContent: 'Streamed',
241+
tokens: result.execution.output.tokens,
242+
cost: { total: 0.0012345678 },
243+
provider: 'xai',
244+
})
245+
expect(mockCreate.mock.calls[0][0]).toMatchObject({
246+
stream: true,
247+
stream_options: { include_usage: true },
248+
})
249+
})
250+
})

apps/sim/providers/xai/usage.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ export function createXAIUsageTotals(model: string): XAIUsageTotals {
139139
const emptyTurn = priceXAIUsage(model, undefined)
140140
return {
141141
tokens: { input: 0, output: 0, total: 0 },
142-
cost: { ...emptyTurn.cost, total: 0 },
142+
cost: { ...emptyTurn.cost, input: 0, output: 0, total: 0 },
143143
providerCostTicks: 0,
144144
fallbackCost: 0,
145145
}
@@ -157,8 +157,8 @@ export function addXAIUsage(accumulator: XAIUsageTotals, turn: XAITurnUsage): vo
157157
const reasoning = (accumulator.tokens.reasoning ?? 0) + (turn.tokens.reasoning ?? 0)
158158
if (reasoning > 0) accumulator.tokens.reasoning = reasoning
159159

160-
accumulator.cost.input = roundUsd(accumulator.cost.input + turn.cost.input, 8)
161-
accumulator.cost.output = roundUsd(accumulator.cost.output + turn.cost.output, 8)
160+
accumulator.cost.input = roundUsd(accumulator.cost.input + turn.cost.input)
161+
accumulator.cost.output = roundUsd(accumulator.cost.output + turn.cost.output)
162162
accumulator.cost.pricing = turn.cost.pricing
163163

164164
if (turn.providerCostTicks !== undefined) {

0 commit comments

Comments
 (0)