From 51df6ec6a7f31d593da11242de78c85c6a7ae34e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 29 Oct 2026 14:13:19 -0700 Subject: [PATCH] fix(billing): price Pi sandboxes at their build resources - Pi sandboxes are built at 4 vCPU / 8 GiB (Daytona adds a 10 GiB disk) but were priced on the 2 vCPU / 4 GiB Function profile, so Pi runs were billed at about half their provider cost. - Pi sizing moves from the build-script package list into lib/execution/remote-sandbox/pi-resources.ts, which both provider builds and sandbox pricing now read, so the build and the price cannot drift apart. --- apps/sim/lib/billing/sandbox-pricing.test.ts | 12 +++++++ apps/sim/lib/billing/sandbox-pricing.ts | 13 +++++++ .../execution/remote-sandbox/pi-resources.ts | 36 +++++++++++++++++++ apps/sim/scripts/build-pi-daytona-snapshot.ts | 21 ++++------- apps/sim/scripts/build-pi-e2b-template.ts | 6 ++-- apps/sim/scripts/pi-sandbox-packages.ts | 29 --------------- 6 files changed, 72 insertions(+), 45 deletions(-) create mode 100644 apps/sim/lib/execution/remote-sandbox/pi-resources.ts diff --git a/apps/sim/lib/billing/sandbox-pricing.test.ts b/apps/sim/lib/billing/sandbox-pricing.test.ts index c64230afc03..f3a565c70d7 100644 --- a/apps/sim/lib/billing/sandbox-pricing.test.ts +++ b/apps/sim/lib/billing/sandbox-pricing.test.ts @@ -28,6 +28,18 @@ describe('sandbox pricing', () => { expect(priceSandboxUsage(pricing, 3_600_000, 3_600_000).rawCost).toBeCloseTo(0.117, 8) }) + it.each([ + ['e2b', 0.3312], + ['daytona', 0.33228], + ] as const)( + 'prices one hour of the Pi sandbox on %s at its build resources', + (provider, expected) => { + const pricing = createSandboxPricing(provider, 'pi', 1) + + expect(priceSandboxUsage(pricing, 3_600_000, 3_600_000).rawCost).toBeCloseTo(expected, 8) + } + ) + it('applies the multiplier once and rounds the final cost to eight decimals', () => { const pricing = createSandboxPricing('e2b', 'code', 1.75) diff --git a/apps/sim/lib/billing/sandbox-pricing.ts b/apps/sim/lib/billing/sandbox-pricing.ts index 6e0eb795891..2cb546ce65b 100644 --- a/apps/sim/lib/billing/sandbox-pricing.ts +++ b/apps/sim/lib/billing/sandbox-pricing.ts @@ -4,6 +4,11 @@ import { FUNCTION_SANDBOX_CPU_COUNT, FUNCTION_SANDBOX_MEMORY_GB, } from '@/lib/execution/remote-sandbox/function-resources' +import { + PI_DAYTONA_DISK_GB, + PI_SANDBOX_CPU_COUNT, + PI_SANDBOX_MEMORY_GB, +} from '@/lib/execution/remote-sandbox/pi-resources' import type { SandboxKind, SandboxProviderId } from '@/lib/execution/remote-sandbox/types' const E2B_CPU_USD_PER_VCPU_SECOND = 0.000014 @@ -53,6 +58,7 @@ const PRICING_BY_PROVIDER: Record = { // The Mothership template is built without explicit resources, so E2B runs it on its // default 2 vCPU and 1 GiB. mothership: { vcpu: 2, memoryGiB: 1, diskGiB: 0 }, + pi: { vcpu: PI_SANDBOX_CPU_COUNT, memoryGiB: PI_SANDBOX_MEMORY_GB, diskGiB: 0 }, }, rates: { cpuUsdPerVcpuSecond: E2B_CPU_USD_PER_VCPU_SECOND, @@ -66,6 +72,13 @@ const PRICING_BY_PROVIDER: Record = { memoryGiB: FUNCTION_SANDBOX_MEMORY_GB, diskGiB: FUNCTION_DAYTONA_DISK_GB, }, + resourcesByKind: { + pi: { + vcpu: PI_SANDBOX_CPU_COUNT, + memoryGiB: PI_SANDBOX_MEMORY_GB, + diskGiB: PI_DAYTONA_DISK_GB, + }, + }, rates: { cpuUsdPerVcpuSecond: DAYTONA_CPU_USD_PER_VCPU_SECOND, memoryUsdPerGiBSecond: DAYTONA_MEMORY_USD_PER_GIB_SECOND, diff --git a/apps/sim/lib/execution/remote-sandbox/pi-resources.ts b/apps/sim/lib/execution/remote-sandbox/pi-resources.ts new file mode 100644 index 00000000000..8100d7c4b5e --- /dev/null +++ b/apps/sim/lib/execution/remote-sandbox/pi-resources.ts @@ -0,0 +1,36 @@ +/** + * vCPU and RAM for the Pi sandbox, shared by both provider builds and by sandbox + * pricing. The two providers had already drifted here. Daytona asked for 4 CPU / 8 GB + * while the E2B template asked for nothing and inherited its base default of + * 2 vCPU / 512 MB — a 16x memory gap between the provider Pi normally runs on and + * the one it fails over to, which would surface as the agent being killed on E2B + * for work that succeeded on Daytona. + * + * 512 MB is the real problem: the Pi CLI is a Node process holding an LLM + * context, running beside a `git clone` of the user's repository, and Node has no + * graceful behaviour at that ceiling — it is OOM-killed, which reaches the user + * as an opaque agent failure rather than a diagnosable one. + * + * Both numbers are at E2B's Professional maximum (8 vCPU / 8192 MB per build, + * 1 vCPU / 512 MB minimum). Sizing is per-second billed against allocated + * resources rather than used ones, so this costs roughly 3x the previous E2B + * default per sandbox-second. On the compute-bound phases that is close to + * neutral — the work finishes proportionally sooner — but Babysit deliberately + * idles between review polls, and idle seconds bill at the same rate. Lower + * {@link PI_SANDBOX_CPU_COUNT} first if that idle time proves dominant: vCPU is + * ~3x the hourly rate of a GB of RAM, and RAM is the dimension that prevents + * hard failures. + */ +export const PI_SANDBOX_CPU_COUNT = 4 + +/** Kept in GB and MB because Daytona takes GB and E2B takes MB. */ +export const PI_SANDBOX_MEMORY_GB = 8 +export const PI_SANDBOX_MEMORY_MB = PI_SANDBOX_MEMORY_GB * 1024 + +/** + * Daytona disk for the Pi sandbox. 10 GB is a hard per-sandbox cap there: the API rejects anything + * larger regardless of plan tier, and raising it requires contacting Daytona. E2B allows 20 GB, so + * this bounds how large a repository Pi can clone on the failover provider, and it is the one + * dimension where the two images legitimately differ. + */ +export const PI_DAYTONA_DISK_GB = 10 diff --git a/apps/sim/scripts/build-pi-daytona-snapshot.ts b/apps/sim/scripts/build-pi-daytona-snapshot.ts index e746ab92ac7..caf352f64de 100644 --- a/apps/sim/scripts/build-pi-daytona-snapshot.ts +++ b/apps/sim/scripts/build-pi-daytona-snapshot.ts @@ -26,34 +26,27 @@ import { Daytona, Image } from '@daytona/sdk' import { getErrorMessage } from '@sim/utils/errors' +import { + PI_DAYTONA_DISK_GB, + PI_SANDBOX_CPU_COUNT, + PI_SANDBOX_MEMORY_GB, +} from '@/lib/execution/remote-sandbox/pi-resources' import { PI_APT, PI_BUN_VERSION_ASSERT, PI_GLOBAL_NPM_PACKAGES, PI_NODE_MAJOR, PI_NODE_VERSION_ASSERT, - PI_SANDBOX_CPU_COUNT, - PI_SANDBOX_MEMORY_GB, } from '@/scripts/pi-sandbox-packages' /** Matches E2B's base: Debian 13 (trixie) with Python 3.13 installed to /usr/local. */ const BASE_IMAGE = 'python:3.13-slim-trixie' -/** - * CPU and memory come from the shared module so the two providers cannot drift - * apart on sizing the way they already had — see {@link PI_SANDBOX_CPU_COUNT}. - * - * Disk stays local because it is not shareable: 10 GB is a HARD per-sandbox cap - * here — the API rejects anything larger ("Disk request 20GB exceeds maximum - * allowed per sandbox (10GB)"), regardless of plan tier, and raising it requires - * contacting Daytona. E2B allows 20 GB, so this is the binding constraint on how - * large a repo Pi can clone on the failover provider, and the one dimension where - * the two images legitimately differ. - */ +/** Sizing comes from the shared module so the two providers cannot drift apart. */ const RESOURCES = { cpu: PI_SANDBOX_CPU_COUNT, memory: PI_SANDBOX_MEMORY_GB, - disk: 10, + disk: PI_DAYTONA_DISK_GB, } as const const APT_PREFIX = 'DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends' diff --git a/apps/sim/scripts/build-pi-e2b-template.ts b/apps/sim/scripts/build-pi-e2b-template.ts index 2f88a7ae287..4677f21cfa0 100644 --- a/apps/sim/scripts/build-pi-e2b-template.ts +++ b/apps/sim/scripts/build-pi-e2b-template.ts @@ -17,14 +17,16 @@ */ import { defaultBuildLogger, Template, waitForTimeout } from '@e2b/code-interpreter' +import { + PI_SANDBOX_CPU_COUNT, + PI_SANDBOX_MEMORY_MB, +} from '@/lib/execution/remote-sandbox/pi-resources' import { PI_APT, PI_BUN_VERSION_ASSERT, PI_GLOBAL_NPM_PACKAGES, PI_NODE_MAJOR, PI_NODE_VERSION_ASSERT, - PI_SANDBOX_CPU_COUNT, - PI_SANDBOX_MEMORY_MB, } from '@/scripts/pi-sandbox-packages' const DEFAULT_TEMPLATE_NAME = 'sim-pi' diff --git a/apps/sim/scripts/pi-sandbox-packages.ts b/apps/sim/scripts/pi-sandbox-packages.ts index 2de8beee8fb..a8eac3a6aa9 100644 --- a/apps/sim/scripts/pi-sandbox-packages.ts +++ b/apps/sim/scripts/pi-sandbox-packages.ts @@ -60,32 +60,3 @@ export const PI_NODE_VERSION_ASSERT = /** Fails the build loudly if the sandbox does not contain the repository's Bun version. */ export const PI_BUN_VERSION_ASSERT = `test "$(bun --version)" = "${PI_BUN_VERSION}"` - -/** - * vCPU and RAM for the Pi sandbox, shared for the same reason the package lists - * are: the two providers had already drifted here. Daytona asked for 4 CPU / 8 GB - * while the E2B template asked for nothing and inherited its base default of - * 2 vCPU / 512 MB — a 16x memory gap between the provider Pi normally runs on and - * the one it fails over to, which would surface as the agent being killed on E2B - * for work that succeeded on Daytona. - * - * 512 MB is the real problem: the Pi CLI is a Node process holding an LLM - * context, running beside a `git clone` of the user's repository, and Node has no - * graceful behaviour at that ceiling — it is OOM-killed, which reaches the user - * as an opaque agent failure rather than a diagnosable one. - * - * Both numbers are at E2B's Professional maximum (8 vCPU / 8192 MB per build, - * 1 vCPU / 512 MB minimum). Sizing is per-second billed against allocated - * resources rather than used ones, so this costs roughly 3x the previous E2B - * default per sandbox-second. On the compute-bound phases that is close to - * neutral — the work finishes proportionally sooner — but Babysit deliberately - * idles between review polls, and idle seconds bill at the same rate. Lower - * {@link PI_SANDBOX_CPU_COUNT} first if that idle time proves dominant: vCPU is - * ~3x the hourly rate of a GB of RAM, and RAM is the dimension that prevents - * hard failures. - */ -export const PI_SANDBOX_CPU_COUNT = 4 - -/** Kept in GB and MB because Daytona takes GB and E2B takes MB. */ -export const PI_SANDBOX_MEMORY_GB = 8 -export const PI_SANDBOX_MEMORY_MB = PI_SANDBOX_MEMORY_GB * 1024