Skip to content

Commit 21cb3c3

Browse files
committed
Fixes
1 parent 0508acf commit 21cb3c3

15 files changed

Lines changed: 268 additions & 20 deletions

File tree

apps/sim/executor/execution/block-executor.test.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@ import { BlockType } from '@/executor/constants'
1010
import type { DAGNode } from '@/executor/dag/builder'
1111
import { BlockExecutor } from '@/executor/execution/block-executor'
1212
import { ExecutionState } from '@/executor/execution/state'
13-
import type { BlockHandler, ExecutionContext } from '@/executor/types'
13+
import type { BlockHandler, ExecutionContext, NormalizedBlockOutput } from '@/executor/types'
1414
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
1515
import { VariableResolver } from '@/executor/variables/resolver'
16+
import { installStreamingCostPolicy } from '@/providers/cost-policy'
1617
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
1718

1819
const { mockUploadFile } = vi.hoisted(() => ({
@@ -661,6 +662,9 @@ describe('BlockExecutor streaming pump', () => {
661662
attachThinkingOnDrain?: string
662663
failAfterText?: string
663664
onFullContent?: (content: string) => void | Promise<void>
665+
outputOverrides?: Partial<NormalizedBlockOutput>
666+
attachModelCostOnDrain?: { input: number; output: number; total: number }
667+
nonBillableStreamingCost?: boolean
664668
}): BlockHandler {
665669
return {
666670
canHandle: () => true,
@@ -683,6 +687,13 @@ describe('BlockExecutor streaming pump', () => {
683687
timeSegments: [timeSegment],
684688
},
685689
cost: { input: 0, output: 0, total: 0 },
690+
...options.outputOverrides,
691+
}
692+
if (options.nonBillableStreamingCost) {
693+
installStreamingCostPolicy(output as NormalizedBlockOutput, {
694+
billable: false,
695+
multiplier: 0,
696+
})
686697
}
687698

688699
const stream = new ReadableStream({
@@ -702,6 +713,9 @@ describe('BlockExecutor streaming pump', () => {
702713
if (options.attachThinkingOnDrain) {
703714
timeSegment.thinkingContent = options.attachThinkingOnDrain
704715
}
716+
if (options.attachModelCostOnDrain) {
717+
timeSegment.cost = options.attachModelCostOnDrain
718+
}
705719
controller.close()
706720
},
707721
})
@@ -786,6 +800,53 @@ describe('BlockExecutor streaming pump', () => {
786800
expect(state.getBlockOutput(block.id)?.content).toBe('offline answer')
787801
})
788802

803+
it('preserves trusted custom-model billing metadata through streamed structured output', async () => {
804+
const estimatedProviderCost = {
805+
available: true,
806+
input: 0.0003,
807+
output: 0.0006,
808+
total: 0.0009,
809+
pricing: {
810+
input: 0.3,
811+
cachedInput: 0.059,
812+
output: 1.2,
813+
updatedAt: '2026-08-03',
814+
},
815+
}
816+
const handler = createAgentEventsStreamingHandler({
817+
events: [
818+
{
819+
type: 'text_delta',
820+
text: JSON.stringify({
821+
answer: 'ok',
822+
estimatedProviderCost: { available: true, total: 999 },
823+
}),
824+
turn: 'final',
825+
},
826+
],
827+
outputOverrides: { estimatedProviderCost },
828+
attachModelCostOnDrain: { input: 0.1, output: 0.2, total: 0.3 },
829+
nonBillableStreamingCost: true,
830+
})
831+
const { executor, block, state } = createExecutor(handler)
832+
;(block.config.params as Record<string, unknown>).responseFormat = {
833+
name: 'answer',
834+
schema: { type: 'object', properties: { answer: { type: 'string' } } },
835+
}
836+
837+
await executor.execute(createContext(state), createNode(block), block)
838+
839+
expect(state.getBlockOutput(block.id)).toMatchObject({
840+
answer: 'ok',
841+
tokens: { input: 1, output: 2, total: 3 },
842+
cost: { input: 0, output: 0, total: 0 },
843+
estimatedProviderCost,
844+
providerTiming: {
845+
timeSegments: [{ cost: { input: 0, output: 0, total: 0 } }],
846+
},
847+
})
848+
})
849+
789850
it('throws on mid-stream provider error (no truncated success)', async () => {
790851
const handler = createAgentEventsStreamingHandler({
791852
failAfterText: 'partial',

apps/sim/executor/execution/block-executor.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import {
6161
FUNCTION_BLOCK_DISPLAY_CODE_KEY,
6262
type VariableResolver,
6363
} from '@/executor/variables/resolver'
64+
import { finalizeStreamingCostPolicy } from '@/providers/cost-policy'
6465
import { createAgentStreamPump } from '@/providers/stream-pump'
6566
import type { SerializedBlock } from '@/serializer/types'
6667
import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants'
@@ -916,6 +917,11 @@ export class BlockExecutor {
916917
await onStreamPromise.catch(() => {})
917918
}
918919
throw error instanceof Error ? error : new Error(String(error))
920+
} finally {
921+
// Provider segments receive their final usage/cost only while draining.
922+
// Reapply the previously installed billing policy before logs or traces
923+
// can observe those late-written values.
924+
finalizeStreamingCostPolicy(streamingExec.execution?.output)
919925
}
920926

921927
if (onStreamPromise) {
@@ -995,6 +1001,10 @@ export class BlockExecutor {
9951001
toolCalls: executionOutput.toolCalls,
9961002
providerTiming: executionOutput.providerTiming,
9971003
cost: executionOutput.cost,
1004+
// Provider metadata is trusted execution state, not model output.
1005+
// Preserve it across structured-output reconstruction and overwrite
1006+
// any same-named field the model emitted in `parsed`.
1007+
estimatedProviderCost: executionOutput.estimatedProviderCost,
9981008
model: executionOutput.model,
9991009
}
10001010
parsedForFormat = true

apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,38 @@ describe('validateInputsForBlock', () => {
497497
expect(result.validInputs.model).toBe('gpt-5.4')
498498
})
499499

500+
it('accepts and canonicalizes a custom-model object from the VFS JSON schema', () => {
501+
const result = validateInputsForBlock(
502+
'agent',
503+
{
504+
customModelConfig: {
505+
provider: 'fireworks',
506+
model: 'accounts/fireworks/models/minimax-m2p7',
507+
credentials: { mode: 'explicit', apiKey: '{{FIREWORKS_API_KEY}}' },
508+
},
509+
},
510+
'agent-1'
511+
)
512+
513+
expect(result.errors).toHaveLength(0)
514+
expect(JSON.parse(result.validInputs.customModelConfig)).toMatchObject({
515+
provider: 'fireworks',
516+
model: 'fireworks/minimax-m2.7',
517+
credentials: { mode: 'explicit', apiKey: '{{FIREWORKS_API_KEY}}' },
518+
})
519+
})
520+
521+
it('rejects an invalid custom-model object before execution', () => {
522+
const result = validateInputsForBlock(
523+
'agent',
524+
{ customModelConfig: { provider: 'unknown', model: 'future' } },
525+
'agent-1'
526+
)
527+
528+
expect(result.validInputs.customModelConfig).toBeUndefined()
529+
expect(result.errors[0]?.error).toContain('Invalid custom model configuration')
530+
})
531+
500532
it('rejects a pattern-matching but uncataloged id even with surrounding whitespace', () => {
501533
const result = validateInputsForBlock('agent', { model: ' gpt-100-ultra ' }, 'agent-1')
502534

apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { getBlock } from '@/blocks/registry'
1717
import type { SubBlockConfig } from '@/blocks/types'
1818
import { getAgentModelOptions, getModelOptions } from '@/blocks/utils'
1919
import { BlockType, EDGE, normalizeName } from '@/executor/constants'
20-
import { isCustomModel } from '@/providers/custom-model'
20+
import { isCustomModel, parseCustomModelConfig } from '@/providers/custom-model'
2121
import {
2222
isAutoModel,
2323
isCustomJsonOnlyModel,
@@ -527,6 +527,27 @@ export function validateValueForSubBlockType(
527527
}
528528

529529
case 'code': {
530+
if (fieldName === 'customModelConfig') {
531+
try {
532+
// VFS advertises this field as JSON, while the editor's code
533+
// subblock persists text. Accept the object Mothership naturally
534+
// produces, validate it now, and store one canonical JSON string.
535+
const parsed = parseCustomModelConfig(value)
536+
return { valid: true, value: JSON.stringify(parsed, null, 2) }
537+
} catch (error) {
538+
return {
539+
valid: false,
540+
error: {
541+
blockId,
542+
blockType,
543+
field: fieldName,
544+
value,
545+
error: `Invalid custom model configuration: ${toError(error).message}`,
546+
},
547+
}
548+
}
549+
}
550+
530551
// Code must be a string (content can be JS, Python, JSON, SQL, HTML, etc.)
531552
if (typeof value !== 'string') {
532553
return {

apps/sim/lib/copilot/vfs/serializers.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1060,7 +1060,7 @@ export function serializeTriggerSchema(trigger: {
10601060
description: trigger.description,
10611061
version: trigger.version,
10621062
webhook: trigger.webhook || undefined,
1063-
subBlocks: trigger.subBlocks.map(serializeSubBlock),
1063+
subBlocks: trigger.subBlocks.map((subBlock) => serializeSubBlock(subBlock, false)),
10641064
outputs: trigger.outputs,
10651065
},
10661066
null,
@@ -1080,7 +1080,7 @@ export function serializeBuiltinTriggerSchema(block: BlockConfig): string {
10801080
longDescription: block.longDescription || undefined,
10811081
category: 'builtin',
10821082
triggers: block.triggers || undefined,
1083-
subBlocks: block.subBlocks.map(serializeSubBlock),
1083+
subBlocks: block.subBlocks.map((subBlock) => serializeSubBlock(subBlock, false)),
10841084
inputs: block.inputs,
10851085
outputs: block.outputs,
10861086
},

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ describe('block outputs parity', () => {
5151
properties: {
5252
min: { type: 'number' },
5353
max: { type: 'number' },
54+
cost: { type: 'string' },
5455
},
5556
required: ['min', 'max'],
5657
additionalProperties: false,
@@ -67,8 +68,19 @@ describe('block outputs parity', () => {
6768
expect(rootPaths(paths)).toEqual(Object.keys(outputs).sort())
6869
expect(paths).toContain('min')
6970
expect(paths).toContain('max')
71+
expect(paths).toEqual(
72+
expect.arrayContaining([
73+
'model',
74+
'tokens',
75+
'toolCalls',
76+
'providerTiming',
77+
'cost',
78+
'estimatedProviderCost',
79+
])
80+
)
7081
expect(getEffectiveBlockOutputType('agent', 'min', subBlocks, options)).toBe('number')
7182
expect(getEffectiveBlockOutputType('agent', 'max', subBlocks, options)).toBe('number')
83+
expect(getEffectiveBlockOutputType('agent', 'cost', subBlocks, options)).toBe('json')
7284
})
7385

7486
it.concurrent('surfaces start run metadata paths only when the toggle is on', () => {

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

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,15 @@ interface EffectiveOutputOptions {
4141

4242
type ConditionValue = string | number | boolean
4343

44+
const AGENT_STRUCTURED_METADATA_OUTPUTS = new Set([
45+
'model',
46+
'tokens',
47+
'toolCalls',
48+
'providerTiming',
49+
'cost',
50+
'estimatedProviderCost',
51+
])
52+
4453
/**
4554
* Checks if a value is a valid primitive for condition comparison.
4655
*/
@@ -410,7 +419,16 @@ export function getEffectiveBlockOutputs(
410419

411420
if (blockType === 'agent') {
412421
const responseFormatOutputs = getResponseFormatOutputs(subBlocks, 'agent')
413-
if (responseFormatOutputs) return responseFormatOutputs
422+
if (responseFormatOutputs) {
423+
const agentOutputs = getBlockOutputs('agent', subBlocks, false, { includeHidden })
424+
const trustedMetadata = Object.fromEntries(
425+
Object.entries(agentOutputs).filter(([key]) => AGENT_STRUCTURED_METADATA_OUTPUTS.has(key))
426+
)
427+
// Runtime structured output is rebuilt the same way: model fields first,
428+
// then trusted provider metadata. Keep discovery aligned and prevent a
429+
// response schema from redefining billing/token fields.
430+
return { ...responseFormatOutputs, ...trustedMetadata }
431+
}
414432
}
415433

416434
let baseOutputs: OutputDefinition

apps/sim/lib/workflows/credentials/credential-extractor.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,10 @@ interface SanitizedWorkflowState {
231231
* workflow leaves its workspace, preserving only an unresolved env reference
232232
* for explicit exports when requested.
233233
*/
234-
function sanitizeCustomModelCredential(value: unknown, preserveEnvVars: boolean): unknown {
234+
function sanitizeCustomModelCredential(
235+
value: unknown,
236+
preserveEnvVars: boolean
237+
): SubBlockState['value'] {
235238
const wasString = typeof value === 'string'
236239
let parsed: unknown = value
237240
if (wasString) {
@@ -246,7 +249,7 @@ function sanitizeCustomModelCredential(value: unknown, preserveEnvVars: boolean)
246249
const config = structuredClone(parsed) as Record<string, unknown>
247250
const credentials = config.credentials
248251
if (!credentials || typeof credentials !== 'object' || Array.isArray(credentials)) {
249-
return value
252+
return wasString ? value : JSON.stringify(config, null, 2)
250253
}
251254

252255
const sanitizedCredentials = credentials as Record<string, unknown>
@@ -255,7 +258,7 @@ function sanitizeCustomModelCredential(value: unknown, preserveEnvVars: boolean)
255258
preserveEnvVars && typeof apiKey === 'string' && /^\{\{[^{}]+\}\}$/.test(apiKey.trim())
256259
if (!preserveReference) sanitizedCredentials.apiKey = undefined
257260

258-
return wasString ? JSON.stringify(config, null, 2) : config
261+
return JSON.stringify(config, null, 2)
259262
}
260263

261264
/**

apps/sim/providers/cost-policy.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
applySegmentCostPolicy,
1010
buildEstimatedProviderCost,
1111
calculateBillableModelCost,
12+
finalizeStreamingCostPolicy,
1213
installStreamingCostPolicy,
1314
LIST_PRICE_POLICY,
1415
priceModelUsage,
@@ -295,6 +296,34 @@ describe('installStreamingCostPolicy', () => {
295296
total: 0.0015,
296297
})
297298
})
299+
300+
it('scrubs model-segment cost written after streaming interception', () => {
301+
const output = {
302+
cost: { input: 0, output: 0, total: 0 },
303+
providerTiming: {
304+
startTime: new Date(0).toISOString(),
305+
endTime: new Date(1).toISOString(),
306+
duration: 1,
307+
timeSegments: [],
308+
},
309+
} as NormalizedBlockOutput
310+
installStreamingCostPolicy(output, { billable: false, multiplier: 0 })
311+
312+
output.providerTiming?.timeSegments?.push({
313+
type: 'model',
314+
startTime: 0,
315+
endTime: 1,
316+
duration: 1,
317+
cost: { input: 0.1, output: 0.2, total: 0.3 },
318+
})
319+
finalizeStreamingCostPolicy(output)
320+
321+
expect(output.providerTiming?.timeSegments?.[0].cost).toEqual({
322+
input: 0,
323+
output: 0,
324+
total: 0,
325+
})
326+
})
298327
})
299328

300329
describe('applySegmentCostPolicy', () => {

0 commit comments

Comments
 (0)