@sandboxrouter/sdk 0.3.2 → 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 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-06) | 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-06) | 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) |
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-06) | Billed on memory only ($0.0000115/GB-s), CPU scales with memory (~2 GB per core) | [pricing](https://blaxel.ai/pricing) |
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
- | 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) |
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 | failed 1 step (2026-11-06) | Per second, plus disk above 5 GiB | [pricing](https://www.daytona.io/pricing) |
97
+ | Daytona | skipped (default image is 1 vCPU / 1 GB; a bigger size needs image.daytona) | none | 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-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) |
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/) |
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`). A setup error (400, 401, 402, 403, 404, 422: a bad key,
189
- // no billing, a bad image) stops instead, so a mistake isn't hidden by a pricier provider.
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-06, v0.2.0 |
310
- | Sailboxes (Sail Research) | 14/14 passed | 2026-11-06, v0.3.0 |
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-06, v0.2.0 |
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
- | OpenScale Sandboxes | 14/14 passed | 2026-11-06, v0.2.0 |
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 | 0/14 passed; failed: create | 2026-11-06, v0.2.0 |
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-06, v0.2.0 |
340
- | Docker (local) | 14/14 passed | 2026-11-06, v0.2.0 |
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,9 +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
+ import { contributor, publishableCheck } from "./results.js";
10
10
  import { loadReports, makeReport, renderLeaderboard } from "./leaderboard.js";
11
- 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";
12
12
  import { Router } from "./router.js";
13
13
  import { createApp } from "./server.js";
14
14
  import { RouterError } from "./types.js";
@@ -215,7 +215,7 @@ async function main() {
215
215
  reports.push(report);
216
216
  if (saveDir) {
217
217
  mkdirSync(saveDir, { recursive: true });
218
- const saved = { tool: "sandboxrouter-check", version, date: new Date().toISOString(), platform: `${platform()}-${arch()}`, ...(by ? { by } : {}), ...report };
218
+ const saved = publishableCheck({ tool: "sandboxrouter-check", version, date: new Date().toISOString(), platform: `${platform()}-${arch()}`, ...(by ? { by } : {}), ...report });
219
219
  writeFileSync(`${saveDir}/${p}.json`, JSON.stringify(saved, null, 2) + "\n");
220
220
  }
221
221
  }
@@ -277,6 +277,9 @@ async function main() {
277
277
  if (values.share) {
278
278
  const version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
279
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)`);
280
283
  const file = values.out ?? `sandboxrouter-bench-${report.date.slice(0, 10)}-${Math.random().toString(36).slice(2, 8)}.json`;
281
284
  writeFileSync(file, JSON.stringify(report, null, 2) + "\n");
282
285
  console.log(`\nwrote ${file}. Add it to results/ in a PR to github.com/gigialc/sandboxrouter to put it on the leaderboard.`);
@@ -301,7 +304,11 @@ async function main() {
301
304
  });
302
305
  const file = values.out ?? `results/cost/cost-${run.date.slice(0, 16).replace(/[:T]/g, "-")}.json`;
303
306
  mkdirSync(file.replace(/\/[^/]*$/, "") || ".", { recursive: true });
