@sandboxrouter/sdk 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +260 -2
- package/dist/bench-store.d.ts +12 -0
- package/dist/bench-store.js +23 -0
- package/dist/bench.d.ts +24 -0
- package/dist/bench.js +65 -0
- package/dist/catalog.d.ts +35 -0
- package/dist/catalog.js +198 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +195 -0
- package/dist/computers.d.ts +124 -0
- package/dist/computers.js +66 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +17 -0
- package/dist/leaderboard.d.ts +39 -0
- package/dist/leaderboard.js +184 -0
- package/dist/providers/blaxel.d.ts +21 -0
- package/dist/providers/blaxel.js +152 -0
- package/dist/providers/daytona.d.ts +13 -0
- package/dist/providers/daytona.js +121 -0
- package/dist/providers/e2b.d.ts +10 -0
- package/dist/providers/e2b.js +69 -0
- package/dist/providers/maritime.d.ts +38 -0
- package/dist/providers/maritime.js +240 -0
- package/dist/providers/modal.d.ts +10 -0
- package/dist/providers/modal.js +82 -0
- package/dist/providers/morph.d.ts +19 -0
- package/dist/providers/morph.js +181 -0
- package/dist/providers/runloop.d.ts +16 -0
- package/dist/providers/runloop.js +145 -0
- package/dist/providers/sprites.d.ts +15 -0
- package/dist/providers/sprites.js +135 -0
- package/dist/providers/tensorlake.d.ts +14 -0
- package/dist/providers/tensorlake.js +192 -0
- package/dist/providers/vercel.d.ts +12 -0
- package/dist/providers/vercel.js +87 -0
- package/dist/router.d.ts +65 -0
- package/dist/router.js +180 -0
- package/dist/server.d.ts +31 -0
- package/dist/server.js +257 -0
- package/dist/sse.d.ts +6 -0
- package/dist/sse.js +33 -0
- package/dist/types.d.ts +83 -0
- package/dist/types.js +19 -0
- package/dist/util.d.ts +11 -0
- package/dist/util.js +37 -0
- package/package.json +113 -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,261 @@
|
|
|
1
|
-
#
|
|
1
|
+
# sandboxrouter
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**OpenRouter for sandboxes.** One API for every place an AI agent can run code: Maritime, E2B, Daytona, Modal, Vercel Sandbox, Morph, Fly.io Sprites, Blaxel, Tensorlake and Runloop. Routes on price, latency or capability, and falls back to the next provider when one is down or rate-limited. Also drives desktops and phones for computer-use agents.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { Router } from "@sandboxrouter/sdk";
|
|
7
|
+
|
|
8
|
+
const router = new Router(); // reads provider keys from env
|
|
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); // 42 ran on maritime
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Change providers without changing code. If one returns a 429, the next one takes the sandbox.
|
|
16
|
+
|
|
17
|
+
## Why
|
|
18
|
+
|
|
19
|
+
Agents need a disposable computer: to run code a model wrote, install packages, run tests, scrape, or do an RL rollout. In 2026 there are more than a dozen sandbox providers, each with its own SDK, auth, lifecycle and price. A 2 vCPU / 4 GB box costs anywhere from **$0.022/hr to $0.34/hr** depending on where you start it.
|
|
20
|
+
|
|
21
|
+
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:
|
|
22
|
+
|
|
23
|
+
- **One interface.** `create`, `exec`, `writeFile`, `readFile`, `pause`, `kill`, the same on every provider. `exec` streams stdout and stderr as they happen.
|
|
24
|
+
- **Routing.** `default` (catalog order), `cheapest` (by list price for your shape) or `fastest` (by your own measured cold starts).
|
|
25
|
+
- **Fallback.** A failed create moves to the next candidate, and every attempt is recorded on `sbx.attempts`.
|
|
26
|
+
- **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.
|
|
27
|
+
- **BYOK.** Your keys and your accounts. Nothing is proxied through a third party.
|
|
28
|
+
- **HTTP server and Python client.** Run `sandboxrouter serve` and any language gets the same API.
|
|
29
|
+
- **Computers.** Linux and Windows desktops and Android phones for computer-use models: actions, screenshots, and a takeover link for a person when the model hits a login.
|
|
30
|
+
- **Open leaderboard.** `bench --share` produces a report; the leaderboard is built from submitted reports in [`results/`](results/).
|
|
31
|
+
|
|
32
|
+
## Prices
|
|
33
|
+
|
|
34
|
+
Estimated list price while running, 2 vCPU / 4 GB (`npx @sandboxrouter/sdk providers --vcpu 2 --memory 4`):
|
|
35
|
+
|
|
36
|
+
| Provider | $/hr | Notes | Source |
|
|
37
|
+
|---|---|---|---|
|
|
38
|
+
| **Maritime** | **$0.022** | Per second, disk included. Firecracker micro-VMs. Also runs stock E2B SDK code. | [pricing](https://maritime.sh/pricing) |
|
|
39
|
+
| Morph Cloud | $0.100 | $0.05 per MCU; an MCU-hour is the larger of 1 vCPU, 4 GB RAM or 16 GB disk. | [pricing](https://cloud.morph.so/web/pricing) |
|
|
40
|
+
| Fly.io Sprites | $0.165 | Bills actual CPU and memory use; this is the full-use price. Storage extra. | [pricing](https://fly.io/sprites) |
|
|
41
|
+
| E2B | $0.166 | Per second. Pro plan $150/mo. | [pricing](https://e2b.dev/pricing) |
|
|
42
|
+
| Daytona | $0.166 | Per second, plus disk above 5 GiB. | [pricing](https://www.daytona.io/pricing) |
|
|
43
|
+
| Blaxel | $0.166 | Billed on memory only; CPU scales with memory. | [pricing](https://blaxel.ai/pricing) |
|
|
44
|
+
| Tensorlake | $0.200 | Bills active CPU (20% floor); this is the full-use price. Pro plan is cheaper. | [pricing](https://www.tensorlake.ai/pricing) |
|
|
45
|
+
| Modal | $0.239 | Sandbox rates. GPUs available. | [pricing](https://modal.com/pricing) |
|
|
46
|
+
| Runloop | $0.317 | Per second, plus disk. Suspend/resume is a Pro feature. | [pricing](https://www.runloop.ai/pricing) |
|
|
47
|
+
| Vercel Sandbox | $0.341 | Bills active CPU only. This is the 100%-CPU price; mostly idle work costs less. | [pricing](https://vercel.com/docs/vercel-sandbox/pricing) |
|
|
48
|
+
|
|
49
|
+
Checked 2026-11-04. Prices live in [`src/catalog.ts`](src/catalog.ts). If one is wrong, open a PR with the source link.
|
|
50
|
+
|
|
51
|
+
**Why Maritime is the default:** it's first in catalog order because it's the cheapest per hour at every shape in this table (there's a test that checks this against the catalog), so `default` and `cheapest` agree. Don't take that on trust: run `sandboxrouter bench` on your own accounts and route with `strategy: "fastest"` if latency matters more to you than price.
|
|
52
|
+
|
|
53
|
+
## Install
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
npm i @sandboxrouter/sdk
|
|
57
|
+
# then install the SDK of each provider you use (Maritime needs none, it's plain REST):
|
|
58
|
+
npm i e2b # E2B
|
|
59
|
+
npm i @daytonaio/sdk # Daytona
|
|
60
|
+
npm i modal # Modal
|
|
61
|
+
npm i @vercel/sandbox # Vercel Sandbox
|
|
62
|
+
npm i morphcloud # Morph Cloud
|
|
63
|
+
npm i @fly/sprites # Fly.io Sprites (needs Node 24+ per its package)
|
|
64
|
+
npm i @blaxel/core # Blaxel
|
|
65
|
+
npm i @tensorlakeai/tensorlake # Tensorlake
|
|
66
|
+
npm i @runloop/api-client # Runloop
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Set the keys for the providers you want. Unconfigured providers are skipped.
|
|
70
|
+
|
|
71
|
+
| Provider | Env |
|
|
72
|
+
|---|---|
|
|
73
|
+
| Maritime | `MARITIME_API_KEY` (an `mk_` key with the `sandboxes` scope: `maritime keys create`) |
|
|
74
|
+
| E2B | `E2B_API_KEY` |
|
|
75
|
+
| Daytona | `DAYTONA_API_KEY` |
|
|
76
|
+
| Modal | `MODAL_TOKEN_ID` + `MODAL_TOKEN_SECRET`, or `~/.modal.toml` |
|
|
77
|
+
| Vercel | `VERCEL_OIDC_TOKEN`, or `VERCEL_TOKEN` + `VERCEL_TEAM_ID` + `VERCEL_PROJECT_ID` |
|
|
78
|
+
| Morph | `MORPH_API_KEY` |
|
|
79
|
+
| Fly.io Sprites | `SPRITES_TOKEN` |
|
|
80
|
+
| Blaxel | `BL_API_KEY` + `BL_WORKSPACE` (optional `BL_REGION`, default `us-pdx-1`) |
|
|
81
|
+
| Tensorlake | `TENSORLAKE_API_KEY` |
|
|
82
|
+
| Runloop | `RUNLOOP_API_KEY` |
|
|
83
|
+
|
|
84
|
+
## Library
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
const router = new Router();
|
|
88
|
+
|
|
89
|
+
// Cheapest provider that can do 8 vCPU / 16 GB, falling back on failure
|
|
90
|
+
const a = await router.create({ vcpu: 8, memoryGb: 16 }, { strategy: "cheapest" });
|
|
91
|
+
|
|
92
|
+
// GPU: only GPU-capable providers are tried (Modal today)
|
|
93
|
+
const b = await router.create({ gpu: "A10G", image: { modal: "pytorch/pytorch:latest" } });
|
|
94
|
+
|
|
95
|
+
// Pin providers in order and turn fallback off
|
|
96
|
+
const c = await router.create({}, { provider: ["maritime", "e2b"], fallback: false });
|
|
97
|
+
|
|
98
|
+
// Per-provider images: each provider names its templates differently
|
|
99
|
+
const d = await router.create({ image: { maritime: "python", e2b: "code-interpreter-v1", modal: "python:3.13-slim" } });
|
|
100
|
+
|
|
101
|
+
// Files
|
|
102
|
+
await a.writeFile("/tmp/data.csv", "x,y\n1,2\n");
|
|
103
|
+
console.log(await a.readText("/tmp/data.csv"));
|
|
104
|
+
|
|
105
|
+
// Stream output as it happens (the full output is still returned)
|
|
106
|
+
await a.exec("npm test", { onStdout: (c) => process.stdout.write(c), onStderr: (c) => process.stderr.write(c) });
|
|
107
|
+
|
|
108
|
+
// Pause keeps the disk (and memory, where the provider snapshots it); router.get() resumes
|
|
109
|
+
const p = await router.create({}, { require: ["pause"] });
|
|
110
|
+
await p.pause();
|
|
111
|
+
|
|
112
|
+
// Ids are portable across processes: "provider:nativeId"
|
|
113
|
+
const again = await router.get(a.id);
|
|
114
|
+
|
|
115
|
+
// Dry run: who would be tried, in what order, and why anything is skipped
|
|
116
|
+
console.table(router.plan({ vcpu: 32 }));
|
|
117
|
+
|
|
118
|
+
await a.kill();
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`create` throws a `RouterError` whose `attempts` lists every provider it tried and why each failed.
|
|
122
|
+
|
|
123
|
+
## Computers (desktops and phones)
|
|
124
|
+
|
|
125
|
+
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.
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
const desk = await router.computer({ os: "linux", externalUserId: "user_42" }); // same id, same desktop
|
|
129
|
+
const shot = await desk.act({ action: "screenshot" }); // { frameId, width, height, imageB64 }
|
|
130
|
+
await desk.act({ action: "left_click", coordinate: [412, 230] });
|
|
131
|
+
await desk.actBatch([{ action: "type", text: "hello" }, { action: "key", text: "Return" }]);
|
|
132
|
+
await desk.exec("ls /data");
|
|
133
|
+
|
|
134
|
+
// Hit a login or CAPTCHA? Hand the desktop to a person, then carry on.
|
|
135
|
+
const { url } = await desk.viewer("control", { reason: "Please sign in" });
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
npx @sandboxrouter/sdk desktop --os linux --user user_42 --control # prints a takeover link
|
|
140
|
+
npx @sandboxrouter/sdk screenshot maritime:<id> --out screen.png
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## CLI
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
npx @sandboxrouter/sdk providers # price table + which keys are set
|
|
147
|
+
npx @sandboxrouter/sdk route --gpu T4 # dry run
|
|
148
|
+
npx @sandboxrouter/sdk run "uname -a && nproc" # start, run, print, kill
|
|
149
|
+
npx @sandboxrouter/sdk run "pytest -q" --provider e2b,maritime --keep
|
|
150
|
+
npx @sandboxrouter/sdk bench --runs 5 # cold start + exec latency table
|
|
151
|
+
npx @sandboxrouter/sdk bench --share --label "us-east-1" # also write a report for results/
|
|
152
|
+
npx @sandboxrouter/sdk leaderboard results --out leaderboard.html
|
|
153
|
+
npx @sandboxrouter/sdk serve --port 8787 # HTTP API
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Bench
|
|
157
|
+
|
|
158
|
+
`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.
|
|
159
|
+
|
|
160
|
+
`--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.
|
|
161
|
+
|
|
162
|
+
## HTTP API
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
SANDBOXROUTER_TOKEN=secret npx @sandboxrouter/sdk serve
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
| Method | Path | Body |
|
|
169
|
+
|---|---|---|
|
|
170
|
+
| `GET` | `/v1/providers` | |
|
|
171
|
+
| `POST` | `/v1/route` | same as create; returns the candidate order |
|
|
172
|
+
| `POST` | `/v1/sandboxes` | `{ vcpu, memoryGb, gpu, env, timeoutMinutes, image, metadata, provider, strategy, fallback, require }` |
|
|
173
|
+
| `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` |
|
|
174
|
+
| `POST` | `/v1/sandboxes/:id/pause` | |
|
|
175
|
+
| `PUT` | `/v1/sandboxes/:id/files?path=` | raw bytes |
|
|
176
|
+
| `GET` | `/v1/sandboxes/:id/files?path=` | |
|
|
177
|
+
| `DELETE` | `/v1/sandboxes/:id` | |
|
|
178
|
+
| `POST` | `/v1/computers` | `{ os, externalUserId, name, screen, arch, provider }` |
|
|
179
|
+
| `POST` | `/v1/computers/:id/actions` | one action, or `{ actions: [...] }` (up to 50) |
|
|
180
|
+
| `GET` | `/v1/computers/:id/screenshot` | image bytes (`?format=jpeg&quality=80`) |
|
|
181
|
+
| `POST` | `/v1/computers/:id/exec` | `{ command, timeoutSeconds }` (max 30 s) |
|
|
182
|
+
| `PUT` / `GET` | `/v1/computers/:id/files?path=` | raw bytes |
|
|
183
|
+
| `POST` | `/v1/computers/:id/viewer` | `{ mode: "watch" \| "control", ttlSeconds, reason }` |
|
|
184
|
+
| `POST` | `/v1/computers/:id/sleep` | |
|
|
185
|
+
| `DELETE` | `/v1/computers/:id` | |
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
curl -s localhost:8787/v1/sandboxes -H "Authorization: Bearer secret" \
|
|
189
|
+
-d '{"vcpu":2,"strategy":"cheapest"}'
|
|
190
|
+
# {"id":"maritime:sbx-k3m7q2ab","provider":"maritime","estimatedHourlyUsd":0.022,"attempts":[...]}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Provider keys stay on the server. Clients only hold `SANDBOXROUTER_TOKEN`. It binds to `127.0.0.1` by default.
|
|
194
|
+
|
|
195
|
+
### Python
|
|
196
|
+
|
|
197
|
+
A standard-library client for the HTTP API lives in [`python/`](python/):
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
from sandboxrouter import Client
|
|
201
|
+
|
|
202
|
+
sr = Client("http://127.0.0.1:8787", token="secret")
|
|
203
|
+
with sr.create(vcpu=2, memory_gb=4, strategy="cheapest") as sbx:
|
|
204
|
+
sbx.exec("pip install rich && python -c 'import rich'", on_stdout=lambda c: print(c, end=""))
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
## How each adapter maps the spec
|
|
208
|
+
|
|
209
|
+
| | vCPU / memory | Lifetime | Image | Pause | Notes |
|
|
210
|
+
|---|---|---|---|---|---|
|
|
211
|
+
| 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. |
|
|
212
|
+
| E2B | set by the template | `timeoutMs` | template | yes (memory + disk) | Nonzero exits are returned as results, not thrown. |
|
|
213
|
+
| 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. |
|
|
214
|
+
| Modal | `cpu`, `memoryMiB`, `gpu` | `timeoutMs` | registry tag | no | Uses Modal app `sandboxrouter` (override with `SANDBOXROUTER_MODAL_APP`). |
|
|
215
|
+
| Vercel | `vcpus` (2 GB each) | `timeout` | `runtime` | no | Max 8 vCPU. |
|
|
216
|
+
| 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. |
|
|
217
|
+
| Fly.io Sprites | `cpus`, `ramMB` (may be ignored) | none: `kill()` deletes | no | no | Sprites idle for free on their own. |
|
|
218
|
+
| Blaxel | memory (CPU follows) | `ttl` | image | yes (archive, disk only) | Idle sandboxes go to free standby on their own. |
|
|
219
|
+
| Tensorlake | `cpus`, `memoryMb` | idle timeout | image | yes (memory + disk) | Output is line-based. |
|
|
220
|
+
| Runloop | snapped to Runloop's custom sizes | `keep_alive_time_seconds` (max 48 h) | blueprint | yes (Pro plan) | Commands start in the home directory. |
|
|
221
|
+
|
|
222
|
+
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.
|
|
223
|
+
|
|
224
|
+
## Roadmap
|
|
225
|
+
|
|
226
|
+
- [x] More providers: Morph, Fly.io Sprites, Blaxel, Tensorlake, Runloop
|
|
227
|
+
- [x] Desktops and phones (Maritime: Linux, Windows, Android)
|
|
228
|
+
- [x] Streaming exec output
|
|
229
|
+
- [x] Pause/resume where providers support it
|
|
230
|
+
- [x] Python client
|
|
231
|
+
- [x] Leaderboard built from `bench --share` reports
|
|
232
|
+
- [ ] Cloudflare Sandbox. Its SDK runs only inside a Worker with a Durable Object binding, so it needs a small Worker shim the router can call
|
|
233
|
+
- [ ] Computers on more providers (E2B Desktop, Daytona computer use)
|
|
234
|
+
- [ ] Snapshots and forks as a routed operation
|
|
235
|
+
- [ ] A hosted copy of the leaderboard page
|
|
236
|
+
|
|
237
|
+
## Website
|
|
238
|
+
|
|
239
|
+
[`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.
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
cd web && npm i && npm run dev # http://localhost:3000
|
|
243
|
+
# Playground without any provider keys (in-memory providers, nothing real starts):
|
|
244
|
+
node --import tsx test/demo-server.ts # token: demo
|
|
245
|
+
# Playground against real providers:
|
|
246
|
+
SANDBOXROUTER_TOKEN=secret npx @sandboxrouter/sdk serve --cors http://localhost:3000
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
## Development
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
npm i
|
|
253
|
+
npm test # router, HTTP server, leaderboard, Maritime sandboxes + computers against contract mocks
|
|
254
|
+
npm run test:py # Python client against the real HTTP server
|
|
255
|
+
npm run build
|
|
256
|
+
npm run dev -- providers
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Adding a provider means implementing `Provider` from [`src/types.ts`](src/types.ts) and adding a catalog entry with a pricing source link.
|
|
260
|
+
|
|
261
|
+
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
|
+
}
|
package/dist/bench.d.ts
ADDED
|
@@ -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,35 @@
|
|
|
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
|
+
/** Env vars the adapter reads. Any one group being fully set means "configured". */
|
|
20
|
+
credentials: string[][];
|
|
21
|
+
docs: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Catalog order is the default routing order. Maritime is first because it is the cheapest
|
|
25
|
+
* managed sandbox per hour in this list at every shape; `npx @sandboxrouter/sdk providers` prints the
|
|
26
|
+
* numbers so you can check, and PRs that correct a price are welcome.
|
|
27
|
+
*/
|
|
28
|
+
export declare const CATALOG: ProviderInfo[];
|
|
29
|
+
export declare function info(id: ProviderId): ProviderInfo;
|
|
30
|
+
export declare const DEFAULT_VCPU = 2;
|
|
31
|
+
export declare const DEFAULT_MEMORY_GB = 4;
|
|
32
|
+
/** Estimated USD per hour for this spec on this provider (list price, while running). */
|
|
33
|
+
export declare function hourlyCost(id: ProviderId, spec?: SandboxSpec): number;
|
|
34
|
+
/** Why a provider can't take this spec, or null if it can. */
|
|
35
|
+
export declare function unfit(id: ProviderId, spec: SandboxSpec, require?: Capability[]): string | null;
|
package/dist/catalog.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog order is the default routing order. Maritime is first because it is the cheapest
|
|
3
|
+
* managed sandbox per hour in this list at every shape; `npx @sandboxrouter/sdk providers` prints the
|
|
4
|
+
* numbers so you can check, and PRs that correct a price are welcome.
|
|
5
|
+
*/
|
|
6
|
+
export const CATALOG = [
|
|
7
|
+
{
|
|
8
|
+
id: "maritime",
|
|
9
|
+
name: "Maritime",
|
|
10
|
+
pricing: {
|
|
11
|
+
vcpuHour: 0.0055,
|
|
12
|
+
gbHour: 0.00275,
|
|
13
|
+
note: "per second, disk included",
|
|
14
|
+
source: "https://maritime.sh/pricing",
|
|
15
|
+
checked: "2026-11-04",
|
|
16
|
+
},
|
|
17
|
+
capabilities: ["pause", "custom-image"],
|
|
18
|
+
limits: { maxVcpu: 16, maxMemoryGb: 64 },
|
|
19
|
+
credentials: [["MARITIME_API_KEY"]],
|
|
20
|
+
docs: "https://maritime.sh/docs/ephemeral-sandboxes",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: "e2b",
|
|
24
|
+
name: "E2B",
|
|
25
|
+
pricing: {
|
|
26
|
+
vcpuHour: 0.0504,
|
|
27
|
+
gbHour: 0.0162,
|
|
28
|
+
note: "per second; Pro plan $150/mo",
|
|
29
|
+
source: "https://e2b.dev/pricing",
|
|
30
|
+
checked: "2026-11-04",
|
|
31
|
+
},
|
|
32
|
+
capabilities: ["pause", "custom-image"],
|
|
33
|
+
limits: { maxVcpu: 8, maxMemoryGb: 8 },
|
|
34
|
+
credentials: [["E2B_API_KEY"]],
|
|
35
|
+
docs: "https://e2b.dev/docs",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: "daytona",
|
|
39
|
+
name: "Daytona",
|
|
40
|
+
pricing: {
|
|
41
|
+
vcpuHour: 0.0504,
|
|
42
|
+
gbHour: 0.0162,
|
|
43
|
+
note: "per second, plus disk above 5 GiB",
|
|
44
|
+
source: "https://www.daytona.io/pricing",
|
|
45
|
+
checked: "2026-11-04",
|
|
46
|
+
},
|
|
47
|
+
capabilities: ["pause", "custom-image"],
|
|
48
|
+
limits: { maxVcpu: 4, maxMemoryGb: 8 },
|
|
49
|
+
credentials: [["DAYTONA_API_KEY"]],
|
|
50
|
+
docs: "https://www.daytona.io/docs",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: "modal",
|
|
54
|
+
name: "Modal",
|
|
55
|
+
pricing: {
|
|
56
|
+
vcpuHour: 0.07096,
|
|
57
|
+
gbHour: 0.0242,
|
|
58
|
+
note: "sandbox rates, GPUs extra; region pinning x1.15-1.75",
|
|
59
|
+
source: "https://modal.com/pricing",
|
|
60
|
+
checked: "2026-11-04",
|
|
61
|
+
},
|
|
62
|
+
capabilities: ["gpu", "custom-image"],
|
|
63
|
+
limits: { maxVcpu: 64, maxMemoryGb: 256 },
|
|
64
|
+
credentials: [["MODAL_TOKEN_ID", "MODAL_TOKEN_SECRET"]],
|
|
65
|
+
docs: "https://modal.com/docs/guide/sandbox",
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: "vercel",
|
|
69
|
+
name: "Vercel Sandbox",
|
|
70
|
+
pricing: {
|
|
71
|
+
vcpuHour: 0.128,
|
|
72
|
+
gbHour: 0.0212,
|
|
73
|
+
note: "bills active CPU only; this is the 100%-CPU price, idle work costs less",
|
|
74
|
+
source: "https://vercel.com/docs/vercel-sandbox/pricing",
|
|
75
|
+
checked: "2026-11-04",
|
|
76
|
+
},
|
|
77
|
+
capabilities: [],
|
|
78
|
+
limits: { maxVcpu: 8, maxMemoryGb: 16 },
|
|
79
|
+
credentials: [["VERCEL_OIDC_TOKEN"], ["VERCEL_TOKEN", "VERCEL_TEAM_ID", "VERCEL_PROJECT_ID"]],
|
|
80
|
+
docs: "https://vercel.com/docs/vercel-sandbox",
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "morph",
|
|
84
|
+
name: "Morph Cloud",
|
|
85
|
+
pricing: {
|
|
86
|
+
vcpuHour: 0.05,
|
|
87
|
+
gbHour: 0,
|
|
88
|
+
note: "$0.05/MCU; MCU-hours = max(vCPU, RAM/4 GB, disk/16 GB); snapshots extra",
|
|
89
|
+
source: "https://cloud.morph.so/web/pricing",
|
|
90
|
+
checked: "2026-11-04",
|
|
91
|
+
},
|
|
92
|
+
capabilities: ["pause", "custom-image"],
|
|
93
|
+
limits: { maxVcpu: 64, maxMemoryGb: 256 },
|
|
94
|
+
credentials: [["MORPH_API_KEY"]],
|
|
95
|
+
docs: "https://cloud.morph.so/docs/developers",
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
id: "sprites",
|
|
99
|
+
name: "Fly.io Sprites",
|
|
100
|
+
pricing: {
|
|
101
|
+
vcpuHour: 0.0385,
|
|
102
|
+
gbHour: 0.021875,
|
|
103
|
+
note: "bills actual CPU and memory use; this is the full-use price; storage extra",
|
|
104
|
+
source: "https://fly.io/sprites",
|
|
105
|
+
checked: "2026-11-04",
|
|
106
|
+
},
|
|
107
|
+
capabilities: [],
|
|
108
|
+
// Not published per sprite; 8 GB is what accounts report getting by default.
|
|
109
|
+
limits: { maxVcpu: 8, maxMemoryGb: 8 },
|
|
110
|
+
credentials: [["SPRITES_TOKEN"]],
|
|
111
|
+
docs: "https://fly.io/sprites",
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
id: "blaxel",
|
|
115
|
+
name: "Blaxel",
|
|
116
|
+
pricing: {
|
|
117
|
+
vcpuHour: 0,
|
|
118
|
+
gbHour: 0.0414,
|
|
119
|
+
note: "billed on memory only ($0.0000115/GB-s), CPU scales with memory (~2 GB per core)",
|
|
120
|
+
source: "https://blaxel.ai/pricing",
|
|
121
|
+
checked: "2026-11-04",
|
|
122
|
+
},
|
|
123
|
+
capabilities: ["pause", "custom-image"],
|
|
124
|
+
// Self-serve caps are per-tier and not published; these are conservative.
|
|
125
|
+
limits: { maxVcpu: 8, maxMemoryGb: 16 },
|
|
126
|
+
credentials: [["BL_API_KEY", "BL_WORKSPACE"]],
|
|
127
|
+
docs: "https://docs.blaxel.ai/Sandboxes/Overview",
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
id: "tensorlake",
|
|
131
|
+
name: "Tensorlake",
|
|
132
|
+
pricing: {
|
|
133
|
+
vcpuHour: 0.07,
|
|
134
|
+
gbHour: 0.015,
|
|
135
|
+
note: "bills active CPU (floor 20% of allocation); this is the full-use price; Pro plan is lower",
|
|
136
|
+
source: "https://www.tensorlake.ai/pricing",
|
|
137
|
+
checked: "2026-11-04",
|
|
138
|
+
},
|
|
139
|
+
capabilities: ["pause", "custom-image"],
|
|
140
|
+
limits: { maxVcpu: 4, maxMemoryGb: 16 },
|
|
141
|
+
credentials: [["TENSORLAKE_API_KEY"]],
|
|
142
|
+
docs: "https://docs.tensorlake.ai/sandboxes",
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
id: "runloop",
|
|
146
|
+
name: "Runloop",
|
|
147
|
+
pricing: {
|
|
148
|
+
vcpuHour: 0.108,
|
|
149
|
+
gbHour: 0.0252,
|
|
150
|
+
note: "per second, plus disk; pause (suspend) is a Pro-plan feature",
|
|
151
|
+
source: "https://www.runloop.ai/pricing",
|
|
152
|
+
checked: "2026-11-04",
|
|
153
|
+
},
|
|
154
|
+
capabilities: ["pause", "custom-image"],
|
|
155
|
+
limits: { maxVcpu: 16, maxMemoryGb: 64 },
|
|
156
|
+
credentials: [["RUNLOOP_API_KEY"]],
|
|
157
|
+
docs: "https://docs.runloop.ai",
|
|
158
|
+
},
|
|
159
|
+
];
|
|
160
|
+
export function info(id) {
|
|
161
|
+
const p = CATALOG.find((c) => c.id === id);
|
|
162
|
+
if (!p)
|
|
163
|
+
throw new Error(`unknown provider: ${id}`);
|
|
164
|
+
return p;
|
|
165
|
+
}
|
|
166
|
+
export const DEFAULT_VCPU = 2;
|
|
167
|
+
export const DEFAULT_MEMORY_GB = 4;
|
|
168
|
+
/** Estimated USD per hour for this spec on this provider (list price, while running). */
|
|
169
|
+
export function hourlyCost(id, spec = {}) {
|
|
170
|
+
const { pricing } = info(id);
|
|
171
|
+
const vcpu = spec.vcpu ?? DEFAULT_VCPU;
|
|
172
|
+
let mem = spec.memoryGb ?? DEFAULT_MEMORY_GB;
|
|
173
|
+
// Vercel fixes memory at 2 GB per vCPU.
|
|
174
|
+
if (id === "vercel")
|
|
175
|
+
mem = Math.max(mem, vcpu * 2);
|
|
176
|
+
// Blaxel sizes by memory, about 2 GB per core.
|
|
177
|
+
if (id === "blaxel")
|
|
178
|
+
mem = Math.max(mem, vcpu * 2);
|
|
179
|
+
// Morph bills whole MCUs: the larger of vCPUs and 4 GB blocks of memory.
|
|
180
|
+
if (id === "morph")
|
|
181
|
+
return Math.max(vcpu, Math.ceil(mem / 4)) * pricing.vcpuHour;
|
|
182
|
+
return vcpu * pricing.vcpuHour + mem * pricing.gbHour;
|
|
183
|
+
}
|
|
184
|
+
/** Why a provider can't take this spec, or null if it can. */
|
|
185
|
+
export function unfit(id, spec, require = []) {
|
|
186
|
+
const p = info(id);
|
|
187
|
+
const needs = new Set(require);
|
|
188
|
+
if (spec.gpu)
|
|
189
|
+
needs.add("gpu");
|
|
190
|
+
for (const c of needs)
|
|
191
|
+
if (!p.capabilities.includes(c))
|
|
192
|
+
return `no ${c} support`;
|
|
193
|
+
if ((spec.vcpu ?? DEFAULT_VCPU) > p.limits.maxVcpu)
|
|
194
|
+
return `max ${p.limits.maxVcpu} vCPU`;
|
|
195
|
+
if ((spec.memoryGb ?? DEFAULT_MEMORY_GB) > p.limits.maxMemoryGb)
|
|
196
|
+
return `max ${p.limits.maxMemoryGb} GB memory`;
|
|
197
|
+
return null;
|
|
198
|
+
}
|
package/dist/cli.d.ts
ADDED