@sandboxrouter/sdk 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +293 -2
  3. package/dist/bench-store.d.ts +12 -0
  4. package/dist/bench-store.js +23 -0
  5. package/dist/bench.d.ts +24 -0
  6. package/dist/bench.js +65 -0
  7. package/dist/catalog.d.ts +105 -0
  8. package/dist/catalog.js +320 -0
  9. package/dist/cli.d.ts +2 -0
  10. package/dist/cli.js +195 -0
  11. package/dist/computers.d.ts +124 -0
  12. package/dist/computers.js +66 -0
  13. package/dist/index.d.ts +23 -0
  14. package/dist/index.js +23 -0
  15. package/dist/leaderboard.d.ts +39 -0
  16. package/dist/leaderboard.js +184 -0
  17. package/dist/providers/blaxel.d.ts +21 -0
  18. package/dist/providers/blaxel.js +152 -0
  19. package/dist/providers/boat.d.ts +25 -0
  20. package/dist/providers/boat.js +258 -0
  21. package/dist/providers/cloudflare.d.ts +30 -0
  22. package/dist/providers/cloudflare.js +147 -0
  23. package/dist/providers/daytona.d.ts +13 -0
  24. package/dist/providers/daytona.js +121 -0
  25. package/dist/providers/docker.d.ts +17 -0
  26. package/dist/providers/docker.js +148 -0
  27. package/dist/providers/e2b.d.ts +10 -0
  28. package/dist/providers/e2b.js +69 -0
  29. package/dist/providers/exedev.d.ts +23 -0
  30. package/dist/providers/exedev.js +278 -0
  31. package/dist/providers/hopx.d.ts +15 -0
  32. package/dist/providers/hopx.js +168 -0
  33. package/dist/providers/maritime.d.ts +38 -0
  34. package/dist/providers/maritime.js +240 -0
  35. package/dist/providers/modal.d.ts +10 -0
  36. package/dist/providers/modal.js +82 -0
  37. package/dist/providers/morph.d.ts +19 -0
  38. package/dist/providers/morph.js +181 -0
  39. package/dist/providers/northflank.d.ts +21 -0
  40. package/dist/providers/northflank.js +261 -0
  41. package/dist/providers/runloop.d.ts +16 -0
  42. package/dist/providers/runloop.js +145 -0
  43. package/dist/providers/sprites.d.ts +15 -0
  44. package/dist/providers/sprites.js +135 -0
  45. package/dist/providers/tensorlake.d.ts +14 -0
  46. package/dist/providers/tensorlake.js +192 -0
  47. package/dist/providers/vercel.d.ts +12 -0
  48. package/dist/providers/vercel.js +87 -0
  49. package/dist/router.d.ts +65 -0
  50. package/dist/router.js +199 -0
  51. package/dist/server.d.ts +31 -0
  52. package/dist/server.js +257 -0
  53. package/dist/sse.d.ts +6 -0
  54. package/dist/sse.js +33 -0
  55. package/dist/types.d.ts +87 -0
  56. package/dist/types.js +19 -0
  57. package/dist/util.d.ts +11 -0
  58. package/dist/util.js +37 -0
  59. package/package.json +127 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gigi Alcaraz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,294 @@
