Skip to content

Commit e73afbe

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
preserve streaming usage estimates
1 parent 55814db commit e73afbe

4 files changed

Lines changed: 99 additions & 2 deletions

File tree

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
55
import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache'
66
import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata'
77
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
8+
import { calculateStreamingCost } from '@/lib/tokenization'
89
import { BlockType } from '@/executor/constants'
910
import type { DAGNode } from '@/executor/dag/builder'
1011
import { BlockExecutor } from '@/executor/execution/block-executor'
@@ -954,6 +955,51 @@ describe('BlockExecutor streaming pump', () => {
954955
expect(state.getBlockOutput(block.id)?.content).toBe('offline answer')
955956
})
956957

958+
it('estimates missing streaming usage from resolved input before sanitizing logs', async () => {
959+
const secret = `sk-${'resolved-secret-'.repeat(20)}`
960+
const params = { prompt: '{{OPENAI_API_KEY}}', model: 'gpt-4o' }
961+
const handler: BlockHandler = {
962+
canHandle: () => true,
963+
execute: async (_ctx, _block, resolvedInputs) => ({
964+
stream: new ReadableStream({
965+
start(controller) {
966+
controller.enqueue(new TextEncoder().encode('streamed answer'))
967+
controller.close()
968+
},
969+
}),
970+
execution: {
971+
success: true,
972+
output: { content: '', model: 'gpt-4o' },
973+
logs: [],
974+
metadata: {
975+
startTime: new Date().toISOString(),
976+
endTime: new Date().toISOString(),
977+
duration: 1,
978+
},
979+
},
980+
}),
981+
}
982+
const { executor, block, state } = createExecutor(handler, params)
983+
const ctx = createContext(state)
984+
ctx.environmentVariables = { OPENAI_API_KEY: secret }
985+
986+
await executor.execute(ctx, createNode(block), block)
987+
988+
const expected = calculateStreamingCost(
989+
'gpt-4o',
990+
JSON.stringify({ prompt: secret, model: 'gpt-4o' }),
991+
'streamed answer'
992+
)
993+
expect(state.getBlockOutput(block.id)?.tokens).toEqual(expected.tokens)
994+
expect(state.getBlockOutput(block.id)?.cost).toEqual(expected.cost)
995+
expect(ctx.blockLogs[0].input).toEqual({
996+
prompt: '{{OPENAI_API_KEY}}',
997+
model: 'gpt-4o',
998+
})
999+
expect(ctx.blockLogs[0].output?.tokens).toEqual(expected.tokens)
1000+
expect(JSON.stringify(ctx.blockLogs[0])).not.toContain(secret)
1001+
})
1002+
9571003
it('throws on mid-stream provider error (no truncated success)', async () => {
9581004
const handler = createAgentEventsStreamingHandler({
9591005
failAfterText: 'partial',

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
77
import { compactExecutionPayload } from '@/lib/execution/payloads/serializer'
88
import { redactLargeValueRefsInValue } from '@/lib/logs/execution/pii-large-values'
99
import { redactObjectStrings } from '@/lib/logs/execution/pii-redaction'
10+
import { processStreamingBlockLog } from '@/lib/tokenization'
1011
import {
1112
containsUserFileWithMetadata,
1213
hydrateUserFilesWithBase64,
@@ -1037,6 +1038,19 @@ export class BlockExecutor {
10371038
if (!parsedForFormat) {
10381039
executionOutput.content = fullContent
10391040
}
1041+
1042+
// Fallback usage estimation must happen while the resolved input is
1043+
// still available. The log copy is sanitized later, and estimating from
1044+
// `{{ENV_VAR}}` placeholders would skew token counts and cost.
1045+
processStreamingBlockLog(
1046+
{
1047+
blockId,
1048+
blockType: block.metadata?.id,
1049+
input: resolvedInputs,
1050+
output: streamingExec.execution.output,
1051+
},
1052+
fullContent
1053+
)
10401054
}
10411055

10421056
if (streamingExec.onFullContent) {
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { processStreamingBlockLog } from '@/lib/tokenization/streaming'
3+
4+
describe('processStreamingBlockLog', () => {
5+
it('does not estimate usage from sanitized environment references', () => {
6+
const log = {
7+
blockId: 'agent-1',
8+
blockType: 'agent',
9+
input: {
10+
prompt: 'Use {{OPENAI_API_KEY}} to answer',
11+
model: 'gpt-4o',
12+
},
13+
output: {
14+
content: 'streamed answer',
15+
model: 'gpt-4o',
16+
},
17+
}
18+
19+
expect(processStreamingBlockLog(log, 'streamed answer')).toBe(false)
20+
expect(log.output).toEqual({
21+
content: 'streamed answer',
22+
model: 'gpt-4o',
23+
})
24+
})
25+
})

apps/sim/lib/tokenization/streaming.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,17 @@ import {
1616
import type { BlockLog } from '@/executor/types'
1717

1818
const logger = createLogger('StreamingTokenization')
19+
const ENVIRONMENT_REFERENCE_PATTERN = /\{\{[^}]+\}\}/
20+
21+
type StreamingTokenizationLog = Pick<BlockLog, 'blockId' | 'blockType' | 'input' | 'output'>
1922

2023
/**
2124
* Processes a block log and adds tokenization data if needed
2225
*/
23-
export function processStreamingBlockLog(log: BlockLog, streamedContent: string): boolean {
26+
export function processStreamingBlockLog(
27+
log: StreamingTokenizationLog,
28+
streamedContent: string
29+
): boolean {
2430
// Check if this block should be tokenized
2531
if (!isTokenizableBlockType(log.blockType)) {
2632
return false
@@ -47,6 +53,12 @@ export function processStreamingBlockLog(log: BlockLog, streamedContent: string)
4753

4854
// Prepare input text from log
4955
const inputText = extractTextContent(log.input)
56+
// Environment values are restored to references before logs leave the
57+
// executor. Never use those shorter placeholders as a billing estimate:
58+
// the executor performs this fallback once with the raw resolved input.
59+
if (ENVIRONMENT_REFERENCE_PATTERN.test(inputText)) {
60+
return false
61+
}
5062

5163
// Calculate streaming cost
5264
const systemPrompt =
@@ -101,7 +113,7 @@ export function processStreamingBlockLog(log: BlockLog, streamedContent: string)
101113
/**
102114
* Determines the appropriate model for a block
103115
*/
104-
function getModelForBlock(log: BlockLog): string {
116+
function getModelForBlock(log: StreamingTokenizationLog): string {
105117
// Try to get model from output first
106118
if (log.output?.model?.trim()) {
107119
return log.output.model

0 commit comments

Comments
 (0)