diff --git a/docs/VERCEL_SANDBOX_PROVIDER.md b/docs/VERCEL_SANDBOX_PROVIDER.md index 3f0251b81..b67d211f2 100644 --- a/docs/VERCEL_SANDBOX_PROVIDER.md +++ b/docs/VERCEL_SANDBOX_PROVIDER.md @@ -123,16 +123,19 @@ keep the current snapshot, and delete old snapshots manually if you need to recl ## CPU and Memory -Open-Inspect does not currently send a Vercel `resources` setting when creating sandboxes. Vercel -therefore applies its default sandbox size. +Open-Inspect maps sandbox resource settings to Vercel's `resources.vcpus` setting when creating or +restoring sandboxes. `cpuCores` is treated as the requested vCPU count. `memoryMib` is treated as a +minimum memory request and converted using Vercel's documented `2 GB` per vCPU ratio. If both are +set, Open-Inspect uses enough vCPUs to satisfy both requests. Vercel currently documents the unspecified default as `2 vCPU / 4 GB RAM`. Its pricing limits state that each vCPU includes `2 GB` of memory, with a maximum of `8` vCPUs and `16 GB` memory per sandbox. The `resources.vcpus` option can be used with `1`, `2`, `4`, or `8` vCPUs. -If Open-Inspect needs to control this later, add a provider config value such as -`VERCEL_SANDBOX_VCPUS`, thread it into the Vercel create-sandbox request as `resources.vcpus`, and -let Vercel derive memory from that vCPU count. +When a request falls between supported Vercel sizes, Open-Inspect rounds up to the next supported +vCPU size. Requests above Vercel's maximum supported size fail locally with a clear provider error. +If resource fields are unset or explicitly `null`, no `resources` setting is sent and Vercel applies +its provider default. References: diff --git a/packages/control-plane/src/sandbox/providers/vercel/client.test.ts b/packages/control-plane/src/sandbox/providers/vercel/client.test.ts index 291fa69f3..0521f1ec0 100644 --- a/packages/control-plane/src/sandbox/providers/vercel/client.test.ts +++ b/packages/control-plane/src/sandbox/providers/vercel/client.test.ts @@ -90,6 +90,7 @@ describe("VercelSandboxClient", () => { name: "sandbox-1", runtime: "node24", timeoutMs: 7200000, + resources: { vcpus: 4 }, ports: [8080], env: { FOO: "bar" }, tags: { openinspect_framework: "open-inspect" }, @@ -120,6 +121,7 @@ describe("VercelSandboxClient", () => { name: "sandbox-1", runtime: "node24", timeout: 7200000, + resources: { vcpus: 4 }, ports: [8080], env: { FOO: "bar" }, tags: { openinspect_framework: "open-inspect" }, diff --git a/packages/control-plane/src/sandbox/providers/vercel/client.ts b/packages/control-plane/src/sandbox/providers/vercel/client.ts index 80b2be492..286d433ae 100644 --- a/packages/control-plane/src/sandbox/providers/vercel/client.ts +++ b/packages/control-plane/src/sandbox/providers/vercel/client.ts @@ -35,6 +35,8 @@ export interface VercelSandboxSession { timeout: number; } +export type VercelVcpus = 1 | 2 | 4 | 8; + export interface VercelSandboxMetadata { name: string; currentSessionId: string; @@ -47,6 +49,7 @@ export interface VercelCreateSandboxRequest { name: string; runtime?: string; timeoutMs?: number; + resources?: { vcpus: VercelVcpus }; ports?: number[]; env?: Record; tags?: Record; @@ -142,6 +145,7 @@ export class VercelSandboxClient { name: request.name, runtime: request.runtime, timeout: request.timeoutMs, + resources: request.resources, ports: request.ports ?? [], env: request.env, tags: request.tags, diff --git a/packages/control-plane/src/sandbox/providers/vercel/provider.test.ts b/packages/control-plane/src/sandbox/providers/vercel/provider.test.ts index 76aae1214..dcdd43d57 100644 --- a/packages/control-plane/src/sandbox/providers/vercel/provider.test.ts +++ b/packages/control-plane/src/sandbox/providers/vercel/provider.test.ts @@ -252,6 +252,58 @@ describe("VercelSandboxProvider", () => { ); }); + it("maps sandbox CPU and memory settings to Vercel vCPU resources", async () => { + const client = createMockClient(); + const provider = new VercelSandboxProvider(client, providerConfig); + + await provider.createSandbox({ + ...baseCreateConfig, + sandboxSettings: { cpuCores: 2, memoryMib: 6144 }, + }); + + expect(vi.mocked(client.createSandbox).mock.calls[0][0].resources).toEqual({ vcpus: 4 }); + }); + + it("omits Vercel resources when sandbox CPU and memory settings use provider defaults", async () => { + const client = createMockClient(); + const provider = new VercelSandboxProvider(client, providerConfig); + + await provider.createSandbox({ + ...baseCreateConfig, + sandboxSettings: { cpuCores: null, memoryMib: null }, + }); + + expect(vi.mocked(client.createSandbox).mock.calls[0][0].resources).toBeUndefined(); + }); + + it("maps restore sandbox memory settings to Vercel vCPU resources", async () => { + const client = createMockClient(); + const provider = new VercelSandboxProvider(client, providerConfig); + + await provider.restoreFromSnapshot({ + ...baseRestoreConfig, + sandboxSettings: { memoryMib: 4096 }, + }); + + expect(vi.mocked(client.createSandbox).mock.calls[0][0].resources).toEqual({ vcpus: 2 }); + }); + + it("rejects Vercel resource requests above the maximum supported vCPU size", async () => { + const client = createMockClient(); + const provider = new VercelSandboxProvider(client, providerConfig); + + await expect( + provider.createSandbox({ + ...baseCreateConfig, + sandboxSettings: { memoryMib: 18432 }, + }) + ).rejects.toMatchObject({ + message: expect.stringContaining("support up to 8 vCPUs; requested 9"), + }); + + expect(vi.mocked(client.createSandbox)).not.toHaveBeenCalled(); + }); + it("resolves a configured base snapshot name before creating a fresh sandbox", async () => { const client = createMockClient(); const provider = new VercelSandboxProvider(client, { diff --git a/packages/control-plane/src/sandbox/providers/vercel/provider.ts b/packages/control-plane/src/sandbox/providers/vercel/provider.ts index 88193f019..5bce1f5ba 100644 --- a/packages/control-plane/src/sandbox/providers/vercel/provider.ts +++ b/packages/control-plane/src/sandbox/providers/vercel/provider.ts @@ -26,6 +26,7 @@ import type { VercelCreateSandboxResponse, VercelSandboxClient, VercelSandboxRoute, + VercelVcpus, } from "./client"; import { VercelSandboxApiError } from "./client"; import { DEFAULT_VERCEL_RUNTIME, VERCEL_PYTHON_BIN } from "./bootstrap"; @@ -39,6 +40,9 @@ const EXPECTED_TUNNEL_PORTS_ENV_VAR = "EXPECTED_TUNNEL_PORTS"; const DEFAULT_SNAPSHOT_EXPIRATION_MS = 0; const BUILD_TIMEOUT_SECONDS = 1800; const VERCEL_MAX_SANDBOX_TIMEOUT_MS = 45 * 60 * 1000; +const VERCEL_MEMORY_MIB_PER_VCPU = 2048; +const VERCEL_SUPPORTED_VCPUS: readonly VercelVcpus[] = [1, 2, 4, 8]; +const VERCEL_MAX_VCPUS = VERCEL_SUPPORTED_VCPUS[VERCEL_SUPPORTED_VCPUS.length - 1]; const VERCEL_TUNNEL_ENV_WRITE_TIMEOUT_MS = 30_000; const REPO_IMAGE_CALLBACK_ENV_KEYS = [ "OI_REPO_IMAGE_PROVIDER_SESSION_ID", @@ -126,6 +130,7 @@ export class VercelSandboxProvider implements SandboxProvider { name: config.sandboxId, runtime: this.providerConfig.runtime || DEFAULT_VERCEL_RUNTIME, timeoutMs: resolveVercelTimeoutMs(config.timeoutSeconds), + resources: resolveVercelResources(config.sandboxSettings), ports, env, tags: this.buildTags(config), @@ -172,6 +177,7 @@ export class VercelSandboxProvider implements SandboxProvider { name: config.sandboxId, runtime: this.providerConfig.runtime || DEFAULT_VERCEL_RUNTIME, timeoutMs: resolveVercelTimeoutMs(config.timeoutSeconds), + resources: resolveVercelResources(config.sandboxSettings), ports, env, tags: this.buildTags(config), @@ -618,6 +624,28 @@ function resolveTunnelPorts(rawPorts: number[] | undefined): number[] { return ports; } +function resolveVercelResources( + sandboxSettings: SandboxSettings | undefined +): { vcpus: VercelVcpus } | undefined { + const requestedCpuCores = sandboxSettings?.cpuCores ?? undefined; + const requestedMemoryMib = sandboxSettings?.memoryMib ?? undefined; + const vcpusForMemory = + requestedMemoryMib === undefined + ? undefined + : Math.ceil(requestedMemoryMib / VERCEL_MEMORY_MIB_PER_VCPU); + const requestedVcpus = Math.max(requestedCpuCores ?? 0, vcpusForMemory ?? 0); + + if (requestedVcpus <= 0) return undefined; + const supportedVcpus = VERCEL_SUPPORTED_VCPUS.find((vcpus) => vcpus >= requestedVcpus); + if (supportedVcpus === undefined) { + throw new SandboxProviderError( + `Vercel sandbox resources support up to ${VERCEL_MAX_VCPUS} vCPUs; requested ${requestedVcpus}`, + "permanent" + ); + } + return { vcpus: supportedVcpus }; +} + function routeToUrl(route: VercelSandboxRoute | undefined): string | undefined { if (!route) return undefined; if (route.url) return route.url.startsWith("http") ? route.url : `https://${route.url}`; diff --git a/packages/shared/src/types/integrations.ts b/packages/shared/src/types/integrations.ts index 36621dd2a..23dd8da4d 100644 --- a/packages/shared/src/types/integrations.ts +++ b/packages/shared/src/types/integrations.ts @@ -51,11 +51,11 @@ export const DEFAULT_MAX_TOTAL_CHILD_SESSIONS = 15; /** * Sandbox environment settings. Provider-agnostic: describes what the user * wants, not how it's done. Resource fields (`cpuCores`, `memoryMib`) are - * advisory and provider-dependent — Modal honors them; providers without - * resource reservations (e.g. Daytona) ignore them. We only check they're - * positive; the provider enforces its own real limits. When unset, the - * provider's own default applies. At repo scope, `null` explicitly uses the - * provider default instead of inheriting a global resource default. + * advisory and provider-dependent — Modal maps them directly, Vercel maps + * them to vCPUs, and providers without resource reservations ignore them. We + * only check they're positive; the provider enforces its own real limits. When + * unset, the provider's own default applies. At repo scope, `null` explicitly + * uses the provider default instead of inheriting a global resource default. */ export interface SandboxSettings { /** Extra ports to expose via tunnels (e.g., dev server ports 3000, 5173). */ @@ -67,13 +67,15 @@ export interface SandboxSettings { /** Maximum total agent-spawned child sessions per parent session. */ maxTotalChildSessions?: number; /** - * CPU cores to reserve for the sandbox (maps to Modal's `cpu`). Fractional - * values are allowed. Unset → inherit/default; null → provider default. + * CPU cores to reserve for the sandbox. Fractional values are allowed, but + * providers may round to their supported resource shapes. Unset → + * inherit/default; null → provider default. */ cpuCores?: number | null; /** - * Memory to reserve for the sandbox, in MiB (maps to Modal's `memory`). - * Unset → inherit/default; null → provider default. + * Memory to reserve for the sandbox, in MiB. Providers may map this to their + * closest supported resource shape. Unset → inherit/default; null → provider + * default. */ memoryMib?: number | null; }