304
- writeFileSync(file, JSON.stringify(await settle(run), null, 2) + "\n");
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)`);
305
312
  console.log(costMarkdown(run));
306
313
  console.log(`\nwrote ${file}. Bills post late on some providers: run "sandboxrouter cost settle ${file}" again later.`);
307
314
  return;
package/dist/cost.d.ts CHANGED
@@ -52,6 +52,13 @@ export interface CostRun {
52
52
  by?: string;
53
53
  entries: CostEntry[];
54
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;
55
62
  export declare function costRun(opts: {
56
63
  providers: ProviderId[];
57
64
  minutes: number;
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
- const PROBE = `echo "$(nproc) $(awk '/MemTotal/{printf "%d", $2/1024}' /proc/meminfo)"`;
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;
@@ -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 {
@@ -14,7 +14,8 @@ export function makeReport(rows, opts) {
14
14
  spec: { vcpu: opts.spec.vcpu, memoryGb: opts.spec.memoryGb },
15
15
  node: process.version,
16
16
  platform: `${process.platform}-${process.arch}`,
17
- rows: rows.map(({ lastError: _drop, ...r }) => r),
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),
18
19
  };
19
20
  }
20
21
  export function loadReports(dir) {
package/dist/results.d.ts CHANGED
@@ -14,6 +14,17 @@ export interface Issue {
14
14
  }
15
15
  /** A GitHub handle, without the "@": what `--by` accepts and what reports store. */
16
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;
17
28
  /** Problems with one parsed result file. `now` is injectable for tests. */
18
29
  export declare function validateResult(j: any, now?: number): string[];
19
30
  /** Problems found anywhere in a file's raw text: secrets, emails, IP addresses. */
@@ -29,5 +40,6 @@ export declare function validateResults(dir?: string, opts?: {
29
40
  status: string;
30
41
  path: string;
31
42
  }[];
43
+ previous?: (path: string) => string | undefined;
32
44
  now?: number;
33
45
  }): Issue[];
package/dist/results.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
2
  import { join, relative } from "node:path";
3
- import { CATALOG } from "./catalog.js";
3
+ import { benchmarksPublishable, CATALOG } from "./catalog.js";
4
4
  import { CHECK_STEPS } from "./check.js";
5
5
  /** A GitHub handle, without the "@": what `--by` accepts and what reports store. */
6
6
  export function contributor(by) {
@@ -13,6 +13,22 @@ export function contributor(by) {
13
13
  return h;
14
14
  }
15
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";
16
32
  const STEPS = new Set(CHECK_STEPS);
17
33
  const MAX_MS = 15 * 60_000;
18
34
  /** Key and token shapes from the providers here and the usual suspects. Matches print as a prefix only. */
@@ -88,6 +104,8 @@ export function validateResult(j, now = Date.now()) {
88
104
  out.push("rows is empty");
89
105
  for (const r of Array.isArray(j.rows) ? j.rows : []) {
90
106
  provider(r?.provider, "row provider");
107
+ if (noBench(r?.provider))
108
+ out.push(`${r.provider}: ${NO_BENCH}`);
91
109
  if (!isNum(r?.runs, 1, 1000))
92
110
  out.push(`${r?.provider}: runs is not a positive number`);
93
111
  if (!isNum(r?.failures, 0, r?.runs ?? 0))
@@ -113,8 +131,11 @@ export function validateResult(j, now = Date.now()) {
113
131
  out.push(`unknown check step "${r?.step}"`);
114
132
  if (!["pass", "fail", "skip", "info"].includes(r?.status))
115
133
  out.push(`${r?.step}: unknown status "${r?.status}"`);
116
- if (!isNum(r?.ms, 0, MAX_MS))
134
+ if (r?.ms !== undefined && !isNum(r.ms, 0, MAX_MS))
117
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
+ }
118
139
  }
119
140
  }
120
141
  else if (j.tool === "sandboxrouter-cost") {
@@ -126,6 +147,8 @@ export function validateResult(j, now = Date.now()) {
126
147
  out.push("entries is empty");
127
148
  for (const e of Array.isArray(j.entries) ? j.entries : []) {
128
149
  provider(e?.provider, "entry provider");
150
+ if (noBench(e?.provider))
151
+ out.push(`${e.provider}: ${NO_BENCH}`);
129
152
  if (e?.seconds !== undefined && !isNum(e.seconds, 0, 2 * 24 * 3600))
130
153
  out.push(`${e?.provider}: seconds is out of range`);
131
154
  if (e?.predictedUsd !== undefined && !isNum(e.predictedUsd, 0, 1000))
@@ -206,10 +229,32 @@ export function validateResults(dir = "results", opts = {}) {
206
229
  continue;
207
230
  if (p.startsWith(`${dir}/conformance/`) || p.startsWith(`${dir}/cost/`))
208
231
  continue;
209
- if (/^[MR]/.test(c.status))
232
+ if (/^[MR]/.test(c.status) && !onlyDropsUnpublishable(opts.previous?.(p), p)) {
210
233
  issues.push({ file: p, problem: "edits an existing bench report; add your run as a new file instead" });
234
+ }
211
235
  if (c.status.startsWith("D"))
212
236
  issues.push({ file: p, problem: "deletes a bench report; reports are only removed by a maintainer" });
213
237
  }
214
238
  return issues;
215
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
@@ -211,7 +211,7 @@ export class Router {
211
211
  const setup = err instanceof SetupError || (status !== undefined && SETUP_ERRORS.has(status));
212
212
  if (route.fallback !== "any" && setup) {
213
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), ` +
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), ` +
215
215
  `so the router stopped instead of moving the work to another provider; pass fallback: "any" to fall back on every error`, attempts);
216
216
  }
217
217
  }
@@ -336,8 +336,12 @@ function round(n) {
336
336
  }
337
337
  /** Seconds to wait for one provider's create before trying the next (RouteOptions.createTimeoutSeconds). */
338
338
  export const DEFAULT_CREATE_TIMEOUT_S = 120;
339
- /** HTTP statuses that mean the request or the account is wrong, not that the provider is down. */
340
- const SETUP_ERRORS = new Set([400, 401, 402, 403, 404, 422]);
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]);
341
345
  /** Seconds past a command's timeout before the router stops waiting for the adapter. */
342
346
  export const EXEC_GRACE_S = 30;
343
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), but stop on
37
- * a setup error (400, 401, 402, 403, 404, 422: a bad key, no billing, a bad image or request), so
38
- * a mistake isn't hidden by quietly moving the work to another, maybe pricier, provider.
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";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sandboxrouter/sdk",
3
- "version": "0.3.2",
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",