@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 +10 -7
- package/dist/catalog.js +5 -3
- package/dist/cli.js +47 -12
- package/dist/conformance.d.ts +4 -0
- package/dist/conformance.js +1 -0
- package/dist/cost.d.ts +3 -0
- package/dist/cost.js +1 -1
- package/dist/leaderboard.d.ts +3 -0
- package/dist/leaderboard.js +1 -0
- package/dist/providers/blaxel.js +2 -2
- package/dist/providers/deno.js +2 -2
- package/dist/results.d.ts +33 -0
- package/dist/results.js +215 -0
- package/dist/router.d.ts +5 -0
- package/dist/router.js +37 -9
- package/dist/types.d.ts +7 -0
- package/dist/types.js +10 -0
- package/dist/util.d.ts +7 -0
- package/dist/util.js +28 -2
- package/package.json +3 -2
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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 ??
|
|
751
|
-
return `default
|
|
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
|
-
|
|
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,
|
|
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(
|
|
114
|
-
|
|
115
|
-
|
|
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`;
|
package/dist/conformance.d.ts
CHANGED
|
@@ -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: {
|
package/dist/conformance.js
CHANGED
|
@@ -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) });
|
package/dist/leaderboard.d.ts
CHANGED
|
@@ -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 {
|
package/dist/leaderboard.js
CHANGED
|
@@ -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 },
|
package/dist/providers/blaxel.js
CHANGED
|
@@ -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 =
|
|
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);
|
package/dist/providers/deno.js
CHANGED
|
@@ -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
|
|
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[];
|
package/dist/results.js
ADDED
|
@@ -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
|
-
|
|
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
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
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.
|
|
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",
|