Skip to content

Commit f607c01

Browse files
BillLeoutsakosvl346Bill Leoutsakosicecrasher321claude
authored
feat(billing): track E2B and Daytona Function sandbox usage (#7184)
* feat(billing): record function sandbox usage * fix(billing): charge function user-code failures * fix(billing): correct sandbox trace cost boundaries * fix(billing): tighten sandbox completion boundaries * test(billing): check the metered sandbox amount against a real provider The pricing unit test pins the arithmetic and the conformance suite proves a cost is produced, attached to the right outcomes, and routed — but that suite stubs the provider and mocks Date.now() with a counter advancing one millisecond per call. Under that clock `total > 0` is the strongest claim available, and it holds equally well if the metered window is anchored to the wrong instants or the resource constants are wrong. Bounds the charge between what the sleep must cost and what the wall clock could justify, so a wrong rate, a wrong vCPU/memory constant, and a mis-anchored window all fail. Provider-agnostic via resolveProvider, opt-in behind SANDBOX_BILLING_SMOKE=1 like the sibling smoke suites. Verified against both providers: E2B billed 9.031s of a 9.302s call at $0.1656/hr, Daytona 8.435s of 8.721s at $0.16668/hr — both matching published rates, both excluding ~275ms of Sim-side overhead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(billing): meter the sandbox a cloud Pi session runs in Pi's own sandbox was never metered. withPiSandbox called createSandbox without the meterUsage argument, so only sandboxes created through executeFunctionRequest were charged — and Pi's is the larger consumer by an order of magnitude. A Function block holds one for seconds; a Pi session holds one for a minimum lifetime of 31 minutes. The gap was worst exactly where it was least visible. A Pi coding agent normally runs BYOK, so its model cost is zero by definition, and the ledger bills a model row on total > 0. With the sandbox unmetered, such a run produced a zero-cost model_unbilled row and Sim collected only the flat execution fee while paying its provider for the whole session. Threads a cost sink through PiRunContext, which is the seam backends already receive and the only one that reaches all four cloud modes. The handler owns one sink covering both sandbox sources — Function tools in local mode, the agent's own sandbox in cloud mode — so neither can be dropped where the cost is folded into the block's output. It rides in toolCost for the same reason the Function tool cost already does: that is what survives the BYOK zeroing. Unlike the Function path this charges on creation rather than on a completed session. A Function run is seconds long, so absorbing one the provider failed to deliver is cheap and reads as fair; tens of minutes of Pi compute is consumed whether the agent finished, errored, or was cancelled, and billing only clean endings would mean paying for every other one. A create that throws still costs nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(billing): check the Pi sandbox charge against a real provider The handler test mocks the backend and writes into the cost sink by hand, so it proves the wiring from a backend to the block's cost and nothing more — it would still pass if withPiSandbox never metered at all, which is precisely the bug that path had. Holds a real Pi sandbox open for a known interval and bounds the charge between what that interval must cost and what the whole session could justify. Verified to fail against the original unmetered call with "expected 0 to be greater than or equal to 0.00023", and to pass once the sink is threaded: 5.949s billed of a 6.141s session on E2B. The second case pins the other half of the contract — a caller that supplies no sink is not charged, which is what keeps mothership and other internal Pi sandboxes free. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(billing): charge a Pi session only when it completed Aligns Pi with the outcome policy the Function path already applies rather than keeping the divergence the previous commit introduced. A session that ends by throwing — a provider crash, a lifetime limit, a cancellation — is absorbed, because a charge nobody can tie to delivered work is not one worth defending, and consistency across the two sandbox paths is worth more than recovering the cost of runs that failed. A command exiting non-zero is still billed: the callback returns normally there and the agent produced its answer, which is the same reason the Function path bills its own non-zero exits. The window still closes at teardown, so a completed session is charged for the whole time the provider held its sandbox. Verified on both providers, including that the new case fails when the charge is applied unconditionally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(billing): keep the charge on completed runs that fail after execution Three paths dropped cost the sandbox had already earned. A harvest that cannot return what the run produced — more files than the export limit, nesting past the listing depth, or an output directory the code deleted — was excluded from the billable-error set. All three arrive only after the sandbox has executed and all three are the caller's to fix, so they belong with the post-completion export failures the policy already bills rather than the provider failures it absorbs. A completed run whose code wrote one file too many went free. That also left the route with nothing to read: it already consults readTrustedSandboxOutputCost for these errors, so attaching the cost at the sandbox layer is what carries it into the response. Separately, a Function block whose handler succeeded could still fail in the steps that follow it — base64 hydration, and large-value redaction that throws rather than emit unredacted data. Those errors carry no cost of their own, so the completed sandbox went unbilled. The handler's cost is now held across that window, in the same way streamingPartialOutput already is, and used only when the error has none. The new conformance case was confirmed to fail against the narrower catch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(billing): carry the Pi charge onto a session its agent failed A backend that returns a result carrying `totals.errorMessage` has already run: the sandbox was billed and the sink holds the charge. But that path throws instead of reaching `buildOutput`, which is what publishes the cost, so the charge was accumulated and then dropped — lost revenue rather than an over-charge. Both failure paths now carry it on the error they raise, the same way the Function handler carries its tool cost, so `handleBlockError` can pick it up. An agent that ran and then reported a failure consumed the same tokens and sandbox seconds as one that succeeded, which is why the cost computation is now shared between the two rather than duplicated. Also corrects the sink's doc comment. Local mode does fill it — the agent runs on the caller's own machine and costs Sim nothing, but a `function_execute` among the Sim tools it calls bills its own remote sandbox into the same total. The new case was confirmed to fail without the attach. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local> Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 95d1969 commit f607c01

39 files changed

Lines changed: 1897 additions & 149 deletions

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

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
55
* client has already seen and cannot re-run the deterministic post-processing.
66
*/
77
import { beforeEach, describe, expect, it, vi } from 'vitest'
8+
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
89
import { BlockType, EDGE } from '@/executor/constants'
910
import type { DAGNode } from '@/executor/dag/builder'
1011
import { BlockExecutor } from '@/executor/execution/block-executor'
1112
import { ExecutionState } from '@/executor/execution/state'
1213
import type { BlockHandler, ExecutionContext } from '@/executor/types'
14+
import { attachTrustedExecutionCost } from '@/executor/utils/errors'
1315
import { VariableResolver } from '@/executor/variables/resolver'
1416
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
1517

@@ -137,6 +139,68 @@ describe('BlockExecutor retry', () => {
137139
expect(ctx.blockLogs[0]?.tries).toBe(2)
138140
})
139141

142+
it('adds the trusted cost of failed Function tries to the successful result', async () => {
143+
const block = createBlock(enabled)
144+
const firstFailure = new Error('first attempt failed')
145+
attachTrustedExecutionCost(firstFailure, { input: 0, output: 0, total: 0.125 })
146+
const successfulOutput = {
147+
result: 'done',
148+
cost: { input: 0, output: 0, total: 0.25 },
149+
}
150+
attachTrustedExecutionCost(successfulOutput, successfulOutput.cost)
151+
const execute = vi
152+
.fn()
153+
.mockRejectedValueOnce(firstFailure)
154+
.mockResolvedValueOnce(successfulOutput)
155+
const state = new ExecutionState()
156+
const ctx = createContext(state)
157+
const executor = buildExecutor(block, { canHandle: () => true, execute }, state)
158+
159+
const output = await executor.execute(ctx, createNode(block), block)
160+
161+
expect(execute).toHaveBeenCalledTimes(2)
162+
expect(output.cost).toEqual({ input: 0, output: 0, total: 0.375 })
163+
expect(ctx.blockLogs[0]?.output?.cost).toEqual(output.cost)
164+
})
165+
166+
it('keeps earlier trusted Function costs when the final try is an infrastructure error', async () => {
167+
const block = createBlock(enabled)
168+
const firstFailure = new Error('first Function attempt failed')
169+
const secondFailure = new Error('second Function attempt failed')
170+
const finalFailure = new Error('provider unavailable')
171+
attachTrustedExecutionCost(firstFailure, { input: 0, output: 0, total: 0.125 })
172+
attachTrustedExecutionCost(secondFailure, { input: 0, output: 0, total: 0.25 })
173+
const execute = vi
174+
.fn()
175+
.mockRejectedValueOnce(firstFailure)
176+
.mockRejectedValueOnce(secondFailure)
177+
.mockRejectedValueOnce(finalFailure)
178+
const state = new ExecutionState()
179+
const ctx = createContext(state)
180+
const executor = buildExecutor(block, { canHandle: () => true, execute }, state)
181+
182+
await expect(executor.execute(ctx, createNode(block), block)).rejects.toThrow(
183+
'provider unavailable'
184+
)
185+
186+
expect(execute).toHaveBeenCalledTimes(3)
187+
expect(ctx.blockLogs[0]?.output).toEqual({
188+
error: 'provider unavailable',
189+
cost: { input: 0, output: 0, total: 0.375 },
190+
})
191+
192+
const { traceSpans } = buildTraceSpans({
193+
success: false,
194+
output: { error: 'provider unavailable' },
195+
error: 'provider unavailable',
196+
logs: ctx.blockLogs,
197+
})
198+
expect(traceSpans[0]).toMatchObject({
199+
status: 'error',
200+
cost: { input: 0, output: 0, total: 0.375 },
201+
})
202+
})
203+
140204
it('stops at maxTries and rethrows the final error unchanged', async () => {
141205
const block = createBlock(enabled)
142206
const failure = new Error('still failing')

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

Lines changed: 71 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,13 @@ import {
4848
type StreamingExecution,
4949
} from '@/executor/types'
5050
import { streamingResponseFormatProcessor } from '@/executor/utils'
51-
import { buildBlockExecutionError, normalizeError } from '@/executor/utils/errors'
51+
import {
52+
attachTrustedExecutionCost,
53+
buildBlockExecutionError,
54+
normalizeError,
55+
readTrustedExecutionCost,
56+
type TrustedExecutionCost,
57+
} from '@/executor/utils/errors'
5258
import {
5359
buildUnifiedParentIterations,
5460
getIterationContext,
@@ -76,6 +82,20 @@ import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants'
7682

7783
const logger = createLogger('BlockExecutor')
7884

85+
function addTrustedExecutionCosts(
86+
accumulated: TrustedExecutionCost | undefined,
87+
current: TrustedExecutionCost | undefined
88+
): TrustedExecutionCost | undefined {
89+
if (!accumulated) return current
90+
if (!current) return accumulated
91+
92+
return {
93+
input: accumulated.input + current.input,
94+
output: accumulated.output + current.output,
95+
total: accumulated.total + current.total,
96+
}
97+
}
98+
7999
export class BlockExecutor {
80100
private execLogger: Logger
81101

@@ -229,6 +249,17 @@ export class BlockExecutor {
229249
cleanupSelfReference?.()
230250

231251
let streamingPartialOutput: Record<string, any> | undefined
252+
/**
253+
* Cost of a handler that already finished, kept for the catch below.
254+
*
255+
* A Function block's sandbox is paid for the moment it completes, but the
256+
* steps after the handler returns — base64 hydration, and large-value
257+
* redaction that deliberately throws rather than emit unredacted data — can
258+
* still fail the block. The error those raise carries no cost of its own, so
259+
* without holding it here the completed sandbox would go unbilled. Hoisted
260+
* for the same reason `streamingPartialOutput` above is.
261+
*/
262+
let completedHandlerCost: TrustedExecutionCost | undefined
232263
try {
233264
/**
234265
* Only the handler call is retried. A streaming handler returns before any
@@ -241,6 +272,8 @@ export class BlockExecutor {
241272
: handler.execute(blockCtx, block, resolvedInputs, nodeMetadata)
242273
)
243274

275+
completedHandlerCost = readTrustedExecutionCost(output)
276+
244277
const isStreamingExecution =
245278
output && typeof output === 'object' && 'stream' in output && 'execution' in output
246279

@@ -416,7 +449,8 @@ export class BlockExecutor {
416449
inputDisplayRegistry,
417450
isSentinel,
418451
'execution',
419-
streamingPartialOutput
452+
streamingPartialOutput,
453+
completedHandlerCost
420454
)
421455
} finally {
422456
commitBlockRegistry()
@@ -506,15 +540,40 @@ export class BlockExecutor {
506540
const policy = resolveBlockRetryPolicy(block)
507541
if (!policy) return invoke()
508542

543+
const shouldAccumulateFunctionCost = block.metadata?.id === BlockType.FUNCTION
544+
let accumulatedFunctionCost: TrustedExecutionCost | undefined
509545
let tries = 0
510546
try {
511547
for (;;) {
512548
tries++
513549
try {
514-
return await invoke()
550+
const output = await invoke()
551+
if (!shouldAccumulateFunctionCost || !accumulatedFunctionCost || !isRecordLike(output)) {
552+
return output
553+
}
554+
555+
const totalCost = addTrustedExecutionCosts(
556+
accumulatedFunctionCost,
557+
readTrustedExecutionCost(output)
558+
)
559+
if (!totalCost) return output
560+
561+
const outputWithCost = { ...output, cost: totalCost }
562+
attachTrustedExecutionCost(outputWithCost, totalCost)
563+
return outputWithCost as T
515564
} catch (error) {
565+
if (shouldAccumulateFunctionCost) {
566+
accumulatedFunctionCost = addTrustedExecutionCosts(
567+
accumulatedFunctionCost,
568+
readTrustedExecutionCost(error)
569+
)
570+
}
571+
516572
const isFinalTry = tries >= policy.maxTries
517-
if (isFinalTry || ctx.abortSignal?.aborted || !isRetryableBlockError(error)) throw error
573+
if (isFinalTry || ctx.abortSignal?.aborted || !isRetryableBlockError(error)) {
574+
attachTrustedExecutionCost(error, accumulatedFunctionCost)
575+
throw error
576+
}
518577

519578
this.execLogger.warn('Block failed; retrying', {
520579
blockId: block.id,
@@ -528,7 +587,10 @@ export class BlockExecutor {
528587
if (policy.waitBetweenTriesMs > 0) await sleep(policy.waitBetweenTriesMs)
529588

530589
/** `sleep` is not abort-aware, so a run stopped mid-wait must not start another try. */
531-
if (ctx.abortSignal?.aborted) throw error
590+
if (ctx.abortSignal?.aborted) {
591+
attachTrustedExecutionCost(error, accumulatedFunctionCost)
592+
throw error
593+
}
532594
}
533595
}
534596
} finally {
@@ -548,7 +610,8 @@ export class BlockExecutor {
548610
inputDisplayRegistry: ResolvedSecretTraceRegistry | undefined,
549611
isSentinel: boolean,
550612
phase: 'input_resolution' | 'execution',
551-
streamingPartialOutput?: Record<string, any>
613+
streamingPartialOutput?: Record<string, any>,
614+
completedHandlerCost?: TrustedExecutionCost
552615
): Promise<NormalizedBlockOutput> {
553616
const endedAt = new Date().toISOString()
554617
const duration = performance.now() - startTime
@@ -620,8 +683,10 @@ export class BlockExecutor {
620683
return softOutput
621684
}
622685

686+
const trustedExecutionCost = readTrustedExecutionCost(error) ?? completedHandlerCost
623687
const errorOutput: NormalizedBlockOutput = {
624688
error: errorMessage,
689+
...(trustedExecutionCost ? { cost: trustedExecutionCost } : {}),
625690
}
626691

627692
// Keep any answer text already drained before timeout/failure so logs match

apps/sim/executor/handlers/function/function-handler.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
22
import { createTimeoutAbortController } from '@/lib/core/execution-limits'
33
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants'
4+
import { NonRetryableExecutionError } from '@/lib/execution/non-retryable-error'
45
import { BlockType } from '@/executor/constants'
56
import { FunctionBlockHandler } from '@/executor/handlers/function/function-handler'
67
import type { ExecutionContext } from '@/executor/types'
8+
import { readTrustedExecutionCost } from '@/executor/utils/errors'
79
import {
810
FUNCTION_BLOCK_CONTEXT_VARS_KEY,
911
FUNCTION_BLOCK_DISPLAY_CODE_KEY,
@@ -254,6 +256,45 @@ describe('FunctionBlockHandler', () => {
254256
expect(mockExecuteTool).toHaveBeenCalled()
255257
})
256258

259+
it.each([
260+
{ retryable: true, nonRetryable: false },
261+
{ retryable: false, nonRetryable: true },
262+
])(
263+
'attaches trusted cost to a failed execution when retryable is $retryable',
264+
async ({ retryable, nonRetryable }) => {
265+
const cost = { input: 0, output: 0, total: 0.125 }
266+
mockExecuteTool.mockResolvedValue({
267+
success: false,
268+
error: 'Remote Function failed',
269+
retryable,
270+
output: { result: null, stdout: '', cost },
271+
})
272+
273+
let thrown: unknown
274+
try {
275+
await handler.execute(mockContext, mockBlock, { code: 'throw new Error("failed")' })
276+
} catch (error) {
277+
thrown = error
278+
}
279+
280+
expect(thrown).toBeInstanceOf(Error)
281+
expect(thrown instanceof NonRetryableExecutionError).toBe(nonRetryable)
282+
expect(readTrustedExecutionCost(thrown)).toEqual(cost)
283+
}
284+
)
285+
286+
it('attaches trusted cost to a successful execution for retry aggregation', async () => {
287+
const cost = { input: 0, output: 0, total: 0.25 }
288+
mockExecuteTool.mockResolvedValue({
289+
success: true,
290+
output: { result: 42, stdout: '', cost },
291+
})
292+
293+
const output = await handler.execute(mockContext, mockBlock, { code: 'return 42' })
294+
295+
expect(readTrustedExecutionCost(output)).toEqual(cost)
296+
})
297+
257298
it('should pass runtime context variables to function_execute', async () => {
258299
const contextVariables = { __blockRef_0: { result: 'from-block' } }
259300

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

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { mergeFileKeys, mergeLargeValueKeys } from '@/lib/execution/payloads/acc
1212
import { BlockType } from '@/executor/constants'
1313
import type { BlockHandler, ExecutionContext } from '@/executor/types'
1414
import { collectBlockData } from '@/executor/utils/block-data'
15+
import { attachTrustedExecutionCost } from '@/executor/utils/errors'
1516
import {
1617
FUNCTION_BLOCK_CONTEXT_VARS_KEY,
1718
FUNCTION_BLOCK_DISPLAY_CODE_KEY,
@@ -111,15 +112,18 @@ export class FunctionBlockHandler implements BlockHandler {
111112
const result = await executeTool('function_execute', toolParams, { executionContext: ctx })
112113

113114
if (!result.success) {
114-
if (result.retryable === false) {
115-
throw new NonRetryableExecutionError(result.error || 'Function execution is indeterminate')
116-
}
117-
throw new Error(result.error || 'Function execution failed')
115+
const error =
116+
result.retryable === false
117+
? new NonRetryableExecutionError(result.error || 'Function execution is indeterminate')
118+
: new Error(result.error || 'Function execution failed')
119+
attachTrustedExecutionCost(error, result.output?.cost)
120+
throw error
118121
}
119122

120123
mergeLargeValueKeys(ctx, result.largeValueKeys ?? [])
121124
mergeFileKeys(ctx, result.fileKeys ?? [])
122125

126+
attachTrustedExecutionCost(result.output, result.output?.cost)
123127
return result.output
124128
}
125129
}

apps/sim/executor/handlers/pi/cloud/authoring/backend.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -435,7 +435,10 @@ async function runCloudAuthoringPi(
435435
const lifetimeMs = resolvePiRunLifetimeMs(context.signal)
436436
const piTimeoutMs = resolvePiTimeoutMs(lifetimeMs)
437437

438-
const authored = await withPiSandbox<AuthoringPhaseResult>({ lifetimeMs }, async (runner) => {
438+
// Bound to a local so the call stays on one line: inlining the second option
439+
// reflows this whole callback body and buries the change in re-indentation.
440+
const sandboxOptions = { lifetimeMs, cost: context.sandboxCost }
441+
const authored = await withPiSandbox<AuthoringPhaseResult>(sandboxOptions, async (runner) => {
439442
try {
440443
const clone = await raceAbort(
441444
runner.run(params.mode === 'cloud' ? CREATE_PR_CLONE_SCRIPT : UPDATE_BRANCH_CLONE_SCRIPT, {

apps/sim/executor/handlers/pi/cloud/babysit/backend.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -784,7 +784,7 @@ export async function runBabysitPiWithOptions(
784784
const lifetimeMs = resolvePiRunLifetimeMs(context.signal)
785785
const piTimeoutMs = resolvePiTimeoutMs(lifetimeMs)
786786

787-
return await withPiSandbox({ lifetimeMs }, async (runner) => {
787+
return await withPiSandbox({ lifetimeMs, cost: context.sandboxCost }, async (runner) => {
788788
const clone = await raceAbort(
789789
runner.run(BABYSIT_CLONE_SCRIPT, {
790790
envs: {

apps/sim/executor/handlers/pi/cloud/plan/backend.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export const runCloudPlanPi: PiBackendRun<PiCloudPlanRunParams> = async (params,
8080
const thinking = mapThinkingLevel(params.thinkingLevel) ?? 'medium'
8181
const lifetimeMs = resolvePiRunLifetimeMs(context.signal)
8282

83-
return withPiSandbox({ lifetimeMs }, async (runner) => {
83+
return withPiSandbox({ lifetimeMs, cost: context.sandboxCost }, async (runner) => {
8484
try {
8585
const clone = await raceAbort(
8686
runner.run(PLAN_CLONE_SCRIPT, {

apps/sim/executor/handlers/pi/cloud/review/backend.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ export const runCloudReviewPi: PiBackendRun<PiCloudReviewRunParams> = async (par
218218
const lifetimeMs = resolvePiRunLifetimeMs(context.signal)
219219

220220
try {
221-
return await withPiSandbox({ lifetimeMs }, async (runner) => {
221+
return await withPiSandbox({ lifetimeMs, cost: context.sandboxCost }, async (runner) => {
222222
await runner.writeFile(GIT_ASKPASS_PATH, GIT_ASKPASS_SCRIPT)
223223
const fetched = await raceAbort(
224224
runner.run(FETCH_PR_SCRIPT, {

apps/sim/executor/handlers/pi/core/backend.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
*/
1010

1111
import type { TSchema } from 'typebox'
12+
import type { SandboxCostSink } from '@/lib/execution/remote-sandbox/types'
1213
import type { SSHConnectionConfig } from '@/lib/internal/ssh/client'
1314
import type { Message } from '@/executor/handlers/agent/types'
1415
import type { PiEvent, PiRunTotals } from '@/executor/handlers/pi/core/events'
@@ -172,6 +173,20 @@ export type PiRunParams =
172173
export interface PiRunContext {
173174
onEvent: (event: PiEvent) => void
174175
signal?: AbortSignal
176+
/**
177+
* Where a backend reports the cost of Sim-provisioned compute it used.
178+
*
179+
* Both modes can fill it, from different sources. Cloud modes run the agent in
180+
* a Sim-paid sandbox and report that session. Local mode drives the caller's
181+
* own machine over SSH, so the agent itself costs Sim nothing — but the Sim
182+
* tools it calls still run here, and a `function_execute` among them bills its
183+
* own remote sandbox into the same total.
184+
*
185+
* The handler folds whatever lands here into the block's `toolCost`, which is
186+
* what keeps a BYOK Pi run — model unbilled by definition — from reporting no
187+
* cost at all for compute Sim actually paid for.
188+
*/
189+
sandboxCost?: SandboxCostSink
175190
}
176191

177192
/** Final result of a Pi run. */

0 commit comments

Comments
 (0)