Skip to content

Commit 34dba4d

Browse files
icecrasher321claude
andcommitted
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>
1 parent 01b3b77 commit 34dba4d

3 files changed

Lines changed: 90 additions & 22 deletions

File tree

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

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -176,11 +176,15 @@ export interface PiRunContext {
176176
/**
177177
* Where a backend reports the cost of Sim-provisioned compute it used.
178178
*
179-
* Only the cloud modes have any: they run the agent in a Sim-paid sandbox,
180-
* while local mode drives the caller's own machine over SSH and costs Sim
181-
* nothing. The handler folds whatever lands here into the block's `toolCost`,
182-
* which is what keeps a BYOK Pi run — model unbilled by definition — from
183-
* reporting no cost at all for a session that ran for tens of minutes.
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.
184188
*/
185189
sandboxCost?: SandboxCostSink
186190
}

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ vi.mock('@/blocks/utils', () => ({
114114
import type { PiRunContext } from '@/executor/handlers/pi/core/backend'
115115
import { PiBlockHandler, parsePiReviewMentions } from '@/executor/handlers/pi/pi-handler'
116116
import type { ExecutionContext, StreamingExecution } from '@/executor/types'
117+
import { readTrustedExecutionCost } from '@/executor/utils/errors'
117118
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
118119
import type { SerializedBlock } from '@/serializer/types'
119120

@@ -313,6 +314,35 @@ describe('PiBlockHandler', () => {
313314
expect(output.cost).toEqual({ input: 0, output: 0, toolCost: 0.0842, total: 0.0842 })
314315
})
315316

317+
it('keeps the sandbox charge on a cloud session whose agent reported an error', async () => {
318+
// The backend returned, so the sandbox was billed and the sink holds the
319+
// charge — but this path throws instead of reaching buildOutput, which is
320+
// what would otherwise have published it.
321+
mockRunCloud.mockImplementation(async (_params: unknown, context: PiRunContext) => {
322+
if (context.sandboxCost) context.sandboxCost.total += 0.0631
323+
return {
324+
totals: { finalText: '', inputTokens: 0, outputTokens: 0, errorMessage: 'agent gave up' },
325+
}
326+
})
327+
328+
const error = await handler
329+
.execute(ctx(), block, {
330+
mode: 'cloud',
331+
task: 'do it',
332+
model: 'claude',
333+
owner: 'o',
334+
repo: 'r',
335+
githubToken: 'ghp',
336+
})
337+
.catch((thrown: unknown) => thrown)
338+
339+
expect(error).toBeInstanceOf(Error)
340+
// `toolCost` is absent by design: the trusted envelope validates exactly the
341+
// three numeric fields it will let cross the handler boundary. `total` is
342+
// what the ledger bills on, and it carries the sandbox charge intact.
343+
expect(readTrustedExecutionCost(error)).toEqual({ input: 0, output: 0, total: 0.0631 })
344+
})
345+
316346
it('leaves a cloud run that provisioned no sandbox uncharged', async () => {
317347
const output = (await handler.execute(ctx(), block, {
318348
mode: 'cloud',

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

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
type PiMemoryConfig,
3838
resolvePiSkills,
3939
} from '@/executor/handlers/pi/core/context'
40+
import type { PiRunTotals } from '@/executor/handlers/pi/core/events'
4041
import { streamTextForEvent } from '@/executor/handlers/pi/core/events'
4142
import {
4243
computePiCost,
@@ -54,7 +55,9 @@ import type {
5455
NormalizedBlockOutput,
5556
StreamingExecution,
5657
} from '@/executor/types'
58+
import { attachTrustedExecutionCost } from '@/executor/utils/errors'
5759
import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal'
60+
import type { ModelCost } from '@/providers/cost-policy'
5861
import { isPiSupportedProvider, resolvePiModelId } from '@/providers/pi-providers'
5962
import { getProviderFromModel } from '@/providers/utils'
6063
import type { SerializedBlock } from '@/serializer/types'
@@ -155,6 +158,34 @@ export function parsePiReviewMentions(value: unknown): string[] {
155158
return mentions
156159
}
157160

161+
/**
162+
* What a Pi block charges: its model tokens plus the Sim-paid sandbox compute.
163+
*
164+
* Sandbox cost rides in `toolCost` so it survives a BYOK run — the model side is
165+
* zero by definition there, and the ledger bills a model row on `total > 0`.
166+
* Folding it in is what makes a BYOK Pi session bill for the provider time it
167+
* actually consumed instead of nothing at all.
168+
*
169+
* Shared with the failure path deliberately: an agent that ran and then reported
170+
* an error consumed exactly the same tokens and sandbox seconds as one that
171+
* succeeded, so both have to arrive at the same number.
172+
*/
173+
function buildPiCost(
174+
model: string,
175+
isBYOK: boolean,
176+
totals: PiRunTotals,
177+
sandboxCost: number
178+
): ModelCost {
179+
const modelCost = computePiCost(model, totals.inputTokens, totals.outputTokens, isBYOK)
180+
if (sandboxCost <= 0) return modelCost
181+
182+
return {
183+
...modelCost,
184+
toolCost: sandboxCost,
185+
total: modelCost.total + sandboxCost,
186+
}
187+
}
188+
158189
export class PiBlockHandler implements BlockHandler {
159190
canHandle(block: SerializedBlock): boolean {
160191
return block.metadata?.id === BlockType.PI
@@ -480,21 +511,7 @@ export class PiBlockHandler implements BlockHandler {
480511
): NormalizedBlockOutput {
481512
const { totals } = result
482513
const endTime = Date.now()
483-
const modelCost = computePiCost(model, totals.inputTokens, totals.outputTokens, isBYOK)
484-
/*
485-
* Sandbox compute rides in `toolCost` so it survives a BYOK run: the model
486-
* side is zero by definition there, and the ledger bills a model row on
487-
* `total > 0`. Folding it in is what makes a BYOK Pi session bill for the
488-
* E2B time it actually consumed instead of nothing at all.
489-
*/
490-
const cost =
491-
sandboxCost > 0
492-
? {
493-
...modelCost,
494-
toolCost: sandboxCost,
495-
total: modelCost.total + sandboxCost,
496-
}
497-
: modelCost
514+
const cost = buildPiCost(model, isBYOK, totals, sandboxCost)
498515
return {
499516
content: totals.finalText,
500517
model,
@@ -574,7 +591,12 @@ export class PiBlockHandler implements BlockHandler {
574591
sandboxCost,
575592
})
576593
if (result.totals.errorMessage) {
577-
controller.error(new Error(result.totals.errorMessage))
594+
const error = new Error(result.totals.errorMessage)
595+
attachTrustedExecutionCost(
596+
error,
597+
buildPiCost(params.model, params.isBYOK, result.totals, sandboxCost.total)
598+
)
599+
controller.error(error)
578600
return
579601
}
580602
if (params.mode === 'cloud_plan' && result.totals.finalText) {
@@ -626,7 +648,19 @@ export class PiBlockHandler implements BlockHandler {
626648
sandboxCost,
627649
})
628650
if (result.totals.errorMessage) {
629-
throw new Error(result.totals.errorMessage)
651+
/*
652+
* The backend returned, so the sandbox was billed and the sink holds the
653+
* charge — but this throw skips `buildOutput`, which is what would have
654+
* published it. Carrying the cost on the error is what keeps a session
655+
* whose agent reported a failure from being run for free, the same way the
656+
* Function handler carries its tool cost onto the error it raises.
657+
*/
658+
const error = new Error(result.totals.errorMessage)
659+
attachTrustedExecutionCost(
660+
error,
661+
buildPiCost(params.model, params.isBYOK, result.totals, sandboxCost.total)
662+
)
663+
throw error
630664
}
631665
if (memoryConfig) {
632666
await appendPiMemory(

0 commit comments

Comments
 (0)