@sandboxrouter/sdk 0.3.0 → 0.3.1

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/README.md CHANGED
@@ -8,7 +8,7 @@ Not every adapter has run against a real account yet. The [Prices](#prices) tabl
8
8
  import { Router } from "@sandboxrouter/sdk";
9
9
 
10
10
  const router = new Router(); // uses whichever provider keys you've set
11
- const sbx = await router.create({ vcpu: 2, memoryGb: 4 });
11
+ const sbx = await router.create(); // or create({ vcpu: 2, memoryGb: 4 }) for a size
12
12
  try {
13
13
  const r = await sbx.exec("python3 -c 'print(6*7)'");
14
14
  console.log(r.stdout, "ran on", sbx.provider);
@@ -19,6 +19,8 @@ try {
19
19
 
20
20
  Runs as plain JavaScript on Node 20+. (In TypeScript 5.2+, or Node 24+, `await using sbx = ...` kills the sandbox for you.)
21
21
 
22
+ With no size you get 2 vCPU / 4 GB, except on providers whose default image has a fixed size (Daytona 1 vCPU / 1 GB, E2B 2 vCPU / 0.5 GB, OpenScale 2 vCPU / 1 GB), where you get that. Ask for more than that and those providers are skipped unless you pass a custom `image` built at the size you need.
23
+
22
24
  Switch providers by changing which keys are set, not your code (custom images are the exception: each provider names its own). If one returns a 429, the next one takes the sandbox.
23
25
 
24
26
  Site: [sandboxrouter.vercel.app](https://sandboxrouter.vercel.app) · npm: [`@sandboxrouter/sdk`](https://www.npmjs.com/package/@sandboxrouter/sdk)
@@ -58,6 +60,7 @@ How to read it:
58
60
  - **Up to $X** means the provider bills CPU only while it's busy, so this is the price with every core busy. An agent that mostly waits on its model pays less.
59
61
  - **Plan needed** is the monthly plan you must be on before the API will create sandboxes. "Credited" means the fee comes back as usage. "None" means a free tier or plain pay-as-you-go works. For a small project, the plan can cost more than the usage.
60
62
  - **Real-account check** is the last time the 14-step [`check`](#compatibility) passed against a real account. "Not run yet" means the adapter is built from the provider's docs and tested against mocks only.
63
+ - **Skipped** means the provider can't take 2 vCPU / 4 GB. One skipped for its default image's size is still used by a `create()` that names no size, at that image's size.
61
64
 
62
65
  | Provider | $/hr | Plan needed | Real-account check | Notes | Source |
63
66
  |---|---|---|---|---|---|
@@ -89,11 +92,11 @@ How to read it:
89
92
  | InstaBox (sign-up waitlisted) | $0.017 | none | not run yet | Per second while running on fixed sizes (2/4, 4/8, 8/16); paused or stopped boxes bill only /data beyond the included disk ($0.00011/GiB-h); Pro $20/mo includes $20 credits; free plan: 1 box at 4/8 | [pricing](https://instabox.sh/docs/pricing.md) |
90
93
  | Koyeb Sandboxes (not taking new accounts) | $0.058 | $29/mo | not run yet | Per second; fixed Standard instance sizes (1 vCPU + 1 GB = $0.0144/h); sandboxes need a paid plan (Pro $29/mo); public preview | [pricing](https://www.koyeb.com/docs/reference/instances) |
91
94
  | Dedalus Machines (sign-up waitlisted) | $0.149 | none | not run yet | Per second while awake; sleeping machines bill storage only (10 GiB included on Hobby, 20 on Pro); Hobby plan max 4 vCPU / 16 GiB; private beta | [pricing](https://docs.dedaluslabs.ai/dcs/dm/pricing) |
92
- | OpenScale Sandboxes | skipped (default size 2 vCPU / 1 GB; more needs image.openscale) | none | passed (2026-11-06) | Per second on allocated size; no platform fee or minimum; paused = free; size comes from the template (E2B-compatible API); Singapore only; needs a pay-as-you-go key | [pricing](https://openscale.so/sandboxes) |
95
+ | OpenScale Sandboxes | skipped (default image is 2 vCPU / 1 GB; a bigger size needs image.openscale) | none | passed (2026-11-06) | Per second on allocated size; no platform fee or minimum; paused = free; size comes from the template (E2B-compatible API); Singapore only; needs a pay-as-you-go key | [pricing](https://openscale.so/sandboxes) |
93
96
  | Azure Container Apps dynamic sessions | skipped (max 1 vCPU) | none | not run yet | Flat $0.03 per session-hour (built-in Shell pools), billed per allocated session in 1-hour increments, East US; needs a session pool you create once | [pricing](https://learn.microsoft.com/en-us/azure/container-apps/billing#dynamic-sessions) |
94
- | Daytona | skipped (default size 1 vCPU / 1 GB; more needs image.daytona) | none | failed 1 step (2026-11-06) | Per second, plus disk above 5 GiB | [pricing](https://www.daytona.io/pricing) |
97
+ | Daytona | skipped (default image is 1 vCPU / 1 GB; a bigger size needs image.daytona) | none | failed 1 step (2026-11-06) | Per second, plus disk above 5 GiB | [pricing](https://www.daytona.io/pricing) |
95
98
  | Alibaba Cloud Agent Sandbox | skipped (max 2 GB memory) | none | not run yet | Per second; Default QoS, Hong Kong and regions outside mainland China; disk separately $0.00025308/GiB-h; code-interpreter-v1 template is 2 vCPU / 2 GiB with 10 GiB disk (E2B-compatible API) | [pricing](https://www.alibabacloud.com/help/en/agent-sandbox/product-overview/billing-overview) |
96
- | E2B | skipped (default size 2 vCPU / 0.5 GB; more needs image.e2b) | none | passed (2026-11-06) | Per second; free Hobby tier ($100 one-time credit, 1-hour sessions, 20 at once); Pro $150/mo for longer sessions | [pricing](https://e2b.dev/pricing) |
99
+ | E2B | skipped (default image is 2 vCPU / 0.5 GB; a bigger size needs image.e2b) | none | passed (2026-11-06) | Per second; free Hobby tier ($100 one-time credit, 1-hour sessions, 20 at once); Pro $150/mo for longer sessions | [pricing](https://e2b.dev/pricing) |
97
100
  | Docker (local) | free | none | passed (2026-11-06) | Runs on your own machine; free, but shares your kernel, so weaker isolation than a cloud micro-VM | [pricing](https://docs.docker.com/engine/) |
98
101
 
99
102
  Checked 2026-11-04 to 2026-11-06.
@@ -277,11 +280,11 @@ A sandbox the provider already stopped is just dropped from the ledger. `sandbox
277
280
 
278
281
  `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.
279
282
 
280
- `--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.
283
+ `--share` writes a JSON report (timings, your label, Node version and platform; never keys, env or error text), and `--by` adds your GitHub username so the website can credit you. Open a PR adding it to [`results/`](results/); CI checks every result file for keys, addresses and impossible numbers (`npm run results:check`), see [Sending results](CONTRIBUTING.md#sending-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.
281
284
 
282
285
  ### Real cost
283
286
 
284
- Prices in the catalog are list prices. `cost` checks them against real bills: it starts one sandbox per provider at a known size, records the size the sandbox really has (`nproc` and `MemTotal`), keeps every core busy for a fixed time (some providers bill active CPU only), kills it, and predicts the cost from list price × wall time. `cost settle` then reads what the provider charged for that sandbox, from Daytona's usage analytics, Blaxel's metrics API and Maritime's metered usage. E2B and OpenScale have no billing API, so their bill is read from the dashboard. Bills post late on some providers (Daytona can take up to 48 h), so rerun `settle` until every row has one.
287
+ Prices in the catalog are list prices. `cost` checks them against real bills: it starts one sandbox per provider at a known size, records the size the sandbox really has (`nproc` and `MemTotal`), keeps every core busy for a fixed time (some providers bill active CPU only), kills it, and predicts the cost from list price × wall time. `cost settle` then reads what the provider charged for that sandbox, from Daytona's usage analytics, Blaxel's metrics API, Maritime's metered usage and Sail's spend API. E2B and OpenScale have no billing API, so their bill is read from the dashboard. Bills post late on some providers (Daytona can take up to 48 h), so rerun `settle` until every row has one.
285
288
 
286
289
  ```bash
287
290
  sandboxrouter cost run --minutes 10 # every configured cloud provider, 2 vCPU / 4 GB
@@ -377,7 +380,7 @@ A standard-library client for the HTTP API lives in [`python/`](python/):
377
380
  from sandboxrouter import Client
378
381
 
379
382
  sr = Client("http://127.0.0.1:8787", token="secret")
380
- with sr.create(vcpu=2, memory_gb=4, strategy="cheapest") as sbx:
383
+ with sr.create(strategy="cheapest") as sbx:
381
384
  sbx.exec("pip install rich && python -c 'import rich'", on_stdout=lambda c: print(c, end=""))
382
385
  ```
383
386
 
package/dist/catalog.js CHANGED
@@ -21,7 +21,7 @@ export function planLabel(id) {
21
21
  * that can't take the size; then Docker. The router uses the same price order, but only among providers
22
22
  * you have keys for, so a gated provider only gets work if you already have an account there.
23
23
  */
24
- export function listingOrder(spec = {}) {
24
+ export function listingOrder(spec = { vcpu: DEFAULT_VCPU, memoryGb: DEFAULT_MEMORY_GB }) {
25
25
  const rank = (c) => (c.local ? 3 : unfit(c.id, spec) ? 2 : c.access ? 1 : 0);
26
26
  return [...CATALOG].sort((a, b) => rank(a) - rank(b) || hourlyCost(a.id, spec) - hourlyCost(b.id, spec) || a.id.localeCompare(b.id));
27
27
  }
@@ -746,8 +746,10 @@ export function unfit(id, spec, require = []) {
746
746
  return `max ${p.limits.maxVcpu} vCPU`;
747
747
  if ((spec.memoryGb ?? DEFAULT_MEMORY_GB) > p.limits.maxMemoryGb)
748
748
  return `max ${p.limits.maxMemoryGb} GB memory`;
749
+ // A default image's size is fixed. With no size asked for you get that size, so only a size you
750
+ // name can be too big for it.
749
751
  const fixed = fixedSize(id, spec);
750
- if (fixed && ((spec.vcpu ?? DEFAULT_VCPU) > fixed.vcpu || (spec.memoryGb ?? DEFAULT_MEMORY_GB) > fixed.memoryGb))
751
- return `default size ${fixed.vcpu} vCPU / ${fixed.memoryGb} GB; more needs image.${id}`;
752
+ if (fixed && ((spec.vcpu ?? 0) > fixed.vcpu || (spec.memoryGb ?? 0) > fixed.memoryGb))
753
+ return `default image is ${fixed.vcpu} vCPU / ${fixed.memoryGb} GB; a bigger size needs image.${id}`;
752
754
  return null;
753
755
  }
package/dist/cli.js CHANGED
@@ -7,7 +7,7 @@ import { costMarkdown, costRun, settle } from "./cost.js";
7
7
  import { checkProvider } from "./check.js";
8
8
  import { gc, listOpen } from "./gc.js";
9
9
  import { loadReports, makeReport, renderLeaderboard } from "./leaderboard.js";
10
- import { accessLabel, hourlyCost, listingOrder, planLabel, unfit } from "./catalog.js";
10
+ import { DEFAULT_MEMORY_GB, DEFAULT_VCPU, accessLabel, hourlyCost, listingOrder, planLabel, unfit } from "./catalog.js";
11
11
  import { Router } from "./router.js";
12
12
  import { createApp } from "./server.js";
13
13
  import { RouterError } from "./types.js";
@@ -78,6 +78,12 @@ const { values, positionals } = parseArgs({
78
78
  },
79
79
  });
80
80
  const list = (s) => (s ? s.split(",").map((x) => x.trim()).filter(Boolean) : undefined);
81
+ /** Plain left-aligned columns, two spaces apart; the last column isn't padded. */
82
+ function textTable(head, rows) {
83
+ const width = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
84
+ const line = (r) => r.map((v, i) => (i === r.length - 1 ? v : v.padEnd(width[i]))).join(" ").trimEnd();
85
+ return [line(head), ...rows.map(line)].join("\n");
86
+ }
81
87
  const num = (s) => (s === undefined ? undefined : Number(s));
82
88
  const spec = {
83
89
  vcpu: num(values.vcpu),
@@ -99,10 +105,12 @@ async function main() {
99
105
  if (!cmd || values.help)
100
106
  return console.log(HELP);
101
107
  if (cmd === "providers") {
102
- const rows = listingOrder(spec).map((c) => ({
108
+ // The table is for a size, so ask for the default one explicitly when no flags are given.
109
+ const size = { ...spec, vcpu: spec.vcpu ?? DEFAULT_VCPU, memoryGb: spec.memoryGb ?? DEFAULT_MEMORY_GB };
110
+ const rows = listingOrder(size).map((c) => ({
103
111
  provider: c.name,
104
112
  id: c.id,
105
- "$/hr": unfit(c.id, spec) ?? `${c.billing === "active-cpu" ? "up to " : ""}$${hourlyCost(c.id, spec).toFixed(4)}`,
113
+ "$/hr": unfit(c.id, size) ?? `${c.billing === "active-cpu" ? "up to " : ""}$${hourlyCost(c.id, size).toFixed(4)}`,
106
114
  plan: c.local ? "none" : planLabel(c.id),
107
115
  configured: router.isConfigured(c.id) ? "yes" : "no",
108
116
  capabilities: c.capabilities.join(", ") || "-",
@@ -110,9 +118,23 @@ async function main() {
110
118
  }));
111
119
  if (values.json)
112
120
  return console.log(JSON.stringify(rows, null, 2));
113
- console.log(`Estimated list price for ${spec.vcpu ?? 2} vCPU, ${spec.memoryGb ?? 4} GB while running, cheapest first.`);
114
- console.log(`"up to" = billed on active CPU only, shown with every core busy. plan = monthly plan needed to use the API.\n`);
115
- console.table(rows);
121
+ console.log(`${size.vcpu} vCPU / ${size.memoryGb} GB: estimated list price while running, cheapest first.\n`);
122
+ const cells = listingOrder(size).map((c) => {
123
+ const why = unfit(c.id, size);
124
+ const usd = c.local ? "free" : why ? "skipped" : `${c.billing === "active-cpu" ? "≤ " : ""}$${hourlyCost(c.id, size).toFixed(4)}`;
125
+ const note = [why, accessLabel(c.id), c.local ? "local; only used when named" : null].filter(Boolean).join("; ");
126
+ const name = c.name.length > 30 ? `${c.name.slice(0, 29)}…` : c.name;
127
+ return [c.id, name, usd, c.local ? "none" : planLabel(c.id), router.isConfigured(c.id) ? "set" : "-", c.capabilities.join(", ") || "-", note];
128
+ });
129
+ console.log(textTable(["ID", "PROVIDER", "$/HR", "PLAN", "KEY", "SUPPORTS", "NOTE"], cells));
130
+ console.log([
131
+ "",
132
+ "≤ billed on busy CPU only; the price with every core busy",
133
+ "PLAN monthly plan the API needs before it creates sandboxes (credited = paid back as usage)",
134
+ "KEY set = this provider's env vars are set here",
135
+ "skipped can't take this size. A provider skipped for its default image's size still takes",
136
+ " a create() that names no size, at that image's size.",
137
+ ].join("\n"));
116
138
  return;
117
139
  }
118
140
  if (cmd === "route") {
package/dist/router.d.ts CHANGED
@@ -39,6 +39,11 @@ export declare class Router {
39
39
  provider(id: ProviderId): Provider;
40
40
  /** The ordered list of providers a create would try, with the reason any are skipped. */
41
41
  plan(spec?: SandboxSpec, route?: RouteOptions): Candidate[];
42
+ /**
43
+ * Why a create has nowhere to go, leading with what the caller can fix: no keys at all, or why the
44
+ * providers they do have keys for were skipped.
45
+ */
46
+ private whyNone;
42
47
  /** Start a sandbox on the best available provider, falling back down the list on failure. */
43
48
  create(spec?: SandboxSpec, route?: RouteOptions): Promise<RoutedSandbox>;
44
49
  private record;
package/dist/router.js CHANGED
@@ -150,14 +150,40 @@ export class Router {
150
150
  }
151
151
  return list;
152
152
  }
153
+ /**
154
+ * Why a create has nowhere to go, leading with what the caller can fix: no keys at all, or why the
155
+ * providers they do have keys for were skipped.
156
+ */
157
+ whyNone(plan, explicit) {
158
+ if (plan.length === 0)
159
+ return "no provider can take this sandbox: none registered";
160
+ // Providers the caller named: say what each one needs.
161
+ if (explicit) {
162
+ const why = plan.map((c) => {
163
+ const vars = CATALOG.find((x) => x.id === c.provider)?.credentials.map((g) => g.join(" + ")).join(" or ");
164
+ return `${c.provider} (${c.skip}${vars && c.skip?.startsWith("not configured") ? `: set ${vars}` : ""})`;
165
+ });
166
+ return `no provider can take this sandbox: ${why.join(", ")}`;
167
+ }
168
+ const keyed = plan.filter((c) => this.isConfigured(c.provider));
169
+ if (keyed.length === 0)
170
+ return ("no provider keys found: set the API key for at least one provider (the README's Env table lists each provider's variables); " +
171
+ "`npx @sandboxrouter/sdk providers` shows which are set");
172
+ const rest = plan.length - keyed.length;
173
+ const small = keyed.filter((c) => c.skip?.startsWith("default image"));
174
+ return (`none of the providers you have keys for can take this sandbox: ${keyed.map((c) => `${c.provider} (${c.skip})`).join(", ")}` +
175
+ (rest ? `; ${rest} other provider${rest === 1 ? " has" : "s have"} no key set` : "") +
176
+ (small.length
177
+ ? `. To use ${small.map((c) => c.provider).join(" or ")}, leave out vcpu and memoryGb to get the default image's size, ` +
178
+ `or pass image: { ${small[0].provider}: "<template>" } with a template built at the size you need`
179
+ : ""));
180
+ }
153
181
  /** Start a sandbox on the best available provider, falling back down the list on failure. */
154
182
  async create(spec = {}, route = {}) {
155
183
  const plan = this.plan(spec, route);
156
184
  const usable = plan.filter((c) => !c.skip);
157
- if (usable.length === 0) {
158
- const why = plan.map((c) => `${c.provider} (${c.skip})`).join(", ");
159
- throw new RouterError(`no provider can take this sandbox: ${why || "none registered"}`);
160
- }
185
+ if (usable.length === 0)
186
+ throw new RouterError(this.whyNone(plan, !!route.provider));
161
187
  const attempts = [];
162
188
  const tries = route.fallback === false ? usable.slice(0, 1) : usable;
163
189
  const limitS = route.createTimeoutSeconds ?? DEFAULT_CREATE_TIMEOUT_S;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sandboxrouter/sdk",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "One API for every sandbox provider: route AI agents' code by price, speed or capability, with automatic fallback. Free and open source.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -28,7 +28,8 @@
28
28
  "test:py": "python3 -m unittest discover -s python/tests -v",
29
29
  "site:data": "node --import tsx scripts/site-data.ts",
30
30
  "price:check": "node --import tsx scripts/price-watch.ts",
31
- "secrets:push": "node --import tsx scripts/push-secrets.ts"
31
+ "secrets:push": "node --import tsx scripts/push-secrets.ts",
32
+ "results:check": "node --import tsx scripts/validate-results.ts"
32
33
  },
33
34
  "keywords": [
34
35
  "ai-agents",