@sandboxrouter/sdk 0.3.1 → 0.3.3
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 +19 -18
- package/dist/catalog.d.ts +13 -0
- package/dist/catalog.js +14 -0
- package/dist/cli.js +28 -8
- package/dist/conformance.d.ts +4 -0
- package/dist/conformance.js +1 -0
- package/dist/cost.d.ts +10 -0
- package/dist/cost.js +14 -2
- package/dist/leaderboard.d.ts +3 -0
- package/dist/leaderboard.js +4 -2
- package/dist/providers/blaxel.js +2 -2
- package/dist/providers/deno.js +2 -2
- package/dist/results.d.ts +45 -0
- package/dist/results.js +260 -0
- package/dist/router.js +13 -7
- package/dist/types.d.ts +11 -3
- package/dist/types.js +10 -0
- package/dist/util.d.ts +7 -0
- package/dist/util.js +28 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -66,8 +66,8 @@ How to read it:
|
|
|
66
66
|
|---|---|---|---|---|---|
|
|
67
67
|
| Agent37 | $0.007 | none | not run yet | Whole-minute billing: monthly shape total / 730 / 60; running estimate includes default disk (2 vCPU / 4 GB / 4 GB disk = $4.76/mo); sleeping/stopped disk $0.09/GB-mo; gVisor; $5 minimum top-up, wallet must hold one running day | [pricing](https://www.agent37.com/docs/agents-api/billing) |
|
|
68
68
|
| Boat | $0.018 | $20/mo (credited) | not run yet | Fixed sizes billed per second ($0.018/h for 2 vCPU / 4 GB up to $0.20/h for 16 / 32); needs a plan from $20/mo, paid back as VM time | [pricing](https://docs.boat.dev/pricing.md) |
|
|
69
|
-
| Maritime | $0.022 | $20/mo (credited) | passed (2026-11-
|
|
70
|
-
| Sailboxes (Sail Research) | up to $0.062 | none | passed (2026-11-
|
|
69
|
+
| Maritime | $0.022 | $20/mo (credited) | passed (2026-11-07) | Per second, no minimum runtime; running disk included; Starter $20/mo incl. $20 usage; paused disk $0.08/GB-month (default idle action deletes sandbox) | [pricing](https://maritime.sh/pricing) |
|
|
70
|
+
| Sailboxes (Sail Research) | up to $0.062 | none | passed (2026-11-07) | Per second on what the box uses (CPU time, RAM it holds, disk $0.0007/GiB-h), billed per minute; RAM counts the VM's file cache, up to the 16 GiB minimum limit, so file-heavy work can hold more than you asked for; paused/sleeping free; creation $0.01 at 2-4 vCPU on the free plan ($5/mo credit with a card), waived on Pro $250/mo | [pricing](https://docs.sailresearch.com/sailboxes-pricing.md) |
|
|
71
71
|
| Northflank | $0.067 | none | not run yet | Per second on Northflank Cloud, ephemeral disk included; sandboxes run as deployment services (microVMs) | [pricing](https://northflank.com/pricing) |
|
|
72
72
|
| Morph Cloud | $0.100 | none | not run yet | $0.05/MCU; MCU-hours = max(vCPU, RAM/4 GB, disk/16 GB); snapshots extra | [pricing](https://cloud.morph.so/web/pricing) |
|
|
73
73
|
| exe.dev | $0.105 | $15/mo | not run yet | Standalone reserved size, per second: $0.105/h per 2 vCPU / 4 GiB unit; stopped CPU/RAM free, retained disk separate; Personal from $15/mo (up to 16 vCPU / 32 GiB), Work $75/mo (32 / 64 without approval) | [pricing](https://exe.dev/docs/standalone-vms.md) |
|
|
@@ -76,10 +76,9 @@ How to read it:
|
|
|
76
76
|
| Beam | $0.140 | $20/mo (credited) | not run yet | Per second, Performance tier (sandbox default); Developer plan $20/mo incl. $20 credits; GPUs billed separately | [pricing](https://www.beam.cloud/pricing) |
|
|
77
77
|
| CodeSandbox SDK (Together) | $0.149 | none | not run yet | Fixed tiers: Pico 2 vCPU / 1 GB $0.0743/h, Nano 2 / 4 $0.1486/h, up to 64 / 128 $4.7552/h; credits $0.01486, billed per started minute; Build free, 10 concurrent; Scale $170/mo incl. 1,100 credits | [pricing](https://docs.together.ai/docs/together-code-sandbox-legacy) |
|
|
78
78
|
| Fly.io Sprites | up to $0.164 | none | not run yet | Bills actual CPU and memory use; this is the full-use price; storage extra | [pricing](https://fly.io/sprites) |
|
|
79
|
-
| Blaxel | $0.166 | none | passed (2026-11-
|
|
79
|
+
| Blaxel | $0.166 | none | passed (2026-11-07) | Billed on memory only ($0.0000115/GB-s), CPU scales with memory (~2 GB per core) | [pricing](https://blaxel.ai/pricing) |
|
|
80
80
|
| HOPX | $0.166 | none | not run yet | Per second, plus storage; size comes from the template, not the request | [pricing](https://hopx.ai/pricing) |
|
|
81
81
|
| Superserve | $0.166 | maybe $20/mo (credited) | not run yet | Per second active; superserve/base fixed at 2 vCPU / 4 GiB / 8 GiB disk; retained storage $0.000108/GiB-h (shared template plus changed blocks); Developer $20/mo incl. $20, max 4/16 with team templates; recurring Free allowance not published on pricing page | [pricing](https://superserve.ai/pricing) |
|
|
82
|
-
| LangSmith Sandboxes | $0.189 | none | not run yet | Per second; memory must be 2 to 6 GiB per vCPU; storage $0.000123/GiB-h; 8 LSU ($8) of sandbox usage a month included; Developer plan max 10 sandboxes | [pricing](https://www.langchain.com/pricing) |
|
|
83
82
|
| Tensorlake | up to $0.200 | none | not run yet | Bills active CPU (floor 20% of allocation); this is the full-use price; Pro plan is lower | [pricing](https://www.tensorlake.ai/pricing) |
|
|
84
83
|
| Upstash Box | up to $0.200 | none | not run yet | Billed per active CPU core-hour only (memory and idle time free); estimate assumes every core of the box busy; fixed sizes 2/4, 4/8, 8/16 | [pricing](https://upstash.com/pricing/box) |
|
|
85
84
|
| Cloudflare Sandbox | up to $0.216 | $5/mo | not run yet | 10 ms billing on active CPU and allocated instance memory; full-CPU estimate excludes allocated disk ($0.000252/GB-h), Worker and Durable Objects charges; needs Workers Paid ($5/mo) and bridges/cloudflare | [pricing](https://developers.cloudflare.com/containers/platform/pricing/) |
|
|
@@ -92,12 +91,13 @@ How to read it:
|
|
|
92
91
|
| 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) |
|
|
93
92
|
| 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) |
|
|
94
93
|
| 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) |
|
|
95
|
-
|
|
|
94
|
+
| LangSmith Sandboxes (sign-up waitlisted) | $0.189 | none | not run yet | Per second; memory must be 2 to 6 GiB per vCPU; storage $0.000123/GiB-h; 8 LSU ($8) of sandbox usage a month included; Developer plan max 10 sandboxes | [pricing](https://www.langchain.com/pricing) |
|
|
95
|
+
| OpenScale Sandboxes | skipped (default image is 2 vCPU / 1 GB; a bigger size needs image.openscale) | none | passed (2026-11-07) | 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) |
|
|
96
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) |
|
|
97
|
-
| Daytona | skipped (default image is 1 vCPU / 1 GB; a bigger size needs image.daytona) | none |
|
|
97
|
+
| Daytona | skipped (default image is 1 vCPU / 1 GB; a bigger size needs image.daytona) | none | passed (2026-11-07) | Per second, plus disk above 5 GiB | [pricing](https://www.daytona.io/pricing) |
|
|
98
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) |
|
|
99
|
-
| E2B | skipped (default image is 2 vCPU / 0.5 GB; a bigger size needs image.e2b) | none | passed (2026-11-
|
|
100
|
-
| Docker (local) | free | none | passed (2026-11-
|
|
99
|
+
| E2B | skipped (default image is 2 vCPU / 0.5 GB; a bigger size needs image.e2b) | none | passed (2026-11-07) | 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) |
|
|
100
|
+
| Docker (local) | free | none | passed (2026-11-07) | Runs on your own machine; free, but shares your kernel, so weaker isolation than a cloud micro-VM | [pricing](https://docs.docker.com/engine/) |
|
|
101
101
|
|
|
102
102
|
Checked 2026-11-04 to 2026-11-06.
|
|
103
103
|
|
|
@@ -185,8 +185,9 @@ const c = await router.create({}, { provider: ["e2b", "daytona"], fallback: fals
|
|
|
185
185
|
|
|
186
186
|
// Fallback moves on when a provider is down: a timeout, a network error, 429 or 5xx, and a create
|
|
187
187
|
// that takes longer than createTimeoutSeconds (default 120; a sandbox that turns up later is
|
|
188
|
-
// killed, or left in the ledger for `gc`)
|
|
189
|
-
//
|
|
188
|
+
// killed, or left in the ledger for `gc`), and when an account can't pay (402: no balance, plan
|
|
189
|
+
// needed). A setup error (400, 401, 403, 404, 422: a bad key, a bad image or request) stops instead,
|
|
190
|
+
// so a mistake isn't hidden by a pricier provider. Every failed attempt is on sbx.attempts.
|
|
190
191
|
// fallback: "any" falls back on every error.
|
|
191
192
|
const e = await router.create({}, { fallback: "any", createTimeoutSeconds: 300 });
|
|
192
193
|
|
|
@@ -306,8 +307,8 @@ It prints one line per step and exits 1 if any fails. `checkProvider(router, id)
|
|
|
306
307
|
|---|---|---|
|
|
307
308
|
| Agent37 | not run yet | |
|
|
308
309
|
| Boat | not run yet | |
|
|
309
|
-
| Maritime | 14/14 passed | 2026-11-
|
|
310
|
-
| Sailboxes (Sail Research) | 14/14 passed | 2026-11-
|
|
310
|
+
| Maritime | 14/14 passed | 2026-11-07, v0.3.2 |
|
|
311
|
+
| Sailboxes (Sail Research) | 14/14 passed | 2026-11-07, v0.3.2 |
|
|
311
312
|
| Northflank | not run yet | |
|
|
312
313
|
| Morph Cloud | not run yet | |
|
|
313
314
|
| exe.dev | not run yet | |
|
|
@@ -316,10 +317,9 @@ It prints one line per step and exits 1 if any fails. `checkProvider(router, id)
|
|
|
316
317
|
| Beam | not run yet | |
|
|
317
318
|
| CodeSandbox SDK (Together) | not run yet | |
|
|
318
319
|
| Fly.io Sprites | not run yet | |
|
|
319
|
-
| Blaxel | 13/13 passed; n/a: pause and resume | 2026-11-
|
|
320
|
+
| Blaxel | 13/13 passed; n/a: pause and resume | 2026-11-07, v0.3.2 |
|
|
320
321
|
| HOPX | not run yet | |
|
|
321
322
|
| Superserve | not run yet | |
|
|
322
|
-
| LangSmith Sandboxes | not run yet | |
|
|
323
323
|
| Tensorlake | not run yet | |
|
|
324
324
|
| Upstash Box | not run yet | |
|
|
325
325
|
| Cloudflare Sandbox | not run yet | |
|
|
@@ -332,12 +332,13 @@ It prints one line per step and exits 1 if any fails. `checkProvider(router, id)
|
|
|
332
332
|
| InstaBox | not run yet | |
|
|
333
333
|
| Koyeb Sandboxes | not run yet | |
|
|
334
334
|
| Dedalus Machines | not run yet | |
|
|
335
|
-
|
|
|
335
|
+
| LangSmith Sandboxes | not run yet | |
|
|
336
|
+
| OpenScale Sandboxes | 14/14 passed | 2026-11-07, v0.3.2 |
|
|
336
337
|
| Azure Container Apps dynamic sessions | not run yet | |
|
|
337
|
-
| Daytona |
|
|
338
|
+
| Daytona | 14/14 passed | 2026-11-07, v0.3.2 |
|
|
338
339
|
| Alibaba Cloud Agent Sandbox | not run yet | |
|
|
339
|
-
| E2B | 14/14 passed | 2026-11-
|
|
340
|
-
| Docker (local) | 14/14 passed | 2026-11-
|
|
340
|
+
| E2B | 14/14 passed | 2026-11-07, v0.3.2 |
|
|
341
|
+
| Docker (local) | 14/14 passed | 2026-11-07, v0.3.2 |
|
|
341
342
|
|
|
342
343
|
## HTTP API
|
|
343
344
|
|
package/dist/catalog.d.ts
CHANGED
|
@@ -58,7 +58,20 @@ export interface ProviderInfo {
|
|
|
58
58
|
note: string;
|
|
59
59
|
checked: string;
|
|
60
60
|
};
|
|
61
|
+
/**
|
|
62
|
+
* Set when the provider's terms forbid publishing benchmarks or performance information about it.
|
|
63
|
+
* Its cold starts, success rate, measured size and real bills are then left out of shared reports,
|
|
64
|
+
* results/ and the website, and saved checks keep pass/fail without timings. List prices (public
|
|
65
|
+
* pricing pages) and the adapter itself are unaffected.
|
|
66
|
+
*/
|
|
67
|
+
noBenchmarks?: {
|
|
68
|
+
clause: string;
|
|
69
|
+
source: string;
|
|
70
|
+
checked: string;
|
|
71
|
+
};
|
|
61
72
|
}
|
|
73
|
+
/** False when the provider's terms forbid publishing its benchmarks (see `noBenchmarks`). */
|
|
74
|
+
export declare function benchmarksPublishable(id: string): boolean;
|
|
62
75
|
/** Short label for a gated provider's sign-up status, or null when anyone can sign up. */
|
|
63
76
|
export declare function accessLabel(id: ProviderId): string | null;
|
|
64
77
|
/** Short label for the plan a provider needs before you can use it: "none", "$20/mo (credited)", "$5/mo". */
|
package/dist/catalog.js
CHANGED
|
@@ -3,6 +3,10 @@ import { AGENT37_SHAPES, agent37Shape } from "./providers/agent37.js";
|
|
|
3
3
|
import { INSTABOX_SIZES, instaboxSize } from "./providers/instabox.js";
|
|
4
4
|
import { FREESTYLE_SIZES, freestyleSize } from "./providers/freestyle.js";
|
|
5
5
|
import { langsmithSize } from "./providers/langsmith.js";
|
|
6
|
+
/** False when the provider's terms forbid publishing its benchmarks (see `noBenchmarks`). */
|
|
7
|
+
export function benchmarksPublishable(id) {
|
|
8
|
+
return !CATALOG.find((c) => c.id === id)?.noBenchmarks;
|
|
9
|
+
}
|
|
6
10
|
/** Short label for a gated provider's sign-up status, or null when anyone can sign up. */
|
|
7
11
|
export function accessLabel(id) {
|
|
8
12
|
const a = CATALOG.find((c) => c.id === id)?.access;
|
|
@@ -172,6 +176,11 @@ const ENTRIES = [
|
|
|
172
176
|
limits: { maxVcpu: 8, maxMemoryGb: 16 },
|
|
173
177
|
credentials: [["BL_API_KEY", "BL_WORKSPACE"]],
|
|
174
178
|
docs: "https://docs.blaxel.ai/Sandboxes/Overview",
|
|
179
|
+
noBenchmarks: {
|
|
180
|
+
clause: "Blaxel's terms (§2.6(f)) don't allow publishing benchmarks or performance information about it",
|
|
181
|
+
source: "https://blaxel.ai/legal/terms/2025-12-10-blaxel-terms-and-conditions.pdf",
|
|
182
|
+
checked: "2026-11-07",
|
|
183
|
+
},
|
|
175
184
|
},
|
|
176
185
|
{
|
|
177
186
|
id: "tensorlake",
|
|
@@ -524,6 +533,11 @@ ENTRIES.push({
|
|
|
524
533
|
limits: { maxVcpu: 16, maxMemoryGb: 96 },
|
|
525
534
|
credentials: [["LANGSMITH_API_KEY"]],
|
|
526
535
|
docs: "https://docs.langchain.com/langsmith/sandboxes",
|
|
536
|
+
access: {
|
|
537
|
+
status: "waitlist",
|
|
538
|
+
note: "Sandboxes are a private preview: until LangChain enables them for your organization, the API answers \"Sandbox feature is not enabled for this organization\"",
|
|
539
|
+
checked: "2026-11-07",
|
|
540
|
+
},
|
|
527
541
|
}, {
|
|
528
542
|
id: "novita",
|
|
529
543
|
name: "Novita AI Agent Sandbox",
|
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, publishableCheck } from "./results.js";
|
|
9
10
|
import { loadReports, makeReport, renderLeaderboard } from "./leaderboard.js";
|
|
10
|
-
import { DEFAULT_MEMORY_GB, DEFAULT_VCPU, accessLabel, hourlyCost, listingOrder, planLabel, unfit } from "./catalog.js";
|
|
11
|
+
import { DEFAULT_MEMORY_GB, DEFAULT_VCPU, accessLabel, benchmarksPublishable, 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,6 +77,7 @@ 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);
|
|
@@ -92,6 +95,15 @@ const spec = {
|
|
|
92
95
|
timeoutMinutes: num(values.timeout),
|
|
93
96
|
};
|
|
94
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
|
+
})();
|
|
95
107
|
const route = {
|
|
96
108
|
provider: providers,
|
|
97
109
|
strategy: values.strategy,
|
|
@@ -203,7 +215,7 @@ async function main() {
|
|
|
203
215
|
reports.push(report);
|
|
204
216
|
if (saveDir) {
|
|
205
217
|
mkdirSync(saveDir, { recursive: true });
|
|
206
|
-
const saved = { tool: "sandboxrouter-check", version, date: new Date().toISOString(), platform: `${platform()}-${arch()}`, ...report };
|
|
218
|
+
const saved = publishableCheck({ tool: "sandboxrouter-check", version, date: new Date().toISOString(), platform: `${platform()}-${arch()}`, ...(by ? { by } : {}), ...report });
|
|
207
219
|
writeFileSync(`${saveDir}/${p}.json`, JSON.stringify(saved, null, 2) + "\n");
|
|
208
220
|
}
|
|
209
221
|
}
|
|
@@ -264,7 +276,10 @@ async function main() {
|
|
|
264
276
|
console.error(`\n${r.provider} last error: ${r.lastError}`);
|
|
265
277
|
if (values.share) {
|
|
266
278
|
const version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
267
|
-
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 });
|
|
280
|
+
const left = rows.filter((r) => !benchmarksPublishable(r.provider)).map((r) => r.provider);
|
|
281
|
+
if (left.length)
|
|
282
|
+
console.log(`\nleft out of the shared report: ${left.join(", ")} (their terms don't allow publishing benchmarks)`);
|
|
268
283
|
const file = values.out ?? `sandboxrouter-bench-${report.date.slice(0, 10)}-${Math.random().toString(36).slice(2, 8)}.json`;
|
|
269
284
|
writeFileSync(file, JSON.stringify(report, null, 2) + "\n");
|
|
270
285
|
console.log(`\nwrote ${file}. Add it to results/ in a PR to github.com/gigialc/sandboxrouter to put it on the leaderboard.`);
|
|
@@ -284,11 +299,16 @@ async function main() {
|
|
|
284
299
|
spec,
|
|
285
300
|
load: !values["no-load"],
|
|
286
301
|
version,
|
|
302
|
+
by,
|
|
287
303
|
onProgress: (m) => console.error(m),
|
|
288
304
|
});
|
|
289
305
|
const file = values.out ?? `results/cost/cost-${run.date.slice(0, 16).replace(/[:T]/g, "-")}.json`;
|
|
290
306
|
mkdirSync(file.replace(/\/[^/]*$/, "") || ".", { recursive: true });
|
|
291
|
-
|
|
307
|
+
const settled = await settle(run);
|
|
308
|
+
const left = settled.entries.filter((e) => !benchmarksPublishable(e.provider)).map((e) => e.provider);
|
|
309
|
+
writeFileSync(file, JSON.stringify({ ...settled, entries: settled.entries.filter((e) => benchmarksPublishable(e.provider)) }, null, 2) + "\n");
|
|
310
|
+
if (left.length)
|
|
311
|
+
console.log(`left out of ${file}: ${left.join(", ")} (their terms don't allow publishing benchmarks; the table above is the only copy)`);
|
|
292
312
|
console.log(costMarkdown(run));
|
|
293
313
|
console.log(`\nwrote ${file}. Bills post late on some providers: run "sandboxrouter cost settle ${file}" again later.`);
|
|
294
314
|
return;
|
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,8 +48,17 @@ 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
|
}
|
|
55
|
+
/**
|
|
56
|
+
* The size the sandbox really has, as "<vcpus> <memoryMb>". nproc and MemTotal can show the whole host
|
|
57
|
+
* (Daytona's containers report 64 CPUs and 755 GB while limited to 1 vCPU and 1 GB), so the cgroup limits
|
|
58
|
+
* win when they're set: cgroup v2 cpu.max / memory.max, or v1 cfs quota / memory limit. vCPUs can be
|
|
59
|
+
* fractional (a 50% quota is 0.5).
|
|
60
|
+
*/
|
|
61
|
+
export declare const PROBE: string;
|
|
53
62
|
export declare function costRun(opts: {
|
|
54
63
|
providers: ProviderId[];
|
|
55
64
|
minutes: number;
|
|
@@ -57,6 +66,7 @@ export declare function costRun(opts: {
|
|
|
57
66
|
load?: boolean;
|
|
58
67
|
router?: Router;
|
|
59
68
|
version: string;
|
|
69
|
+
by?: string;
|
|
60
70
|
onProgress?: (msg: string) => void;
|
|
61
71
|
}): Promise<CostRun>;
|
|
62
72
|
type Reader = (e: CostEntry) => Promise<Omit<Bill, "at">>;
|
package/dist/cost.js
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { fittingSpec, hourlyCost } from "./catalog.js";
|
|
2
2
|
import { Router } from "./router.js";
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* The size the sandbox really has, as "<vcpus> <memoryMb>". nproc and MemTotal can show the whole host
|
|
5
|
+
* (Daytona's containers report 64 CPUs and 755 GB while limited to 1 vCPU and 1 GB), so the cgroup limits
|
|
6
|
+
* win when they're set: cgroup v2 cpu.max / memory.max, or v1 cfs quota / memory limit. vCPUs can be
|
|
7
|
+
* fractional (a 50% quota is 0.5).
|
|
8
|
+
*/
|
|
9
|
+
export const PROBE = [
|
|
10
|
+
`n=$(nproc); m=$(awk '/MemTotal/{printf "%d", $2/1024}' /proc/meminfo)`,
|
|
11
|
+
`q=; p=; l=`,
|
|
12
|
+
`if [ -r /sys/fs/cgroup/cpu.max ]; then read q p < /sys/fs/cgroup/cpu.max; elif [ -r /sys/fs/cgroup/cpu/cpu.cfs_quota_us ]; then q=$(cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us); p=$(cat /sys/fs/cgroup/cpu/cpu.cfs_period_us); fi`,
|
|
13
|
+
`if [ -r /sys/fs/cgroup/memory.max ]; then l=$(cat /sys/fs/cgroup/memory.max); elif [ -r /sys/fs/cgroup/memory/memory.limit_in_bytes ]; then l=$(cat /sys/fs/cgroup/memory/memory.limit_in_bytes); fi`,
|
|
14
|
+
`awk -v n="$n" -v m="$m" -v q="$q" -v p="$p" -v l="$l" 'BEGIN { c = n; if (q ~ /^[0-9]+$/ && p ~ /^[0-9]+$/ && p > 0 && q / p < c) c = q / p; if (l ~ /^[0-9]+$/ && l / 1048576 < m) m = int(l / 1048576); printf "%s %d\\n", (c == int(c) ? c : sprintf("%.2f", c)), m }'`,
|
|
15
|
+
].join("; ");
|
|
4
16
|
// One `yes` per core, detached so the exec returns; they die with the sandbox.
|
|
5
17
|
const LOAD = `nohup sh -c 'for i in $(seq $(nproc)); do yes > /dev/null & done; wait' > /dev/null 2>&1 &`;
|
|
6
18
|
const round = (n, d = 6) => Math.round(n * 10 ** d) / 10 ** d;
|
|
@@ -46,7 +58,7 @@ export async function costRun(opts) {
|
|
|
46
58
|
return entry;
|
|
47
59
|
};
|
|
48
60
|
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 };
|
|
61
|
+
return { tool: "sandboxrouter-cost", version: opts.version, date: new Date().toISOString(), minutes: opts.minutes, load, ...(opts.by ? { by: opts.by } : {}), entries };
|
|
50
62
|
}
|
|
51
63
|
async function getJson(url, headers) {
|
|
52
64
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { CATALOG, fittingSpec, hourlyCost } from "./catalog.js";
|
|
3
|
+
import { benchmarksPublishable, CATALOG, fittingSpec, hourlyCost } from "./catalog.js";
|
|
4
4
|
import { median } from "./bench-store.js";
|
|
5
5
|
export function makeReport(rows, opts) {
|
|
6
6
|
return {
|
|
@@ -8,12 +8,14 @@ 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 },
|
|
14
15
|
node: process.version,
|
|
15
16
|
platform: `${process.platform}-${process.arch}`,
|
|
16
|
-
|
|
17
|
+
// Providers whose terms forbid publishing benchmarks stay out of anything meant to be shared.
|
|
18
|
+
rows: rows.filter((r) => benchmarksPublishable(r.provider)).map(({ lastError: _drop, ...r }) => r),
|
|
17
19
|
};
|
|
18
20
|
}
|
|
19
21
|
export function loadReports(dir) {
|
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,45 @@
|
|
|
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
|
+
/**
|
|
18
|
+
* A saved check as it may be published: for a provider whose terms forbid publishing benchmarks,
|
|
19
|
+
* pass/fail stays and every timing goes (the per-step `ms`, and notes that quote a duration).
|
|
20
|
+
*/
|
|
21
|
+
export declare function publishableCheck<T extends {
|
|
22
|
+
provider: string;
|
|
23
|
+
results: {
|
|
24
|
+
ms?: number;
|
|
25
|
+
detail?: string;
|
|
26
|
+
}[];
|
|
27
|
+
}>(saved: T): T;
|
|
28
|
+
/** Problems with one parsed result file. `now` is injectable for tests. */
|
|
29
|
+
export declare function validateResult(j: any, now?: number): string[];
|
|
30
|
+
/** Problems found anywhere in a file's raw text: secrets, emails, IP addresses. */
|
|
31
|
+
export declare function scanText(text: string): string[];
|
|
32
|
+
/**
|
|
33
|
+
* Every problem in a results directory. `changed` is the PR's `git diff --name-status` against its
|
|
34
|
+
* base, when there is one: a modified bench report means someone edited a run, which is refused.
|
|
35
|
+
* Saved checks (one per provider, replaced by each new run) and cost runs (`cost settle` fills in the
|
|
36
|
+
* bill later) may change.
|
|
37
|
+
*/
|
|
38
|
+
export declare function validateResults(dir?: string, opts?: {
|
|
39
|
+
changed?: {
|
|
40
|
+
status: string;
|
|
41
|
+
path: string;
|
|
42
|
+
}[];
|
|
43
|
+
previous?: (path: string) => string | undefined;
|
|
44
|
+
now?: number;
|
|
45
|
+
}): Issue[];
|
package/dist/results.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import { benchmarksPublishable, 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
|
+
/** A timing in a check step's note, e.g. "first output after 100 ms" or "threw after 3.0 s". */
|
|
17
|
+
const TIMING = /\d[\d,.]*\s?(?:ms|s|sec|seconds?)\b/i;
|
|
18
|
+
/**
|
|
19
|
+
* A saved check as it may be published: for a provider whose terms forbid publishing benchmarks,
|
|
20
|
+
* pass/fail stays and every timing goes (the per-step `ms`, and notes that quote a duration).
|
|
21
|
+
*/
|
|
22
|
+
export function publishableCheck(saved) {
|
|
23
|
+
if (benchmarksPublishable(saved.provider))
|
|
24
|
+
return saved;
|
|
25
|
+
return {
|
|
26
|
+
...saved,
|
|
27
|
+
results: saved.results.map(({ ms: _ms, detail, ...r }) => ({ ...r, ...(detail && !TIMING.test(detail) ? { detail } : {}) })),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
const noBench = (id) => typeof id === "string" && IDS.has(id) && !benchmarksPublishable(id);
|
|
31
|
+
const NO_BENCH = "its terms don't allow publishing benchmarks (noBenchmarks in src/catalog.ts); leave it out of shared results";
|
|
32
|
+
const STEPS = new Set(CHECK_STEPS);
|
|
33
|
+
const MAX_MS = 15 * 60_000;
|
|
34
|
+
/** Key and token shapes from the providers here and the usual suspects. Matches print as a prefix only. */
|
|
35
|
+
const SECRET_SHAPES = [
|
|
36
|
+
["Stripe-style or Sail key", /\bsk_(?:live_|test_)?[A-Za-z0-9]{16,}/],
|
|
37
|
+
["OpenAI-style key", /\bsk-[A-Za-z0-9_-]{20,}/],
|
|
38
|
+
["E2B key", /\be2b_[A-Za-z0-9]{20,}/],
|
|
39
|
+
["Daytona key", /\bdtn_[A-Za-z0-9]{20,}/],
|
|
40
|
+
["LangSmith key", /\blsv2_(?:sk|pt)_[A-Za-z0-9_]{20,}/],
|
|
41
|
+
["AWS access key", /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/],
|
|
42
|
+
["GitHub token", /\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,})/],
|
|
43
|
+
["Slack token", /\bxox[abprs]-[A-Za-z0-9-]{10,}/],
|
|
44
|
+
["JWT", /\beyJ[A-Za-z0-9_-]{15,}\.eyJ[A-Za-z0-9_-]{15,}\./],
|
|
45
|
+
["bearer token", /\bBearer\s+[A-Za-z0-9._~+/-]{20,}/],
|
|
46
|
+
["private key", /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
|
|
47
|
+
["key=value secret", /\b[A-Z][A-Z0-9_]*(?:API_KEY|TOKEN|SECRET)["']?\s*[:=]\s*["']?[A-Za-z0-9_\-./+]{16,}/],
|
|
48
|
+
];
|
|
49
|
+
const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}/;
|
|
50
|
+
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/;
|
|
51
|
+
/** "12 Main Street", "Calle Mayor 5", "Apt 4B": a street or a flat, which a label never needs. */
|
|
52
|
+
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;
|
|
53
|
+
const isNum = (n, min = 0, max = Infinity) => typeof n === "number" && Number.isFinite(n) && n >= min && n <= max;
|
|
54
|
+
function checkDate(iso, now) {
|
|
55
|
+
if (typeof iso !== "string" || Number.isNaN(Date.parse(iso)))
|
|
56
|
+
return "date is missing or not an ISO date";
|
|
57
|
+
if (Date.parse(iso) > now + 86_400_000)
|
|
58
|
+
return "date is in the future";
|
|
59
|
+
if (Date.parse(iso) < Date.parse("2026-01-01"))
|
|
60
|
+
return "date is before sandboxrouter existed";
|
|
61
|
+
}
|
|
62
|
+
function checkLabel(label) {
|
|
63
|
+
if (label === undefined || label === null)
|
|
64
|
+
return;
|
|
65
|
+
if (typeof label !== "string")
|
|
66
|
+
return "label must be text";
|
|
67
|
+
if (label.length > 80)
|
|
68
|
+
return "label is longer than 80 characters";
|
|
69
|
+
if (EMAIL.test(label))
|
|
70
|
+
return "label contains an email address";
|
|
71
|
+
if (IPV4.test(label))
|
|
72
|
+
return "label contains an IP address";
|
|
73
|
+
if (ADDRESS.test(label))
|
|
74
|
+
return 'label looks like a street address; use a city or cloud region, e.g. "Berlin" or "us-east-1"';
|
|
75
|
+
}
|
|
76
|
+
function checkBy(by) {
|
|
77
|
+
if (by === undefined)
|
|
78
|
+
return;
|
|
79
|
+
try {
|
|
80
|
+
if (typeof by !== "string" || contributor(by) !== by)
|
|
81
|
+
return 'by must be a GitHub username without the "@"';
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
return e.message;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Problems with one parsed result file. `now` is injectable for tests. */
|
|
88
|
+
export function validateResult(j, now = Date.now()) {
|
|
89
|
+
const out = [];
|
|
90
|
+
const add = (p) => p && out.push(p);
|
|
91
|
+
const provider = (id, where = "provider") => {
|
|
92
|
+
if (typeof id !== "string" || !IDS.has(id))
|
|
93
|
+
out.push(`${where} "${String(id)}" is not in the catalog`);
|
|
94
|
+
};
|
|
95
|
+
if (!j || typeof j !== "object" || Array.isArray(j))
|
|
96
|
+
return ["not a result file: expected a JSON object with a \"tool\" field"];
|
|
97
|
+
if (j.tool === "sandboxrouter") {
|
|
98
|
+
add(checkDate(j.date, now));
|
|
99
|
+
add(checkLabel(j.label));
|
|
100
|
+
add(checkBy(j.by));
|
|
101
|
+
if (!isNum(j.runs, 3, 1000))
|
|
102
|
+
out.push("runs must be at least 3");
|
|
103
|
+
if (!Array.isArray(j.rows) || !j.rows.length)
|
|
104
|
+
out.push("rows is empty");
|
|
105
|
+
for (const r of Array.isArray(j.rows) ? j.rows : []) {
|
|
106
|
+
provider(r?.provider, "row provider");
|
|
107
|
+
if (noBench(r?.provider))
|
|
108
|
+
out.push(`${r.provider}: ${NO_BENCH}`);
|
|
109
|
+
if (!isNum(r?.runs, 1, 1000))
|
|
110
|
+
out.push(`${r?.provider}: runs is not a positive number`);
|
|
111
|
+
if (!isNum(r?.failures, 0, r?.runs ?? 0))
|
|
112
|
+
out.push(`${r?.provider}: failures must be between 0 and runs`);
|
|
113
|
+
for (const k of ["medianCreateMs", "medianExecMs"]) {
|
|
114
|
+
if (r?.[k] !== undefined && !isNum(r[k], 1, MAX_MS))
|
|
115
|
+
out.push(`${r?.provider}: ${k} must be between 1 ms and 15 min`);
|
|
116
|
+
}
|
|
117
|
+
if (r?.failures < r?.runs && r?.medianCreateMs === undefined)
|
|
118
|
+
out.push(`${r?.provider}: successful runs but no medianCreateMs`);
|
|
119
|
+
if ("lastError" in (r ?? {}))
|
|
120
|
+
out.push(`${r?.provider}: lastError must not be shared (it can hold account details)`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
else if (j.tool === "sandboxrouter-check") {
|
|
124
|
+
add(checkDate(j.date, now));
|
|
125
|
+
add(checkBy(j.by));
|
|
126
|
+
provider(j.provider);
|
|
127
|
+
if (!Array.isArray(j.results) || !j.results.length)
|
|
128
|
+
out.push("results is empty");
|
|
129
|
+
for (const r of Array.isArray(j.results) ? j.results : []) {
|
|
130
|
+
if (!STEPS.has(r?.step))
|
|
131
|
+
out.push(`unknown check step "${r?.step}"`);
|
|
132
|
+
if (!["pass", "fail", "skip", "info"].includes(r?.status))
|
|
133
|
+
out.push(`${r?.step}: unknown status "${r?.status}"`);
|
|
134
|
+
if (r?.ms !== undefined && !isNum(r.ms, 0, MAX_MS))
|
|
135
|
+
out.push(`${r?.step}: ms must be between 0 and 15 min`);
|
|
136
|
+
if (noBench(j.provider) && (r?.ms !== undefined || (typeof r?.detail === "string" && TIMING.test(r.detail)))) {
|
|
137
|
+
out.push(`${r?.step}: has timings, but ${j.provider}'s terms don't allow publishing performance information; save with check --save`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
else if (j.tool === "sandboxrouter-cost") {
|
|
142
|
+
add(checkDate(j.date, now));
|
|
143
|
+
add(checkBy(j.by));
|
|
144
|
+
if (!isNum(j.minutes, 1, 24 * 60))
|
|
145
|
+
out.push("minutes must be between 1 and 1440");
|
|
146
|
+
if (!Array.isArray(j.entries) || !j.entries.length)
|
|
147
|
+
out.push("entries is empty");
|
|
148
|
+
for (const e of Array.isArray(j.entries) ? j.entries : []) {
|
|
149
|
+
provider(e?.provider, "entry provider");
|
|
150
|
+
if (noBench(e?.provider))
|
|
151
|
+
out.push(`${e.provider}: ${NO_BENCH}`);
|
|
152
|
+
if (e?.seconds !== undefined && !isNum(e.seconds, 0, 2 * 24 * 3600))
|
|
153
|
+
out.push(`${e?.provider}: seconds is out of range`);
|
|
154
|
+
if (e?.predictedUsd !== undefined && !isNum(e.predictedUsd, 0, 1000))
|
|
155
|
+
out.push(`${e?.provider}: predictedUsd is out of range`);
|
|
156
|
+
if (e?.bill?.usd !== undefined && !isNum(e.bill.usd, 0, 1000))
|
|
157
|
+
out.push(`${e?.provider}: bill.usd is out of range`);
|
|
158
|
+
if (e?.delivered && !(isNum(e.delivered.vcpu, 0.1, 1024) && isNum(e.delivered.memoryMb, 64, 4_194_304))) {
|
|
159
|
+
out.push(`${e?.provider}: delivered size is out of range`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
out.push(`unknown tool "${String(j.tool)}": expected a bench report, a saved check or a cost run`);
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
/** Problems found anywhere in a file's raw text: secrets, emails, IP addresses. */
|
|
169
|
+
export function scanText(text) {
|
|
170
|
+
const out = [];
|
|
171
|
+
for (const [what, re] of SECRET_SHAPES) {
|
|
172
|
+
const m = text.match(re);
|
|
173
|
+
if (m)
|
|
174
|
+
out.push(`looks like a ${what} ("${m[0].slice(0, 6)}…"); never commit keys`);
|
|
175
|
+
}
|
|
176
|
+
const email = text.match(EMAIL);
|
|
177
|
+
if (email)
|
|
178
|
+
out.push(`contains an email address ("${email[0].replace(/^(.).*@/, "$1…@")}")`);
|
|
179
|
+
const ip = text.match(IPV4);
|
|
180
|
+
if (ip && !/^0\.|^127\./.test(ip[0]))
|
|
181
|
+
out.push(`contains an IP address ("${ip[0]}")`);
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
/** Files in results/ that aren't submissions: notes and the maintainers' price audits. */
|
|
185
|
+
const NOT_A_SUBMISSION = (rel) => /(^|\/)README\.md$/.test(rel) || /(^|\/)price-audit-[^/]+$/.test(rel);
|
|
186
|
+
function walk(dir) {
|
|
187
|
+
if (!existsSync(dir))
|
|
188
|
+
return [];
|
|
189
|
+
return readdirSync(dir).flatMap((f) => {
|
|
190
|
+
const p = join(dir, f);
|
|
191
|
+
return statSync(p).isDirectory() ? walk(p) : [p];
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Every problem in a results directory. `changed` is the PR's `git diff --name-status` against its
|
|
196
|
+
* base, when there is one: a modified bench report means someone edited a run, which is refused.
|
|
197
|
+
* Saved checks (one per provider, replaced by each new run) and cost runs (`cost settle` fills in the
|
|
198
|
+
* bill later) may change.
|
|
199
|
+
*/
|
|
200
|
+
export function validateResults(dir = "results", opts = {}) {
|
|
201
|
+
const issues = [];
|
|
202
|
+
for (const path of walk(dir)) {
|
|
203
|
+
const rel = relative(".", path);
|
|
204
|
+
if (NOT_A_SUBMISSION(relative(dir, path)))
|
|
205
|
+
continue;
|
|
206
|
+
if (path.endsWith(".DS_Store"))
|
|
207
|
+
continue;
|
|
208
|
+
if (!path.endsWith(".json")) {
|
|
209
|
+
issues.push({ file: rel, problem: "only .json result files belong here" });
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const text = readFileSync(path, "utf8");
|
|
213
|
+
for (const problem of scanText(text))
|
|
214
|
+
issues.push({ file: rel, problem });
|
|
215
|
+
let j;
|
|
216
|
+
try {
|
|
217
|
+
j = JSON.parse(text);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
issues.push({ file: rel, problem: "is not valid JSON" });
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
for (const problem of validateResult(j, opts.now))
|
|
224
|
+
issues.push({ file: rel, problem });
|
|
225
|
+
}
|
|
226
|
+
for (const c of opts.changed ?? []) {
|
|
227
|
+
const p = c.path;
|
|
228
|
+
if (!p.startsWith(`${dir}/`) || NOT_A_SUBMISSION(p.slice(dir.length + 1)))
|
|
229
|
+
continue;
|
|
230
|
+
if (p.startsWith(`${dir}/conformance/`) || p.startsWith(`${dir}/cost/`))
|
|
231
|
+
continue;
|
|
232
|
+
if (/^[MR]/.test(c.status) && !onlyDropsUnpublishable(opts.previous?.(p), p)) {
|
|
233
|
+
issues.push({ file: p, problem: "edits an existing bench report; add your run as a new file instead" });
|
|
234
|
+
}
|
|
235
|
+
if (c.status.startsWith("D"))
|
|
236
|
+
issues.push({ file: p, problem: "deletes a bench report; reports are only removed by a maintainer" });
|
|
237
|
+
}
|
|
238
|
+
return issues;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* True when a bench report's only change is removing rows for providers whose terms forbid publishing
|
|
242
|
+
* benchmarks: the one edit to someone's report that's allowed, so those numbers can be taken down.
|
|
243
|
+
*/
|
|
244
|
+
function onlyDropsUnpublishable(before, path) {
|
|
245
|
+
if (before === undefined || !existsSync(path))
|
|
246
|
+
return false;
|
|
247
|
+
try {
|
|
248
|
+
const a = JSON.parse(before);
|
|
249
|
+
const b = JSON.parse(readFileSync(path, "utf8"));
|
|
250
|
+
const { rows: ra, ...restA } = a;
|
|
251
|
+
const { rows: rb, ...restB } = b;
|
|
252
|
+
if (JSON.stringify(restA) !== JSON.stringify(restB))
|
|
253
|
+
return false;
|
|
254
|
+
const kept = ra.filter((r) => benchmarksPublishable(r.provider));
|
|
255
|
+
return JSON.stringify(kept) === JSON.stringify(rb);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
}
|
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()];
|
|
@@ -207,9 +207,11 @@ export class Router {
|
|
|
207
207
|
if (timedOut)
|
|
208
208
|
pending.then((late) => this.discard(c.provider, late, c.estimatedHourlyUsd, spec.timeoutMinutes), () => { });
|
|
209
209
|
attempts.push({ provider: c.provider, ok: false, ms: Date.now() - t0, error: err.message });
|
|
210
|
-
const status = err
|
|
211
|
-
|
|
212
|
-
|
|
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, image, request or a missing package), ` +
|
|
213
215
|
`so the router stopped instead of moving the work to another provider; pass fallback: "any" to fall back on every error`, attempts);
|
|
214
216
|
}
|
|
215
217
|
}
|
|
@@ -334,8 +336,12 @@ function round(n) {
|
|
|
334
336
|
}
|
|
335
337
|
/** Seconds to wait for one provider's create before trying the next (RouteOptions.createTimeoutSeconds). */
|
|
336
338
|
export const DEFAULT_CREATE_TIMEOUT_S = 120;
|
|
337
|
-
/**
|
|
338
|
-
|
|
339
|
+
/**
|
|
340
|
+
* HTTP statuses that mean the request or the key is wrong, not that the provider is down. 402 (out of
|
|
341
|
+
* balance, plan needed) isn't one: an unfunded account is exactly when the next provider should take the
|
|
342
|
+
* sandbox, and the failed attempt stays on `attempts` (the CLI prints it), so it isn't hidden.
|
|
343
|
+
*/
|
|
344
|
+
const SETUP_ERRORS = new Set([400, 401, 403, 404, 422]);
|
|
339
345
|
/** Seconds past a command's timeout before the router stops waiting for the adapter. */
|
|
340
346
|
export const EXEC_GRACE_S = 30;
|
|
341
347
|
/** Resolve with `work`, or reject with `onExpire()` after `ms`. A late `work` rejection is swallowed. */
|
package/dist/types.d.ts
CHANGED
|
@@ -33,9 +33,10 @@ export interface RouteOptions {
|
|
|
33
33
|
strategy?: Strategy;
|
|
34
34
|
/**
|
|
35
35
|
* What to do when a create fails. `true` (default): try the next candidate when the provider
|
|
36
|
-
* looks unavailable (timeout, network error, 429, 5xx, or an error without a status)
|
|
37
|
-
* a setup error (400, 401,
|
|
38
|
-
* a mistake isn't hidden by quietly moving the work to another, maybe
|
|
36
|
+
* looks unavailable (timeout, network error, 429, 5xx, or an error without a status) or can't bill
|
|
37
|
+
* (402: no balance, plan needed), but stop on a setup error (400, 401, 403, 404, 422: a bad key, a
|
|
38
|
+
* bad image or request), so a mistake isn't hidden by quietly moving the work to another, maybe
|
|
39
|
+
* pricier, provider. Every failed attempt is on `sandbox.attempts`.
|
|
39
40
|
* `"any"`: try the next candidate on every error. `false`: only the first candidate.
|
|
40
41
|
*/
|
|
41
42
|
fallback?: boolean | "any";
|
|
@@ -93,3 +94,10 @@ export declare class ProviderError extends Error {
|
|
|
93
94
|
readonly status?: number | undefined;
|
|
94
95
|
constructor(provider: ProviderId | (string & {}), message: string, status?: number | undefined);
|
|
95
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* A problem with local setup, such as a provider SDK that isn't installed or a missing setting:
|
|
99
|
+
* something to fix here, not a provider being down. The router stops on it instead of falling back.
|
|
100
|
+
*/
|
|
101
|
+
export declare class SetupError extends Error {
|
|
102
|
+
constructor(message: string);
|
|
103
|
+
}
|
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.3",
|
|
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",
|