@sandboxrouter/sdk 0.3.0 → 0.3.2

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
@@ -6,8 +6,9 @@ import { bench, benchMarkdown } from "./bench.js";
6
6
  import { costMarkdown, costRun, settle } from "./cost.js";
7
7
  import { checkProvider } from "./check.js";
8
8
  import { gc, listOpen } from "./gc.js";
9
+ import { contributor } from "./results.js";
9
10
  import { loadReports, makeReport, renderLeaderboard } from "./leaderboard.js";
10
- import { accessLabel, hourlyCost, listingOrder, planLabel, unfit } from "./catalog.js";
11
+ import { DEFAULT_MEMORY_GB, DEFAULT_VCPU, accessLabel, hourlyCost, listingOrder, planLabel, unfit } from "./catalog.js";
11
12
  import { Router } from "./router.js";
12
13
  import { createApp } from "./server.js";
13
14
  import { RouterError } from "./types.js";
@@ -17,15 +18,15 @@ Usage:
17
18
  sandboxrouter providers [--vcpu N] [--memory GB] price table + which keys are set
18
19
  sandboxrouter route [spec flags] [route flags] dry run: the order providers would be tried
19
20
  sandboxrouter run "<cmd>" [spec flags] [route flags] [--keep]
20
- sandboxrouter check --provider ID[,ID] | --all [--json] [--save [dir]]
21
+ sandboxrouter check --provider ID[,ID] | --all [--json] [--save [dir]] [--by GITHUB_USER]
21
22
  test providers for real: exec, env, UTF-8, streaming, files, timeout, pause,
22
23
  kill; exits 1 if any step fails. --all = every configured provider;
23
24
  --save writes results/conformance/<id>.json for the compatibility table
24
25
  sandboxrouter ls [--json] sandboxes this machine started that weren't killed
25
26
  sandboxrouter gc [--all | --older-than MIN] [--dry-run]
26
27
  kill leaked sandboxes: by default those past their lifetime
27
- sandboxrouter bench [--runs 3] [--provider a,b] [--json] [--share [--label "where you ran it"]]
28
- sandboxrouter cost run [--provider a,b] [--minutes 10] [--vcpu 2 --memory 4] [--no-load] [--out FILE]
28
+ sandboxrouter bench [--runs 3] [--provider a,b] [--json] [--share [--label "where you ran it"] [--by GITHUB_USER]]
29
+ sandboxrouter cost run [--provider a,b] [--minutes 10] [--vcpu 2 --memory 4] [--no-load] [--out FILE] [--by GITHUB_USER]
29
30
  start one sandbox per provider for a fixed time with every core busy, record
30
31
  the size it really got and the list-price prediction
31
32
  sandboxrouter cost settle FILE
@@ -42,7 +43,8 @@ Route flags: --provider e2b[,modal,...] --strategy cheapest|fastest
42
43
  --no-fallback --fallback-any (also on setup errors like a bad key)
43
44
  --create-timeout SEC (per provider, default 120) --require gpu,custom-image
44
45
 