1
- # Temporary Holding Version
1
+ # sandboxrouter
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **One API for every sandbox.** A free, open-source router for the places an AI agent can run code: Blaxel, Boat, Cloudflare, Daytona, E2B, exe.dev, Fly.io Sprites, HOPX, Maritime, Modal, Morph, Northflank, Runloop, Tensorlake and Vercel, plus Docker on your own machine. It picks a provider by price, speed or capability, and falls back to the next one when a provider is down or rate-limited.
4
+
5
+ ```ts
6
+ import { Router } from "@sandboxrouter/sdk";
7
+
8
+ const router = new Router(); // uses whichever provider keys you've set
9
+ await using sbx = await router.create({ vcpu: 2, memoryGb: 4 });
10
+
11
+ const r = await sbx.exec("python3 -c 'print(6*7)'");
12
+ console.log(r.stdout, "ran on", sbx.provider);
13
+ ```
14
+
15
+ Change providers without changing code. If one returns a 429, the next one takes the sandbox.
16
+
17
+ Site: [sandboxrouter.vercel.app](https://sandboxrouter.vercel.app) · npm: [`@sandboxrouter/sdk`](https://www.npmjs.com/package/@sandboxrouter/sdk)
18
+
19
+ ## Why
20
+
21
+ Agents need a disposable computer: to run code a model wrote, install packages, run tests, scrape, or do an RL rollout. There are more than a dozen sandbox providers, each with its own SDK, auth, lifecycle and price, and the same 2 vCPU / 4 GB box costs anywhere from a few cents to over thirty cents an hour.
22
+
23
+ So every agent team either locks into one provider or writes its own adapter layer. sandboxrouter is that adapter layer, done once, in the open:
24
+
25
+ - **One interface.** `create`, `exec`, `writeFile`, `readFile`, `pause`, `kill`, the same on every provider. `exec` streams stdout and stderr as they happen.
26
+ - **Routing.** `cheapest` (the default: lowest list price for your size) or `fastest` (lowest cold start you measured with `bench`), or name providers yourself.
27
+ - **Fallback.** A failed create moves to the next candidate, and every attempt is recorded on `sbx.attempts`.
28
+ - **Capability filters.** Ask for a GPU and only GPU-capable providers are tried. Ask for 32 vCPU and providers that can't do it are skipped, with the reason.
29
+ - **Your keys.** Your accounts, your bills. Nothing is proxied through a third party.
30
+ - **HTTP server and Python client.** Run `sandboxrouter serve` and any language gets the same API.
31
+ - **Computers.** Desktops and phones for computer-use models (Maritime today): actions, screenshots, and a takeover link for a person when the model hits a login.
32
+ - **Open benchmark.** `bench --share` produces a report; cold starts on the site come from reports submitted to [`results/`](results/).
33
+
34
+ ## Fair by construction
35
+
36
+ No provider gets a better slot, and none pays for placement.
37
+
38
+ - With no strategy, a sandbox goes to the **lowest list price for the size you asked for**, among providers you have keys for. Ties break **alphabetically**.
39
+ - `fastest` uses only cold starts **you** measured with `bench`, then price.
40
+ - Prices are each provider's public rate, with a source link, in [`src/catalog.ts`](src/catalog.ts). There's a test that the catalog has no privileged ordering.
41
+ - Docker is never picked automatically, because it always "costs" $0 and isolates less than a cloud micro-VM. Name it to use it.
42
+
43
+ If a price is wrong, open a PR with the source link.
44
+
45
+ ## Prices
46
+
47
+ Estimated list price while running, 2 vCPU / 4 GB (`npx @sandboxrouter/sdk providers --vcpu 2 --memory 4`), in the order the router would try them:
48
+
49
+ | Provider | $/hr | Notes | Source |
50
+ |---|---|---|---|
51
+ | Boat | $0.018 | Fixed sizes billed per second ($0.018/h for 2 vCPU / 4 GB up to $0.20/h for 16 / 32); needs a plan from $20/mo, paid back as VM time | [pricing](https://docs.boat.dev/pricing.md) |
52
+ | Maritime | $0.022 | Per second, disk included | [pricing](https://maritime.sh/pricing) |
53
+ | Northflank | $0.067 | Per second on Northflank Cloud, ephemeral disk included; sandboxes run as deployment services (microVMs) | [pricing](https://northflank.com/pricing) |
54
+ | Morph Cloud | $0.100 | $0.05/MCU; MCU-hours = max(vCPU, RAM/4 GB, disk/16 GB); snapshots extra | [pricing](https://cloud.morph.so/web/pricing) |
55
+ | exe.dev | $0.105 | $0.105/h per 2 vCPU + 4 GiB, per second, up to 2 GiB per vCPU; needs an exe.dev subscription | [pricing](https://exe.dev/docs/standalone-vms.md) |
56
+ | Fly.io Sprites | $0.164 | Bills actual CPU and memory use; this is the full-use price; storage extra | [pricing](https://fly.io/sprites) |
57
+ | Blaxel | $0.166 | Billed on memory only ($0.0000115/GB-s), CPU scales with memory (~2 GB per core) | [pricing](https://blaxel.ai/pricing) |
58
+ | Daytona | $0.166 | Per second, plus disk above 5 GiB | [pricing](https://www.daytona.io/pricing) |
59
+ | E2B | $0.166 | Per second; Pro plan $150/mo | [pricing](https://e2b.dev/pricing) |
60
+ | HOPX | $0.166 | Per second, plus storage; size comes from the template, not the request | [pricing](https://hopx.ai/pricing) |
61
+ | Tensorlake | $0.200 | Bills active CPU (floor 20% of allocation); this is the full-use price; Pro plan is lower | [pricing](https://www.tensorlake.ai/pricing) |
62
+ | Cloudflare Sandbox | $0.216 | Bills active CPU and provisioned memory of the instance type; this is the full-CPU price; needs Workers Paid ($5/mo) and the bridge Worker in bridges/cloudflare | [pricing](https://developers.cloudflare.com/containers/platform/pricing/) |
63
+ | Modal | $0.239 | Sandbox rates, GPUs extra; region pinning x1.15-1.75 | [pricing](https://modal.com/pricing) |
64
+ | Runloop | $0.317 | Per second, plus disk; pause (suspend) is a Pro-plan feature | [pricing](https://www.runloop.ai/pricing) |
65
+ | Vercel Sandbox | $0.341 | Bills active CPU only; this is the 100%-CPU price, idle work costs less | [pricing](https://vercel.com/docs/vercel-sandbox/pricing) |
66
+ | Docker (local) | free | Runs on your own machine; free, but shares your kernel, so weaker isolation than a cloud micro-VM | [pricing](https://docs.docker.com/engine/) |
67
+
68
+ Checked 2026-11-04.
69
+
70
+ ## Install
71
+
72
+ ```bash
73
+ npm i @sandboxrouter/sdk
74
+ # then the SDK of each provider you use (Boat, Cloudflare, exe.dev, Maritime and Docker need none):
75
+ npm i e2b # E2B
76
+ npm i @daytonaio/sdk # Daytona
77
+ npm i modal # Modal
78
+ npm i @vercel/sandbox # Vercel Sandbox
79
+ npm i morphcloud # Morph Cloud
80
+ npm i @fly/sprites # Fly.io Sprites (its package asks for Node 24+)
81
+ npm i @blaxel/core # Blaxel
82
+ npm i @tensorlakeai/tensorlake # Tensorlake
83
+ npm i @runloop/api-client # Runloop
84
+ npm i @hopx-ai/sdk # HOPX
85
+ npm i @northflank/js-client # Northflank
86
+ ```
87
+
88
+ Cloudflare sandboxes only run inside a Cloudflare Worker, so you deploy a small bridge Worker to your own account first: see [`bridges/cloudflare`](bridges/cloudflare).
89
+
90
+ Set the keys for the providers you want. Providers without keys are skipped.
91
+
92
+ | Provider | Env |
93
+ |---|---|
94
+ | Blaxel | `BL_API_KEY` + `BL_WORKSPACE` |
95
+ | Boat | `BOAT_API_KEY` |
96
+ | Cloudflare Sandbox | `CLOUDFLARE_SANDBOX_URL` + `CLOUDFLARE_SANDBOX_TOKEN` |
97
+ | Daytona | `DAYTONA_API_KEY` |
98
+ | Docker (local) | none: Docker running locally |
99
+ | E2B | `E2B_API_KEY` |
100
+ | exe.dev | `EXE_TOKEN` |
101
+ | Fly.io Sprites | `SPRITES_TOKEN` |
102
+ | HOPX | `HOPX_API_KEY` |
103
+ | Maritime | `MARITIME_API_KEY` |
104
+ | Modal | `MODAL_TOKEN_ID` + `MODAL_TOKEN_SECRET` |
105
+ | Morph Cloud | `MORPH_API_KEY` |
106
+ | Northflank | `NORTHFLANK_API_TOKEN` + `NORTHFLANK_PROJECT_ID` |
107
+ | Runloop | `RUNLOOP_API_KEY` |
108
+ | Tensorlake | `TENSORLAKE_API_KEY` |
109
+ | Vercel Sandbox | `VERCEL_OIDC_TOKEN`, or `VERCEL_TOKEN` + `VERCEL_TEAM_ID` + `VERCEL_PROJECT_ID` |
110
+
111
+ ## Library
112
+
113
+ ```ts
114
+ const router = new Router();
115
+
116
+ // Cheapest provider that can do 8 vCPU / 16 GB, falling back on failure
117
+ const a = await router.create({ vcpu: 8, memoryGb: 16 }, { strategy: "cheapest" });
118
+
119
+ // GPU: only GPU-capable providers are tried (Modal today)
120
+ const b = await router.create({ gpu: "A10G", image: { modal: "pytorch/pytorch:latest" } });
121
+
122
+ // Pin providers in order and turn fallback off
123
+ const c = await router.create({}, { provider: ["maritime", "e2b"], fallback: false });
124
+
125
+ // Per-provider images: each provider names its templates differently
126
+ const d = await router.create({ image: { maritime: "python", e2b: "code-interpreter-v1", modal: "python:3.13-slim" } });
127
+
128
+ // Files
129
+ await a.writeFile("/tmp/data.csv", "x,y\n1,2\n");
130
+ console.log(await a.readText("/tmp/data.csv"));
131
+
132
+ // Stream output as it happens (the full output is still returned)
133
+ await a.exec("npm test", { onStdout: (c) => process.stdout.write(c), onStderr: (c) => process.stderr.write(c) });
134
+
135
+ // Pause keeps the disk (and memory, where the provider snapshots it); router.get() resumes
136
+ const p = await router.create({}, { require: ["pause"] });
137
+ await p.pause();
138
+
139
+ // Ids are portable across processes: "provider:nativeId"
140
+ const again = await router.get(a.id);
141
+
142
+ // Dry run: who would be tried, in what order, and why anything is skipped
143
+ console.table(router.plan({ vcpu: 32 }));
144
+
145
+ await a.kill();
146
+ ```
147
+
148
+ `create` throws a `RouterError` whose `attempts` lists every provider it tried and why each failed.
149
+
150
+ ## Computers (desktops and phones)
151
+
152
+ For computer-use agents: a machine with a screen that your model drives with clicks and keys. Maritime provides Linux desktops, Windows desktops and Android phones on the same key. Actions use the Anthropic computer-use shape.
153
+
154
+ ```ts
155
+ const desk = await router.computer({ os: "linux", externalUserId: "user_42" }); // same id, same desktop
156
+ const shot = await desk.act({ action: "screenshot" }); // { frameId, width, height, imageB64 }
157
+ await desk.act({ action: "left_click", coordinate: [412, 230] });
158
+ await desk.actBatch([{ action: "type", text: "hello" }, { action: "key", text: "Return" }]);
159
+ await desk.exec("ls /data");
160
+
161
+ // Hit a login or CAPTCHA? Hand the desktop to a person, then carry on.
162
+ const { url } = await desk.viewer("control", { reason: "Please sign in" });
163
+ ```
164
+
165
+ ```bash
166
+ npx @sandboxrouter/sdk desktop --os linux --user user_42 --control # prints a takeover link
167
+ npx @sandboxrouter/sdk screenshot maritime:<id> --out screen.png
168
+ ```
169
+
170
+ ## CLI
171
+
172
+ ```bash
173
+ npx @sandboxrouter/sdk providers # price table + which keys are set
174
+ npx @sandboxrouter/sdk route --gpu T4 # dry run
175
+ npx @sandboxrouter/sdk run "uname -a && nproc" # start, run, print, kill
176
+ npx @sandboxrouter/sdk run "pytest -q" --provider e2b,maritime --keep
177
+ npx @sandboxrouter/sdk bench --runs 5 # cold start + exec latency table
178
+ npx @sandboxrouter/sdk bench --share --label "us-east-1" # also write a report for results/
179
+ npx @sandboxrouter/sdk leaderboard results --out leaderboard.html
180
+ npx @sandboxrouter/sdk serve --port 8787 # HTTP API
181
+ ```
182
+
183
+ ### Bench
184
+
185
+ `bench` starts real sandboxes on every configured provider, measures time to a usable sandbox and the round trip of `echo ok`, kills them, and prints a markdown leaderboard. Results are saved to `~/.sandboxrouter/bench.json`, which is what `strategy: "fastest"` routes on. Latency is measured from wherever you run it, so network distance counts.
186
+
187
+ `--share` writes a JSON report (timings, your label, Node version and platform; never keys, env or error text). Open a PR adding it to [`results/`](results/). `sandboxrouter leaderboard` builds one static HTML page from every report there, taking the median of each submission's medians so one submitter can't dominate. Host the page anywhere static files go.
188
+
189
+ ## HTTP API
190
+
191
+ ```bash
192
+ SANDBOXROUTER_TOKEN=secret npx @sandboxrouter/sdk serve
193
+ ```
194
+
195
+ | Method | Path | Body |
196
+ |---|---|---|
197
+ | `GET` | `/v1/providers` | |
198
+ | `POST` | `/v1/route` | same as create; returns the candidate order |
199
+ | `POST` | `/v1/sandboxes` | `{ vcpu, memoryGb, gpu, env, timeoutMinutes, image, metadata, provider, strategy, fallback, require }` |
200
+ | `POST` | `/v1/sandboxes/:id/exec` | `{ cmd, cwd, env, timeoutSeconds, stream }`. With `stream: true` the answer is `text/event-stream`: `stdout` and `stderr` events, then `result` |
201
+ | `POST` | `/v1/sandboxes/:id/pause` | |
202
+ | `PUT` | `/v1/sandboxes/:id/files?path=` | raw bytes |
203
+ | `GET` | `/v1/sandboxes/:id/files?path=` | |
204
+ | `DELETE` | `/v1/sandboxes/:id` | |
205
+ | `POST` | `/v1/computers` | `{ os, externalUserId, name, screen, arch, provider }` |
206
+ | `POST` | `/v1/computers/:id/actions` | one action, or `{ actions: [...] }` (up to 50) |
207
+ | `GET` | `/v1/computers/:id/screenshot` | image bytes (`?format=jpeg&quality=80`) |
208
+ | `POST` | `/v1/computers/:id/exec` | `{ command, timeoutSeconds }` (max 30 s) |
209
+ | `PUT` / `GET` | `/v1/computers/:id/files?path=` | raw bytes |
210
+ | `POST` | `/v1/computers/:id/viewer` | `{ mode: "watch" \| "control", ttlSeconds, reason }` |
211
+ | `POST` | `/v1/computers/:id/sleep` | |
212
+ | `DELETE` | `/v1/computers/:id` | |
213
+
214
+ ```bash
215
+ curl -s localhost:8787/v1/sandboxes -H "Authorization: Bearer secret" \
216
+ -d '{"vcpu":2,"strategy":"cheapest"}'
217
+ # {"id":"maritime:sbx-k3m7q2ab","provider":"maritime","estimatedHourlyUsd":0.022,"attempts":[...]}
218
+ ```
219
+
220
+ Provider keys stay on the server. Clients only hold `SANDBOXROUTER_TOKEN`. It binds to `127.0.0.1` by default.
221
+
222
+ ### Python
223
+
224
+ A standard-library client for the HTTP API lives in [`python/`](python/):
225
+
226
+ ```python
227
+ from sandboxrouter import Client
228
+
229
+ sr = Client("http://127.0.0.1:8787", token="secret")
230
+ with sr.create(vcpu=2, memory_gb=4, strategy="cheapest") as sbx:
231
+ sbx.exec("pip install rich && python -c 'import rich'", on_stdout=lambda c: print(c, end=""))
232
+ ```
233
+
234
+ ## How each adapter maps the spec
235
+
236
+ | | vCPU / memory | Lifetime | Image | Pause | Notes |
237
+ |---|---|---|---|---|---|
238
+ | Maritime | `vcpu`, `memoryMib` | `timeLimitMinutes` (5 to 1,440) | `from` | yes | Plain REST, no SDK. Streams over SSE. Any data-plane call resumes a paused sandbox. |
239
+ | E2B | set by the template | `timeoutMs` | template | yes (memory + disk) | Nonzero exits are returned as results, not thrown. |
240
+ | Daytona | only with `image.daytona` | `autoStopInterval` | `image` | yes (stop/start, disk only) | Non-streaming exec merges stdout and stderr; streaming uses a session and keeps them apart. |
241
+ | Modal | `cpu`, `memoryMiB`, `gpu` | `timeoutMs` | registry tag | no | Uses Modal app `sandboxrouter` (override with `SANDBOXROUTER_MODAL_APP`). |
242
+ | Vercel | `vcpus` (2 GB each) | `timeout` | `runtime` | no | Max 8 vCPU. |
243
+ | Morph | via a cached base snapshot per shape | `ttlSeconds` | snapshot id | yes (memory + disk) | First create of a new shape builds a snapshot. Files over SSH. |
244
+ | Fly.io Sprites | `cpus`, `ramMB` (may be ignored) | none: `kill()` deletes | no | no | Sprites idle for free on their own. |
245
+ | Blaxel | memory (CPU follows) | `ttl` | image | yes (archive, disk only) | Idle sandboxes go to free standby on their own. |
246
+ | Tensorlake | `cpus`, `memoryMb` | idle timeout | image | yes (memory + disk) | Output is line-based. |
247
+ | Runloop | snapped to Runloop's custom sizes | `keep_alive_time_seconds` (max 48 h) | blueprint | yes (Pro plan) | Commands start in the home directory. |
248
+ | HOPX | set by the template | timeout | template | yes (memory + disk) | Size requests aren't applied, so the router only sends default-size work. |
249
+ | Northflank | cheapest `nf-compute` plan that fits | `expiryTime` (5 min to 7 days) | any public image | no | Runs as a deployment service; files move as base64 over exec. |
250
+ | Cloudflare | smallest instance type that fits | alarm in the bridge Worker | Dockerfile in the bridge | no | Through the bridge Worker you deploy ([`bridges/cloudflare`](bridges/cloudflare)). |
251
+ | Boat | smallest of 4 fixed sizes that fits | auto-stop timer | saved snapshot | yes (stop: disk kept, processes end) | Plain REST. Commands that wait are capped at 600 s; files API up to 5 MiB. |
252
+ | exe.dev | `--cpu`, `--memory` (2 GiB per vCPU max) | none: `kill()` deletes | `--image` | no | Plain REST over exe.dev's `/exec`; long commands run detached and are polled. |
253
+ | Docker (local) | `--cpus`, `--memory` | container exits at the lifetime | any image | yes (`docker pause`) | Your machine, shared kernel. Never auto-routed; use `{ provider: "docker" }`. |
254
+
255
+ Capabilities in the catalog describe what each **adapter** implements, not everything a provider sells. Size limits are self-serve defaults and may be higher on your plan.
256
+
257
+ ## Roadmap
258
+
259
+ - [x] More providers: Morph, Fly.io Sprites, Blaxel, Tensorlake, Runloop, HOPX, Northflank, Boat, exe.dev, Cloudflare (via a bridge Worker), Docker (local)
260
+ - [x] Neutral routing: price decides, ties alphabetical, no privileged slot
261
+ - [x] Desktops and phones (Maritime: Linux, Windows, Android)
262
+ - [x] Streaming exec output
263
+ - [x] Pause/resume where providers support it
264
+ - [x] Python client
265
+ - [x] Leaderboard built from `bench --share` reports
266
+ - [ ] Computers on more providers (E2B Desktop, Daytona computer use)
267
+ - [ ] Snapshots and forks as a routed operation
268
+ - [ ] A hosted copy of the leaderboard page
269
+
270
+ ## Website
271
+
272
+ [`web/`](web/) is the project site: home, a browsable Sandboxes list with live price-per-size, a page per provider, Rankings, Docs, and a Playground that drives your own `sandboxrouter serve` from the browser. Its data is generated from the library (`npm run site:data`), so prices never drift from the code.
273
+
274
+ ```bash
275
+ cd web && npm i && npm run dev # http://localhost:3000
276
+ # Playground without any provider keys (in-memory providers, nothing real starts):
277
+ node --import tsx test/demo-server.ts # token: demo
278
+ # Playground against real providers:
279
+ SANDBOXROUTER_TOKEN=secret npx @sandboxrouter/sdk serve --cors http://localhost:3000
280
+ ```
281
+
282
+ ## Development
283
+
284
+ ```bash
285
+ npm i
286
+ npm test # router, HTTP server, leaderboard, Maritime sandboxes + computers against contract mocks
287
+ npm run test:py # Python client against the real HTTP server
288
+ npm run build
289
+ npm run dev -- providers
290
+ ```
291
+
292
+ Adding a provider means implementing `Provider` from [`src/types.ts`](src/types.ts) and adding a catalog entry with a pricing source link.
293
+
294
+ MIT
@@ -0,0 +1,12 @@
1
+ import type { ProviderId } from "./types.js";
2
+ export interface BenchSample {
3
+ createMs: number[];
4
+ execMs: number[];
5
+ failures: number;
6
+ at: string;
7
+ }
8
+ export type BenchData = Partial<Record<ProviderId, BenchSample>>;
9
+ export declare const DEFAULT_BENCH_FILE: string;
10
+ export declare function loadBench(file?: string): BenchData;
11
+ export declare function saveBench(data: BenchData, file?: string): void;
12
+ export declare function median(xs?: number[]): number | undefined;
@@ -0,0 +1,23 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ export const DEFAULT_BENCH_FILE = process.env.SANDBOXROUTER_BENCH_FILE ?? join(homedir(), ".sandboxrouter", "bench.json");
5
+ export function loadBench(file = DEFAULT_BENCH_FILE) {
6
+ try {
7
+ return JSON.parse(readFileSync(file, "utf8"));
8
+ }
9
+ catch {
10
+ return {};
11
+ }
12
+ }
13
+ export function saveBench(data, file = DEFAULT_BENCH_FILE) {
14
+ mkdirSync(dirname(file), { recursive: true });
15
+ writeFileSync(file, JSON.stringify(data, null, 2));
16
+ }
17
+ export function median(xs) {
18
+ if (!xs || xs.length === 0)
19
+ return undefined;
20
+ const s = [...xs].sort((a, b) => a - b);
21
+ const m = Math.floor(s.length / 2);
22
+ return s.length % 2 ? s[m] : Math.round((s[m - 1] + s[m]) / 2);
23
+ }
@@ -0,0 +1,24 @@
1
+ import { Router } from "./router.js";
2
+ import type { ProviderId, SandboxSpec } from "./types.js";
3
+ export interface BenchRow {
4
+ provider: ProviderId;
5
+ runs: number;
6
+ failures: number;
7
+ medianCreateMs?: number;
8
+ medianExecMs?: number;
9
+ hourlyUsd: number;
10
+ lastError?: string;
11
+ }
12
+ /**
13
+ * Cold start = time from create() call to a usable sandbox. Exec = round trip of `echo ok`.
14
+ * Measured from wherever you run it, so network distance to each provider counts.
15
+ */
16
+ export declare function bench(opts: {
17
+ router?: Router;
18
+ providers?: ProviderId[];
19
+ runs?: number;
20
+ spec?: SandboxSpec;
21
+ benchFile?: string;
22
+ onProgress?: (msg: string) => void;
23
+ }): Promise<BenchRow[]>;
24
+ export declare function benchMarkdown(rows: BenchRow[]): string;
package/dist/bench.js ADDED
@@ -0,0 +1,65 @@
1
+ import { CATALOG, hourlyCost } from "./catalog.js";
2
+ import { loadBench, median, saveBench } from "./bench-store.js";
3
+ import { Router } from "./router.js";
4
+ /**
5
+ * Cold start = time from create() call to a usable sandbox. Exec = round trip of `echo ok`.
6
+ * Measured from wherever you run it, so network distance to each provider counts.
7
+ */
8
+ export async function bench(opts) {
9
+ const router = opts.router ?? new Router();
10
+ const runs = opts.runs ?? 3;
11
+ const spec = opts.spec ?? { timeoutMinutes: 5 };
12
+ const ids = opts.providers ?? router.registered().filter((id) => router.isConfigured(id));
13
+ const log = opts.onProgress ?? (() => { });
14
+ const stored = loadBench(opts.benchFile);
15
+ const rows = [];
16
+ for (const id of ids) {
17
+ const createMs = [];
18
+ const execMs = [];
19
+ let failures = 0;
20
+ let lastError;
21
+ for (let i = 0; i < runs; i++) {
22
+ log(`${id} run ${i + 1}/${runs}`);
23
+ let box;
24
+ try {
25
+ const t0 = performance.now();
26
+ box = await router.create(spec, { provider: id, fallback: false });
27
+ createMs.push(Math.round(performance.now() - t0));
28
+ const t1 = performance.now();
29
+ const r = await box.exec("echo ok");
30
+ if (r.exitCode !== 0 || r.stdout.trim() !== "ok")
31
+ throw new Error(`echo returned ${r.exitCode}: ${r.stdout}${r.stderr}`);
32
+ execMs.push(Math.round(performance.now() - t1));
33
+ }
34
+ catch (err) {
35
+ failures++;
36
+ lastError = err.message.slice(0, 200);
37
+ }
38
+ finally {
39
+ await box?.kill().catch(() => { });
40
+ }
41
+ }
42
+ stored[id] = { createMs, execMs, failures, at: new Date().toISOString() };
43
+ rows.push({
44
+ provider: id,
45
+ runs,
46
+ failures,
47
+ medianCreateMs: median(createMs),
48
+ medianExecMs: median(execMs),
49
+ hourlyUsd: hourlyCost(id, spec),
50
+ lastError,
51
+ });
52
+ }
53
+ saveBench(stored, opts.benchFile);
54
+ return rows.sort((a, b) => (a.medianCreateMs ?? Infinity) - (b.medianCreateMs ?? Infinity));
55
+ }
56
+ export function benchMarkdown(rows) {
57
+ const name = (id) => CATALOG.find((c) => c.id === id).name;
58
+ const ms = (n) => (n === undefined ? "n/a" : `${n} ms`);
59
+ const lines = [
60
+ "| # | Provider | Cold start (median) | Exec round trip | $/hr (2 vCPU, 4 GB) | Success |",
61
+ "|---|---|---|---|---|---|",
62
+ ...rows.map((r, i) => `| ${i + 1} | ${name(r.provider)} | ${ms(r.medianCreateMs)} | ${ms(r.medianExecMs)} | $${r.hourlyUsd.toFixed(3)} | ${r.runs - r.failures}/${r.runs} |`),
63
+ ];
64
+ return lines.join("\n");
65
+ }
@@ -0,0 +1,105 @@
1
+ import type { Capability, ProviderId, SandboxSpec } from "./types.js";
2
+ export interface ProviderInfo {
3
+ id: ProviderId;
4
+ name: string;
5
+ /** List price while running, USD. */
6
+ pricing: {
7
+ vcpuHour: number;
8
+ gbHour: number;
9
+ note?: string;
10
+ source: string;
11
+ checked: string;
12
+ };
13
+ /** Capabilities this adapter actually implements, not everything the provider markets. */
14
+ capabilities: Capability[];
15
+ limits: {
16
+ maxVcpu: number;
17
+ maxMemoryGb: number;
18
+ };
19
+ /** Runs on your own machine (Docker). Never chosen automatically; name it to use it. */
20
+ local?: boolean;
21
+ /** Env vars the adapter reads. Any one group being fully set means "configured". */
22
+ credentials: string[][];
23
+ docs: string;
24
+ }
25
+ export declare const CATALOG: ProviderInfo[];
26
+ /** Cloudflare only rents fixed instance types: https://developers.cloudflare.com/containers/platform/limits/ */
27
+ export declare const CLOUDFLARE_INSTANCES: readonly [{
28
+ readonly name: "lite";
29
+ readonly vcpu: number;
30
+ readonly memoryGb: 0.25;
31
+ }, {
32
+ readonly name: "basic";
33
+ readonly vcpu: number;
34
+ readonly memoryGb: 1;
35
+ }, {
36
+ readonly name: "standard-1";
37
+ readonly vcpu: number;
38
+ readonly memoryGb: 4;
39
+ }, {
40
+ readonly name: "standard-2";
41
+ readonly vcpu: 1;
42
+ readonly memoryGb: 6;
43
+ }, {
44
+ readonly name: "standard-3";
45
+ readonly vcpu: 2;
46
+ readonly memoryGb: 8;
47
+ }, {
48
+ readonly name: "standard-4";
49
+ readonly vcpu: 4;
50
+ readonly memoryGb: 12;
51
+ }];
52
+ /** Boat's fixed machine sizes and hourly prices: https://docs.boat.dev/machines.md */
53
+ export declare const BOAT_SIZES: readonly [{
54
+ readonly type: "small";
55
+ readonly vcpu: 2;
56
+ readonly memoryGb: 4;
57
+ readonly usdHour: 0.018;
58
+ }, {
59
+ readonly type: "default";
60
+ readonly vcpu: 4;
61
+ readonly memoryGb: 8;
62
+ readonly usdHour: 0.036;
63
+ }, {
64
+ readonly type: "large";
65
+ readonly vcpu: 8;
66
+ readonly memoryGb: 16;
67
+ readonly usdHour: 0.072;
68
+ }, {
69
+ readonly type: "xlarge";
70
+ readonly vcpu: 16;
71
+ readonly memoryGb: 32;
72
+ readonly usdHour: 0.2;
73
+ }];
74
+ export declare function cloudflareInstance(vcpu: number, memoryGb: number): {
75
+ readonly name: "lite";
76
+ readonly vcpu: number;
77
+ readonly memoryGb: 0.25;
78
+ } | {
79
+ readonly name: "basic";
80
+ readonly vcpu: number;
81
+ readonly memoryGb: 1;
82
+ } | {
83
+ readonly name: "standard-1";
84
+ readonly vcpu: number;
85
+ readonly memoryGb: 4;
86
+ } | {
87
+ readonly name: "standard-2";
88
+ readonly vcpu: 1;
89
+ readonly memoryGb: 6;
90
+ } | {
91
+ readonly name: "standard-3";
92
+ readonly vcpu: 2;
93
+ readonly memoryGb: 8;
94
+ } | {
95
+ readonly name: "standard-4";
96
+ readonly vcpu: 4;
97
+ readonly memoryGb: 12;
98
+ };
99
+ export declare function info(id: ProviderId): ProviderInfo;
100
+ export declare const DEFAULT_VCPU = 2;
101
+ export declare const DEFAULT_MEMORY_GB = 4;
102
+ /** Estimated USD per hour for this spec on this provider (list price, while running). */
103
+ export declare function hourlyCost(id: ProviderId, spec?: SandboxSpec): number;
104
+ /** Why a provider can't take this spec, or null if it can. */
105
+ export declare function unfit(id: ProviderId, spec: SandboxSpec, require?: Capability[]): string | null;