45
- Keys: one env var group per provider; "sandboxrouter providers" lists them and shows which are set.`;
46
+ Keys: one env var group per provider; "sandboxrouter providers" lists them and shows which are set.
47
+ --by puts your GitHub username on a saved result, so the website can credit it.`;
46
48
  const { values, positionals } = parseArgs({
47
49
  allowPositionals: true,
48
50
  options: {
@@ -75,9 +77,16 @@ const { values, positionals } = parseArgs({
75
77
  "dry-run": { type: "boolean" },
76
78
  minutes: { type: "string" },
77
79
  "no-load": { type: "boolean" },
80
+ by: { type: "string" },
78
81
  },
79
82
  });
80
83
  const list = (s) => (s ? s.split(",").map((x) => x.trim()).filter(Boolean) : undefined);
84
+ /** Plain left-aligned columns, two spaces apart; the last column isn't padded. */
85
+ function textTable(head, rows) {
86
+ const width = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
87
+ const line = (r) => r.map((v, i) => (i === r.length - 1 ? v : v.padEnd(width[i]))).join(" ").trimEnd();
88
+ return [line(head), ...rows.map(line)].join("\n");
89
+ }
81
90
  const num = (s) => (s === undefined ? undefined : Number(s));
82
91
  const spec = {
83
92
  vcpu: num(values.vcpu),
@@ -86,6 +95,15 @@ const spec = {
86
95
  timeoutMinutes: num(values.timeout),
87
96
  };
88
97
  const providers = list(values.provider);
98
+ const by = (() => {
99
+ try {
100
+ return contributor(values.by);
101
+ }
102
+ catch (e) {
103
+ console.error(`error: ${e.message}`);
104
+ process.exit(2);
105
+ }
106
+ })();
89
107
  const route = {
90
108
  provider: providers,
91
109
  strategy: values.strategy,
@@ -99,10 +117,12 @@ async function main() {
99
117
  if (!cmd || values.help)
100
118
  return console.log(HELP);
101
119
  if (cmd === "providers") {
102
- const rows = listingOrder(spec).map((c) => ({
120
+ // The table is for a size, so ask for the default one explicitly when no flags are given.
121
+ const size = { ...spec, vcpu: spec.vcpu ?? DEFAULT_VCPU, memoryGb: spec.memoryGb ?? DEFAULT_MEMORY_GB };
122
+ const rows = listingOrder(size).map((c) => ({
103
123
  provider: c.name,
104
124
  id: c.id,
105
- "$/hr": unfit(c.id, spec) ?? `${c.billing === "active-cpu" ? "up to " : ""}$${hourlyCost(c.id, spec).toFixed(4)}`,
125
+ "$/hr": unfit(c.id, size) ?? `${c.billing === "active-cpu" ? "up to " : ""}$${hourlyCost(c.id, size).toFixed(4)}`,
106
126
  plan: c.local ? "none" : planLabel(c.id),
107
127
  configured: router.isConfigured(c.id) ? "yes" : "no",
108
128
  capabilities: c.capabilities.join(", ") || "-",
@@ -110,9 +130,23 @@ async function main() {
110
130
  }));
111
131
  if (values.json)
112
132
  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);
133
+ console.log(`${size.vcpu} vCPU / ${size.memoryGb} GB: estimated list price while running, cheapest first.\n`);
134
+ const cells = listingOrder(size).map((c) => {
135
+ const why = unfit(c.id, size);
136
+ const usd = c.local ? "free" : why ? "skipped" : `${c.billing === "active-cpu" ? "≤ " : ""}$${hourlyCost(c.id, size).toFixed(4)}`;
137
+ const note = [why, accessLabel(c.id), c.local ? "local; only used when named" : null].filter(Boolean).join("; ");
138
+ const name = c.name.length > 30 ? `${c.name.slice(0, 29)}…` : c.name;
139
+ return [c.id, name, usd, c.local ? "none" : planLabel(c.id), router.isConfigured(c.id) ? "set" : "-", c.capabilities.join(", ") || "-", note];
140
+ });
141
+ console.log(textTable(["ID", "PROVIDER", "$/HR", "PLAN", "KEY", "SUPPORTS", "NOTE"], cells));
142
+ console.log([
143
+ "",
144
+ "≤ billed on busy CPU only; the price with every core busy",
145
+ "PLAN monthly plan the API needs before it creates sandboxes (credited = paid back as usage)",
146
+ "KEY set = this provider's env vars are set here",
147
+ "skipped can't take this size. A provider skipped for its default image's size still takes",
148
+ " a create() that names no size, at that image's size.",
149
+ ].join("\n"));
116
150
  return;
117
151
  }
118
152
  if (cmd === "route") {
@@ -181,7 +215,7 @@ async function main() {
181
215
  reports.push(report);
182
216
  if (saveDir) {
183
217
  mkdirSync(saveDir, { recursive: true });
184
- const saved = { tool: "sandboxrouter-check", version, date: new Date().toISOString(), platform: `${platform()}-${arch()}`, ...report };
218
+ const saved = { tool: "sandboxrouter-check", version, date: new Date().toISOString(), platform: `${platform()}-${arch()}`, ...(by ? { by } : {}), ...report };
185
219
  writeFileSync(`${saveDir}/${p}.json`, JSON.stringify(saved, null, 2) + "\n");
186
220
  }
187
221
  }
@@ -242,7 +276,7 @@ async function main() {
242
276
  console.error(`\n${r.provider} last error: ${r.lastError}`);
243
277
  if (values.share) {
244
278
  const version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
245
- const report = makeReport(rows, { version, runs: rows[0].runs, spec, label: values.label });
279
+ const report = makeReport(rows, { version, runs: rows[0].runs, spec, label: values.label, by });
246
280
  const file = values.out ?? `sandboxrouter-bench-${report.date.slice(0, 10)}-${Math.random().toString(36).slice(2, 8)}.json`;
247
281
  writeFileSync(file, JSON.stringify(report, null, 2) + "\n");
248
282
  console.log(`\nwrote ${file}. Add it to results/ in a PR to github.com/gigialc/sandboxrouter to put it on the leaderboard.`);
@@ -262,6 +296,7 @@ async function main() {
262
296
  spec,
263
297
  load: !values["no-load"],
264
298
  version,
299
+ by,
265
300
  onProgress: (m) => console.error(m),
266
301
  });
267
302
  const file = values.out ?? `results/cost/cost-${run.date.slice(0, 16).replace(/[:T]/g, "-")}.json`;
@@ -6,6 +6,8 @@ export interface SavedCheck extends CheckReport {
6
6
  version: string;
7
7
  date: string;
8
8
  platform: string;
9
+ /** GitHub username of whoever ran it, from `--by`. */
10
+ by?: string;
9
11
  }
10
12
  export interface ConformanceRow {
11
13
  provider: ProviderId;
@@ -14,6 +16,8 @@ export interface ConformanceRow {
14
16
  run: null | {
15
17
  date: string;
16
18
  version: string;
19
+ /** GitHub username of whoever ran it, when they gave one. */
20
+ by: string | null;
17
21
  passed: number;
18
22
  /** Steps that ran and failed, with why. */
19
23
  failed: {
@@ -52,6 +52,7 @@ export function conformanceTable(saved = loadConformance()) {
52
52
  run: {
53
53
  date: r.date,
54
54
  version: r.version,
55
+ by: r.by ?? null,
55
56
  passed: pick("pass").length + pick("info").length,
56
57
  failed: pick("fail").map((x) => ({ step: x.step, detail: x.detail })),
57
58
  skipped: pick("skip").map((x) => x.step),
package/dist/cost.d.ts CHANGED
@@ -48,6 +48,8 @@ export interface CostRun {
48
48
  date: string;
49
49
  minutes: number;
50
50
  load: boolean;
51
+ /** GitHub username of whoever ran it, from `--by`. */
52
+ by?: string;
51
53
  entries: CostEntry[];
52
54
  }
53
55
  export declare function costRun(opts: {
@@ -57,6 +59,7 @@ export declare function costRun(opts: {
57
59
  load?: boolean;
58
60
  router?: Router;
59
61
  version: string;
62
+ by?: string;
60
63
  onProgress?: (msg: string) => void;
61
64
  }): Promise<CostRun>;
62
65
  type Reader = (e: CostEntry) => Promise<Omit<Bill, "at">>;
package/dist/cost.js CHANGED
@@ -46,7 +46,7 @@ export async function costRun(opts) {
46
46
  return entry;
47
47
  };
48
48
  const entries = await Promise.all(opts.providers.map(one));
49
- return { tool: "sandboxrouter-cost", version: opts.version, date: new Date().toISOString(), minutes: opts.minutes, load, entries };
49
+ return { tool: "sandboxrouter-cost", version: opts.version, date: new Date().toISOString(), minutes: opts.minutes, load, ...(opts.by ? { by: opts.by } : {}), entries };
50
50
  }
51
51
  async function getJson(url, headers) {
52
52
  const r = await fetch(url, { headers, signal: AbortSignal.timeout(30_000) });
@@ -11,6 +11,8 @@ export interface BenchReport {
11
11
  date: string;
12
12
  /** Free text from the submitter, e.g. "us-east-1 c7g.large" or "Berlin, home fiber". */
13
13
  label?: string;
14
+ /** GitHub username of whoever ran it, from `--by`. */
15
+ by?: string;
14
16
  runs: number;
15
17
  spec: SandboxSpec;
16
18
  node: string;
@@ -22,6 +24,7 @@ export declare function makeReport(rows: BenchRow[], opts: {
22
24
  runs: number;
23
25
  spec: SandboxSpec;
24
26
  label?: string;
27
+ by?: string;
25
28
  }): BenchReport;
26
29
  export declare function loadReports(dir: string): BenchReport[];
27
30
  export interface LeaderRow {
@@ -8,6 +8,7 @@ export function makeReport(rows, opts) {
8
8
  version: opts.version,
9
9
  date: new Date().toISOString(),
10
10
  label: opts.label,
11
+ ...(opts.by ? { by: opts.by } : {}),
11
12
  runs: opts.runs,
12
13
  // Only the shape matters for comparison; never ship env or metadata.
13
14
  spec: { vcpu: opts.spec.vcpu, memoryGb: opts.spec.memoryGb },
@@ -1,6 +1,6 @@
1
1
  import { DEFAULT_MEMORY_GB, DEFAULT_VCPU } from "../catalog.js";
2
2
  import { ProviderError } from "../types.js";
3
- import { DEFAULT_EXEC_TIMEOUT_S, optionalImport, timeoutError, toBytes } from "../util.js";
3
+ import { DEFAULT_EXEC_TIMEOUT_S, errorStatus, optionalImport, timeoutError, toBytes } from "../util.js";
4
4
  /** Blaxel's public base image (the SDK's own default). Set image.blaxel for another image or a `bl deploy` template. */
5
5
  const DEFAULT_IMAGE = "blaxel/base-image:latest";
6
6
  /** Blaxel rejects a max-age ttl under 5 minutes. */
@@ -155,7 +155,7 @@ class BlaxelBox {
155
155
  function wrap(err) {
156
156
  if (err instanceof ProviderError)
157
157
  return err;
158
- const status = typeof err?.status === "number" ? err.status : typeof err?.code === "number" ? err.code : err?.response?.status;
158
+ const status = errorStatus(err) ?? (typeof err?.code === "number" ? err.code : undefined);
159
159
  // The SDK sometimes throws the plain API body ({ code, error }), not an Error.
160
160
  const message = err?.message ?? err?.error ?? (typeof err === "object" ? JSON.stringify(err) : String(err));
161
161
  return new ProviderError("blaxel", message, status);
@@ -1,4 +1,4 @@
1
- import { ProviderError } from "../types.js";
1
+ import { ProviderError, SetupError } from "../types.js";
2
2
  import { dirname, optionalImport, toBytes } from "../util.js";
3
3
  /**
4
4
  * Deno Sandbox (Linux microVMs on Deno Deploy) via the official `@deno/sandbox` SDK. The SDK is
@@ -43,7 +43,7 @@ export class DenoProvider {
43
43
  if (!this.token)
44
44
  throw new ProviderError(ID, "DENO_DEPLOY_TOKEN is not set");
45
45
  if (!this.token.startsWith("ddo_") && !this.org)
46
- throw new ProviderError(ID, "a personal DENO_DEPLOY_TOKEN needs DENO_DEPLOY_ORG");
46
+ throw new SetupError(`${ID}: a personal DENO_DEPLOY_TOKEN needs DENO_DEPLOY_ORG`);
47
47
  return { token: this.token, ...(this.org ? { org: this.org } : {}) };
48
48
  }
49
49
  async create(spec) {
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Checks for the files people submit to results/: bench reports, saved `check` runs and `cost` runs.
3
+ *
4
+ * Submissions are self-reported, so nothing here can prove a number is real. What it can do is keep
5
+ * the obvious problems out before a maintainer reviews the PR: files that aren't one of the three
6
+ * formats, unknown providers, impossible numbers, anything that looks like a key, and labels that
7
+ * look like a home address or an email. CI runs it on every push and pull request
8
+ * (`npm run results:check`), and on a pull request it also refuses edits to someone else's bench
9
+ * report: a new run is a new file.
10
+ */
11
+ export interface Issue {
12
+ file: string;
13
+ problem: string;
14
+ }
15
+ /** A GitHub handle, without the "@": what `--by` accepts and what reports store. */
16
+ export declare function contributor(by?: string): string | undefined;
17
+ /** Problems with one parsed result file. `now` is injectable for tests. */
18
+ export declare function validateResult(j: any, now?: number): string[];
19
+ /** Problems found anywhere in a file's raw text: secrets, emails, IP addresses. */
20
+ export declare function scanText(text: string): string[];
21
+ /**
22
+ * Every problem in a results directory. `changed` is the PR's `git diff --name-status` against its
23
+ * base, when there is one: a modified bench report means someone edited a run, which is refused.
24
+ * Saved checks (one per provider, replaced by each new run) and cost runs (`cost settle` fills in the
25
+ * bill later) may change.
26
+ */
27
+ export declare function validateResults(dir?: string, opts?: {
28
+ changed?: {
29
+ status: string;
30
+ path: string;
31
+ }[];
32
+ now?: number;
33
+ }): Issue[];
@@ -0,0 +1,215 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import { CATALOG } from "./catalog.js";
4
+ import { CHECK_STEPS } from "./check.js";
5
+ /** A GitHub handle, without the "@": what `--by` accepts and what reports store. */
6
+ export function contributor(by) {
7
+ if (by === undefined)
8
+ return undefined;
9
+ const h = by.trim().replace(/^@/, "");
10
+ if (!/^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/.test(h)) {
11
+ throw new Error(`--by takes a GitHub username, e.g. --by octocat (got "${by}")`);
12
+ }
13
+ return h;
14
+ }
15
+ const IDS = new Set(CATALOG.map((c) => c.id));
16
+ const STEPS = new Set(CHECK_STEPS);
17
+ const MAX_MS = 15 * 60_000;
18
+ /** Key and token shapes from the providers here and the usual suspects. Matches print as a prefix only. */
19
+ const SECRET_SHAPES = [
20
+ ["Stripe-style or Sail key", /\bsk_(?:live_|test_)?[A-Za-z0-9]{16,}/],
21
+ ["OpenAI-style key", /\bsk-[A-Za-z0-9_-]{20,}/],
22
+ ["E2B key", /\be2b_[A-Za-z0-9]{20,}/],
23
+ ["Daytona key", /\bdtn_[A-Za-z0-9]{20,}/],
24
+ ["LangSmith key", /\blsv2_(?:sk|pt)_[A-Za-z0-9_]{20,}/],
25
+ ["AWS access key", /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/],
26
+ ["GitHub token", /\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,})/],
27
+ ["Slack token", /\bxox[abprs]-[A-Za-z0-9-]{10,}/],
28
+ ["JWT", /\beyJ[A-Za-z0-9_-]{15,}\.eyJ[A-Za-z0-9_-]{15,}\./],
29
+ ["bearer token", /\bBearer\s+[A-Za-z0-9._~+/-]{20,}/],
30
+ ["private key", /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
31
+ ["key=value secret", /\b[A-Z][A-Z0-9_]*(?:API_KEY|TOKEN|SECRET)["']?\s*[:=]\s*["']?[A-Za-z0-9_\-./+]{16,}/],
32
+ ];
33
+ const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}/;
34
+ const IPV4 = /\b(?:25[0-5]|2[0-4]\d|1?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}\b/;
35
+ /** "12 Main Street", "Calle Mayor 5", "Apt 4B": a street or a flat, which a label never needs. */
36
+ const ADDRESS = /\b\d+[A-Za-z]?\s+(?:[A-Za-zÀ-ÿ'.-]+\s+){0,3}(?:street|st|road|rd|avenue|ave|lane|ln|drive|dr|way|court|ct|place|pl|boulevard|blvd|terrace|close)\b\.?|\b(?:calle|carrer|rue|via|avenida|straße|strasse|str)\.?\s+[A-Za-zÀ-ÿ'.-]+(?:\s+[A-Za-zÀ-ÿ'.-]+){0,3}\s+\d+|\b(?:apt|apartment|flat|suite|unit|piso|puerta)\.?\s*#?\d+/i;
37
+ const isNum = (n, min = 0, max = Infinity) => typeof n === "number" && Number.isFinite(n) && n >= min && n <= max;
38
+ function checkDate(iso, now) {
39
+ if (typeof iso !== "string" || Number.isNaN(Date.parse(iso)))
40
+ return "date is missing or not an ISO date";
41
+ if (Date.parse(iso) > now + 86_400_000)
42
+ return "date is in the future";
43
+ if (Date.parse(iso) < Date.parse("2026-01-01"))
44
+ return "date is before sandboxrouter existed";
45
+ }
46
+ function checkLabel(label) {
47
+ if (label === undefined || label === null)
48
+ return;
49
+ if (typeof label !== "string")
50
+ return "label must be text";
51
+ if (label.length > 80)
52
+ return "label is longer than 80 characters";
53
+ if (EMAIL.test(label))
54
+ return "label contains an email address";
55
+ if (IPV4.test(label))
56
+ return "label contains an IP address";
57
+ if (ADDRESS.test(label))
58
+ return 'label looks like a street address; use a city or cloud region, e.g. "Berlin" or "us-east-1"';
59
+ }
60
+ function checkBy(by) {
61
+ if (by === undefined)
62
+ return;
63
+ try {
64
+ if (typeof by !== "string" || contributor(by) !== by)
65
+ return 'by must be a GitHub username without the "@"';
66
+ }
67
+ catch (e) {
68
+ return e.message;
69
+ }
70
+ }
71
+ /** Problems with one parsed result file. `now` is injectable for tests. */
72
+ export function validateResult(j, now = Date.now()) {
73
+ const out = [];
74
+ const add = (p) => p && out.push(p);
75
+ const provider = (id, where = "provider") => {
76
+ if (typeof id !== "string" || !IDS.has(id))
77
+ out.push(`${where} "${String(id)}" is not in the catalog`);
78
+ };
79
+ if (!j || typeof j !== "object" || Array.isArray(j))
80
+ return ["not a result file: expected a JSON object with a \"tool\" field"];
81
+ if (j.tool === "sandboxrouter") {
82
+ add(checkDate(j.date, now));
83
+ add(checkLabel(j.label));
84
+ add(checkBy(j.by));
85
+ if (!isNum(j.runs, 3, 1000))
86
+ out.push("runs must be at least 3");
87
+ if (!Array.isArray(j.rows) || !j.rows.length)
88
+ out.push("rows is empty");
89
+ for (const r of Array.isArray(j.rows) ? j.rows : []) {
90
+ provider(r?.provider, "row provider");
91
+ if (!isNum(r?.runs, 1, 1000))
92
+ out.push(`${r?.provider}: runs is not a positive number`);
93
+ if (!isNum(r?.failures, 0, r?.runs ?? 0))
94
+ out.push(`${r?.provider}: failures must be between 0 and runs`);
95
+ for (const k of ["medianCreateMs", "medianExecMs"]) {
96
+ if (r?.[k] !== undefined && !isNum(r[k], 1, MAX_MS))
97
+ out.push(`${r?.provider}: ${k} must be between 1 ms and 15 min`);
98
+ }
99
+ if (r?.failures < r?.runs && r?.medianCreateMs === undefined)
100
+ out.push(`${r?.provider}: successful runs but no medianCreateMs`);
101
+ if ("lastError" in (r ?? {}))
102
+ out.push(`${r?.provider}: lastError must not be shared (it can hold account details)`);
103
+ }
104
+ }
105
+ else if (j.tool === "sandboxrouter-check") {
106
+ add(checkDate(j.date, now));
107
+ add(checkBy(j.by));
108
+ provider(j.provider);
109
+ if (!Array.isArray(j.results) || !j.results.length)
110
+ out.push("results is empty");
111
+ for (const r of Array.isArray(j.results) ? j.results : []) {
112
+ if (!STEPS.has(r?.step))
113
+ out.push(`unknown check step "${r?.step}"`);
114
+ if (!["pass", "fail", "skip", "info"].includes(r?.status))
115
+ out.push(`${r?.step}: unknown status "${r?.status}"`);
116
+ if (!isNum(r?.ms, 0, MAX_MS))
117
+ out.push(`${r?.step}: ms must be between 0 and 15 min`);
118
+ }
119
+ }
120
+ else if (j.tool === "sandboxrouter-cost") {
121
+ add(checkDate(j.date, now));
122
+ add(checkBy(j.by));
123
+ if (!isNum(j.minutes, 1, 24 * 60))
124
+ out.push("minutes must be between 1 and 1440");
125
+ if (!Array.isArray(j.entries) || !j.entries.length)
126
+ out.push("entries is empty");
127
+ for (const e of Array.isArray(j.entries) ? j.entries : []) {
128
+ provider(e?.provider, "entry provider");
129
+ if (e?.seconds !== undefined && !isNum(e.seconds, 0, 2 * 24 * 3600))
130
+ out.push(`${e?.provider}: seconds is out of range`);
131
+ if (e?.predictedUsd !== undefined && !isNum(e.predictedUsd, 0, 1000))
132
+ out.push(`${e?.provider}: predictedUsd is out of range`);
133
+ if (e?.bill?.usd !== undefined && !isNum(e.bill.usd, 0, 1000))
134
+ out.push(`${e?.provider}: bill.usd is out of range`);
135
+ if (e?.delivered && !(isNum(e.delivered.vcpu, 0.1, 1024) && isNum(e.delivered.memoryMb, 64, 4_194_304))) {
136
+ out.push(`${e?.provider}: delivered size is out of range`);
137
+ }
138
+ }
139
+ }
140
+ else {
141
+ out.push(`unknown tool "${String(j.tool)}": expected a bench report, a saved check or a cost run`);
142
+ }
143
+ return out;
144
+ }
145
+ /** Problems found anywhere in a file's raw text: secrets, emails, IP addresses. */
146
+ export function scanText(text) {
147
+ const out = [];
148
+ for (const [what, re] of SECRET_SHAPES) {
149
+ const m = text.match(re);
150
+ if (m)
151
+ out.push(`looks like a ${what} ("${m[0].slice(0, 6)}…"); never commit keys`);
152
+ }
153
+ const email = text.match(EMAIL);
154
+ if (email)
155
+ out.push(`contains an email address ("${email[0].replace(/^(.).*@/, "$1…@")}")`);
156
+ const ip = text.match(IPV4);
157
+ if (ip && !/^0\.|^127\./.test(ip[0]))
158
+ out.push(`contains an IP address ("${ip[0]}")`);
159
+ return out;
160
+ }
161
+ /** Files in results/ that aren't submissions: notes and the maintainers' price audits. */
162
+ const NOT_A_SUBMISSION = (rel) => /(^|\/)README\.md$/.test(rel) || /(^|\/)price-audit-[^/]+$/.test(rel);
163
+ function walk(dir) {
164
+ if (!existsSync(dir))
165
+ return [];
166
+ return readdirSync(dir).flatMap((f) => {
167
+ const p = join(dir, f);
168
+ return statSync(p).isDirectory() ? walk(p) : [p];
169
+ });
170
+ }
171
+ /**
172
+ * Every problem in a results directory. `changed` is the PR's `git diff --name-status` against its
173
+ * base, when there is one: a modified bench report means someone edited a run, which is refused.
174
+ * Saved checks (one per provider, replaced by each new run) and cost runs (`cost settle` fills in the
175
+ * bill later) may change.
176
+ */
177
+ export function validateResults(dir = "results", opts = {}) {
178
+ const issues = [];
179
+ for (const path of walk(dir)) {
180
+ const rel = relative(".", path);
181
+ if (NOT_A_SUBMISSION(relative(dir, path)))
182
+ continue;
183
+ if (path.endsWith(".DS_Store"))
184
+ continue;
185
+ if (!path.endsWith(".json")) {
186
+ issues.push({ file: rel, problem: "only .json result files belong here" });
187
+ continue;
188
+ }
189
+ const text = readFileSync(path, "utf8");
190
+ for (const problem of scanText(text))
191
+ issues.push({ file: rel, problem });
192
+ let j;
193
+ try {
194
+ j = JSON.parse(text);
195
+ }
196
+ catch {
197
+ issues.push({ file: rel, problem: "is not valid JSON" });
198
+ continue;
199
+ }
200
+ for (const problem of validateResult(j, opts.now))
201
+ issues.push({ file: rel, problem });
202
+ }
203
+ for (const c of opts.changed ?? []) {
204
+ const p = c.path;
205
+ if (!p.startsWith(`${dir}/`) || NOT_A_SUBMISSION(p.slice(dir.length + 1)))
206
+ continue;
207
+ if (p.startsWith(`${dir}/conformance/`) || p.startsWith(`${dir}/cost/`))
208
+ continue;
209
+ if (/^[MR]/.test(c.status))
210
+ issues.push({ file: p, problem: "edits an existing bench report; add your run as a new file instead" });
211
+ if (c.status.startsWith("D"))
212
+ issues.push({ file: p, problem: "deletes a bench report; reports are only removed by a maintainer" });
213
+ }
214
+ return issues;
215
+ }
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
@@ -2,7 +2,7 @@ import { CATALOG, accessLabel, hourlyCost, unfit } from "./catalog.js";
2
2
  import { RoutedComputer, createComputer, planComputer, } from "./computers.js";
3
3
  import { loadBench, median } from "./bench-store.js";
4
4
  import { Ledger, defaultLedgerFile } from "./ledger.js";
5
- import { DEFAULT_EXEC_TIMEOUT_S, timeoutError } from "./util.js";
5
+ import { DEFAULT_EXEC_TIMEOUT_S, errorStatus, timeoutError } from "./util.js";
6
6
  import { DaytonaProvider } from "./providers/daytona.js";
7
7
  import { E2BProvider } from "./providers/e2b.js";
8
8
  import { MaritimeProvider } from "./providers/maritime.js";
@@ -41,7 +41,7 @@ import { OrgoProvider } from "./providers/orgo.js";
41
41
  import { MobilerunProvider } from "./providers/mobilerun.js";
42
42
  import { LimrunProvider } from "./providers/limrun.js";
43
43
  import { WorkspacesAgentProvider } from "./providers/workspaces.js";
44
- import { ProviderError, RouterError, } from "./types.js";
44
+ import { ProviderError, SetupError, RouterError, } from "./types.js";
45
45
  /** Desktop-only providers. Filled in as adapters are added. */
46
46
  export function defaultComputerProviders() {
47
47
  return [new OrgoProvider(), new MobilerunProvider(), new LimrunProvider(), new WorkspacesAgentProvider()];
@@ -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;
@@ -181,9 +207,11 @@ export class Router {
181
207
  if (timedOut)
182
208
  pending.then((late) => this.discard(c.provider, late, c.estimatedHourlyUsd, spec.timeoutMinutes), () => { });
183
209
  attempts.push({ provider: c.provider, ok: false, ms: Date.now() - t0, error: err.message });
184
- const status = err instanceof ProviderError ? err.status : undefined;
185
- if (route.fallback !== "any" && status !== undefined && SETUP_ERRORS.has(status)) {
186
- throw new RouterError(`${err.message.replace(/[.\s]+$/, "")}. This looks like a setup problem with ${c.provider} (key, billing, image or request), ` +
210
+ const status = errorStatus(err);
211
+ const setup = err instanceof SetupError || (status !== undefined && SETUP_ERRORS.has(status));
212
+ if (route.fallback !== "any" && setup) {
213
+ const msg = err.message.replace(/[.\s]+$/, "");
214
+ throw new RouterError(`${msg.startsWith(c.provider) ? msg : `${c.provider}: ${msg}`}. This looks like a setup problem with ${c.provider} (key, billing, image, request or a missing package), ` +
187
215
  `so the router stopped instead of moving the work to another provider; pass fallback: "any" to fall back on every error`, attempts);
188
216
  }
189
217
  }
package/dist/types.d.ts CHANGED
@@ -93,3 +93,10 @@ export declare class ProviderError extends Error {
93
93
  readonly status?: number | undefined;
94
94
  constructor(provider: ProviderId | (string & {}), message: string, status?: number | undefined);
95
95
  }
96
+ /**
97
+ * A problem with local setup, such as a provider SDK that isn't installed or a missing setting:
98
+ * something to fix here, not a provider being down. The router stops on it instead of falling back.
99
+ */
100
+ export declare class SetupError extends Error {
101
+ constructor(message: string);
102
+ }
package/dist/types.js CHANGED
@@ -17,3 +17,13 @@ export class ProviderError extends Error {
17
17
  this.name = "ProviderError";
18
18
  }
19
19
  }
20
+ /**
21
+ * A problem with local setup, such as a provider SDK that isn't installed or a missing setting:
22
+ * something to fix here, not a provider being down. The router stops on it instead of falling back.
23
+ */
24
+ export class SetupError extends Error {
25
+ constructor(message) {
26
+ super(message);
27
+ this.name = "SetupError";
28
+ }
29
+ }
package/dist/util.d.ts CHANGED
@@ -36,3 +36,10 @@ export declare function toBytes(content: string | Uint8Array): Uint8Array;
36
36
  export declare function dirname(path: string): string;
37
37
  /** Load an optional peer dependency with a helpful error. */
38
38
  export declare function optionalImport<T>(pkg: string, provider: string): Promise<T>;
39
+ /**
40
+ * The HTTP status an error stands for, read from the shapes provider SDKs use: `status`,
41
+ * `statusCode` (Daytona), `status_code` (Blaxel), `response.status` (Vercel), AWS's
42
+ * `$metadata.httpStatusCode`, gRPC codes (Modal), and error classes that only say it by name
43
+ * (E2B's AuthenticationError, AWS's CredentialsProviderError). Undefined when there's none.
44
+ */
45
+ export declare function errorStatus(err: unknown, depth?: number): number | undefined;
package/dist/util.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ProviderError } from "./types.js";
1
+ import { ProviderError, SetupError } from "./types.js";
2
2
  /** Per-command timeout when ExecOptions.timeoutSeconds is not given. */
3
3
  export const DEFAULT_EXEC_TIMEOUT_S = 60;
4
4
  /** Single-quote a string for POSIX sh. */
@@ -79,8 +79,34 @@ export async function optionalImport(pkg, provider) {
79
79
  // Bundlers and test runners (Vite, esbuild) report a missing package without Node's error code.
80
80
  const missing = /Cannot find (module|package)|Could not resolve|Failed to (resolve|load url)/.test(String(err?.message));
81
81
  if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND" || missing) {
82
- throw new Error(`${provider} needs the "${pkg}" package: npm i ${pkg}`);
82
+ throw new SetupError(`${provider} needs the "${pkg}" package: npm i ${pkg}`);
83
83
  }
84
84
  throw err;
85
85
  }
86
86
  }
87
+ /** gRPC status codes (Modal's SDK) as the HTTP status they correspond to. */
88
+ const GRPC_TO_HTTP = { 3: 400, 5: 404, 7: 403, 8: 429, 14: 503, 16: 401 };
89
+ /**
90
+ * The HTTP status an error stands for, read from the shapes provider SDKs use: `status`,
91
+ * `statusCode` (Daytona), `status_code` (Blaxel), `response.status` (Vercel), AWS's
92
+ * `$metadata.httpStatusCode`, gRPC codes (Modal), and error classes that only say it by name
93
+ * (E2B's AuthenticationError, AWS's CredentialsProviderError). Undefined when there's none.
94
+ */
95
+ export function errorStatus(err, depth = 0) {
96
+ if (!err || typeof err !== "object" || depth > 3)
97
+ return undefined;
98
+ const e = err;
99
+ for (const v of [e.status, e.statusCode, e.status_code, e.response?.status, e.$metadata?.httpStatusCode])
100
+ if (typeof v === "number" && v >= 100 && v < 600)
101
+ return v;
102
+ if (e.name === "ClientError" && typeof e.code === "number" && GRPC_TO_HTTP[e.code])
103
+ return GRPC_TO_HTTP[e.code];
104
+ const name = String(e.name && e.name !== "Error" ? e.name : (e.constructor?.name ?? ""));
105
+ if (/AuthenticationError$/.test(name) || name === "CredentialsProviderError")
106
+ return 401;
107
+ if (/PermissionDenied|ForbiddenError$/.test(name))
108
+ return 403;
109
+ if (/RateLimitError$/.test(name))
110
+ return 429;
111
+ return errorStatus(e.cause, depth + 1);
112
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sandboxrouter/sdk",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
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",