@sandboxrouter/sdk 0.3.2 → 0.3.4

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/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";
@@ -39,7 +39,7 @@ Usage:
39
39
  sandboxrouter serve [--port 8787] [--host 127.0.0.1] [--cors https://site.example,http://localhost:3000]
40
40
 
41
41
  Spec flags: --vcpu N --memory GB --gpu T4 --timeout MIN
42
- Route flags: --provider e2b[,modal,...] --strategy cheapest|fastest
42
+ Route flags: --provider e2b[,modal,...] --strategy verified|cheapest|fastest (default verified: tested providers first)
43
43
  --no-fallback --fallback-any (also on setup errors like a bad key)
44
44
  --create-timeout SEC (per provider, default 120) --require gpu,custom-image
45
45
 
@@ -156,7 +156,8 @@ async function main() {
156
156
  let n = 0;
157
157
  for (const c of plan) {
158
158
  const mark = c.skip ? " -" : `${++n}.`.padStart(3);
159
- console.log(`${mark} ${c.provider.padEnd(11)} $${c.estimatedHourlyUsd.toFixed(4)}/hr${c.skip ? ` (skipped: ${c.skip})` : ""}`);
159
+ const tested = c.verified ? "tested " : " ";
160
+ console.log(`${mark} ${c.provider.padEnd(11)} $${c.estimatedHourlyUsd.toFixed(4)}/hr ${tested}${c.skip ? `(skipped: ${c.skip})` : ""}`.trimEnd());
160
161
  }
161
162
  return;
162
163
  }
@@ -215,7 +216,7 @@ async function main() {
215
216
  reports.push(report);
216
217
  if (saveDir) {
217
218
  mkdirSync(saveDir, { recursive: true });
218
- const saved = { tool: "sandboxrouter-check", version, date: new Date().toISOString(), platform: `${platform()}-${arch()}`, ...(by ? { by } : {}), ...report };
219
+ const saved = publishableCheck({ tool: "sandboxrouter-check", version, date: new Date().toISOString(), platform: `${platform()}-${arch()}`, ...(by ? { by } : {}), ...report });
219
220
  writeFileSync(`${saveDir}/${p}.json`, JSON.stringify(saved, null, 2) + "\n");
220
221
  }
221
222
  }
@@ -277,6 +278,9 @@ async function main() {
277
278
  if (values.share) {
278
279
  const version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
279
280
  const report = makeReport(rows, { version, runs: rows[0].runs, spec, label: values.label, by });
281
+ const left = rows.filter((r) => !benchmarksPublishable(r.provider)).map((r) => r.provider);
282
+ if (left.length)
283
+ console.log(`\nleft out of the shared report: ${left.join(", ")} (their terms don't allow publishing benchmarks)`);
280
284
  const file = values.out ?? `sandboxrouter-bench-${report.date.slice(0, 10)}-${Math.random().toString(36).slice(2, 8)}.json`;
281
285
  writeFileSync(file, JSON.stringify(report, null, 2) + "\n");
282
286
  console.log(`\nwrote ${file}. Add it to results/ in a PR to github.com/gigialc/sandboxrouter to put it on the leaderboard.`);
@@ -301,7 +305,11 @@ async function main() {
301
305
  });
302
306
  const file = values.out ?? `results/cost/cost-${run.date.slice(0, 16).replace(/[:T]/g, "-")}.json`;
303
307
  mkdirSync(file.replace(/\/[^/]*$/, "") || ".", { recursive: true });
304
- writeFileSync(file, JSON.stringify(await settle(run), null, 2) + "\n");
308
+ const settled = await settle(run);
309
+ const left = settled.entries.filter((e) => !benchmarksPublishable(e.provider)).map((e) => e.provider);
310
+ writeFileSync(file, JSON.stringify({ ...settled, entries: settled.entries.filter((e) => benchmarksPublishable(e.provider)) }, null, 2) + "\n");
311
+ if (left.length)
312
+ console.log(`left out of ${file}: ${left.join(", ")} (their terms don't allow publishing benchmarks; the table above is the only copy)`);
305
313
  console.log(costMarkdown(run));
306
314
  console.log(`\nwrote ${file}. Bills post late on some providers: run "sandboxrouter cost settle ${file}" again later.`);
307
315
  return;
@@ -0,0 +1,58 @@
1
+ import type { Attempt, ExecOptions, ExecResult, RouteOptions, SandboxSpec } from "./types.js";
2
+ /**
3
+ * Client for the sandboxrouter HTTP API: the hosted service (one key, prepaid credits) or your own
4
+ * `sandboxrouter serve`. Provider keys live on the server; this side only needs the one key.
5
+ *
6
+ * const sr = new Client({ apiKey: process.env.SANDBOXROUTER_API_KEY });
7
+ * const sbx = await sr.create({ vcpu: 2, memoryGb: 4 });
8
+ * console.log(sbx.provider, (await sbx.exec("python3 -c 'print(6*7)'")).stdout);
9
+ * await sbx.kill();
10
+ */
11
+ export declare class Client {
12
+ readonly baseUrl: string;
13
+ private readonly apiKey?;
14
+ private readonly fetch;
15
+ constructor(opts?: {
16
+ apiKey?: string;
17
+ baseUrl?: string;
18
+ fetch?: typeof fetch;
19
+ });
20
+ /** @internal */
21
+ request(method: string, path: string, body?: unknown, accept?: string): Promise<Response>;
22
+ private json;
23
+ /** Providers this server can route to, with list prices. */
24
+ providers(): Promise<{
25
+ id: string;
26
+ configured: boolean;
27
+ estimatedHourlyUsd: number;
28
+ }[]>;
29
+ /** Dry run: which providers would be tried, in order. */
30
+ route(spec?: SandboxSpec, route?: RouteOptions): Promise<{
31
+ provider: string;
32
+ estimatedHourlyUsd: number;
33
+ skip?: string;
34
+ }[]>;
35
+ /** Start a sandbox on the first provider that works, as the server routes it. */
36
+ create(spec?: SandboxSpec, route?: RouteOptions): Promise<RemoteSandbox>;
37
+ /** A handle on a sandbox you created earlier. */
38
+ sandbox(id: string): RemoteSandbox;
39
+ }
40
+ export declare class ClientError extends Error {
41
+ readonly status: number;
42
+ readonly attempts: Attempt[];
43
+ constructor(status: number, message: string, attempts?: Attempt[]);
44
+ }
45
+ export declare class RemoteSandbox {
46
+ private readonly client;
47
+ readonly id: string;
48
+ readonly provider: string;
49
+ readonly estimatedHourlyUsd?: number | undefined;
50
+ readonly attempts: Attempt[];
51
+ constructor(client: Client, id: string, provider: string, estimatedHourlyUsd?: number | undefined, attempts?: Attempt[]);
52
+ private path;
53
+ exec(cmd: string, opts?: ExecOptions): Promise<ExecResult>;
54
+ writeFile(path: string, content: string | Uint8Array): Promise<void>;
55
+ readFile(path: string): Promise<Uint8Array>;
56
+ pause(): Promise<void>;
57
+ kill(): Promise<void>;
58
+ }
package/dist/client.js ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Client for the sandboxrouter HTTP API: the hosted service (one key, prepaid credits) or your own
3
+ * `sandboxrouter serve`. Provider keys live on the server; this side only needs the one key.
4
+ *
5
+ * const sr = new Client({ apiKey: process.env.SANDBOXROUTER_API_KEY });
6
+ * const sbx = await sr.create({ vcpu: 2, memoryGb: 4 });
7
+ * console.log(sbx.provider, (await sbx.exec("python3 -c 'print(6*7)'")).stdout);
8
+ * await sbx.kill();
9
+ */
10
+ export class Client {
11
+ baseUrl;
12
+ apiKey;
13
+ fetch;
14
+ constructor(opts = {}) {
15
+ this.baseUrl = (opts.baseUrl ?? process.env.SANDBOXROUTER_URL ?? DEFAULT_URL).replace(/\/+$/, "");
16
+ this.apiKey = opts.apiKey ?? process.env.SANDBOXROUTER_API_KEY ?? process.env.SANDBOXROUTER_TOKEN;
17
+ this.fetch = opts.fetch ?? globalThis.fetch;
18
+ }
19
+ /** @internal */
20
+ async request(method, path, body, accept = "application/json") {
21
+ const raw = body instanceof Uint8Array;
22
+ const res = await this.fetch(this.baseUrl + path, {
23
+ method,
24
+ headers: {
25
+ Accept: accept,
26
+ ...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}),
27
+ ...(body === undefined ? {} : { "Content-Type": raw ? "application/octet-stream" : "application/json" }),
28
+ },
29
+ body: body === undefined ? undefined : raw ? body : JSON.stringify(body),
30
+ });
31
+ if (!res.ok) {
32
+ const text = await res.text();
33
+ let parsed = {};
34
+ try {
35
+ parsed = JSON.parse(text);
36
+ }
37
+ catch { }
38
+ throw new ClientError(res.status, parsed.error ?? (text || res.statusText), parsed.attempts ?? []);
39
+ }
40
+ return res;
41
+ }
42
+ async json(method, path, body) {
43
+ return (await this.request(method, path, body)).json();
44
+ }
45
+ /** Providers this server can route to, with list prices. */
46
+ async providers() {
47
+ return (await this.json("GET", "/v1/providers")).providers;
48
+ }
49
+ /** Dry run: which providers would be tried, in order. */
50
+ async route(spec = {}, route = {}) {
51
+ return (await this.json("POST", "/v1/route", { ...spec, ...route })).candidates;
52
+ }
53
+ /** Start a sandbox on the first provider that works, as the server routes it. */
54
+ async create(spec = {}, route = {}) {
55
+ const r = await this.json("POST", "/v1/sandboxes", {
56
+ ...spec,
57
+ ...route,
58
+ });
59
+ return new RemoteSandbox(this, r.id, r.provider, r.estimatedHourlyUsd, r.attempts);
60
+ }
61
+ /** A handle on a sandbox you created earlier. */
62
+ sandbox(id) {
63
+ return new RemoteSandbox(this, id, id.split(":")[0]);
64
+ }
65
+ }
66
+ const DEFAULT_URL = "http://127.0.0.1:8787";
67
+ export class ClientError extends Error {
68
+ status;
69
+ attempts;
70
+ constructor(status, message, attempts = []) {
71
+ super(`${status}: ${message}`);
72
+ this.status = status;
73
+ this.attempts = attempts;
74
+ this.name = "ClientError";
75
+ }
76
+ }
77
+ export class RemoteSandbox {
78
+ client;
79
+ id;
80
+ provider;
81
+ estimatedHourlyUsd;
82
+ attempts;
83
+ constructor(client, id, provider, estimatedHourlyUsd, attempts = []) {
84
+ this.client = client;
85
+ this.id = id;
86
+ this.provider = provider;
87
+ this.estimatedHourlyUsd = estimatedHourlyUsd;
88
+ this.attempts = attempts;
89
+ }
90
+ path(suffix = "") {
91
+ return `/v1/sandboxes/${encodeURIComponent(this.id)}${suffix}`;
92
+ }
93
+ async exec(cmd, opts = {}) {
94
+ const { onStdout, onStderr, ...rest } = opts;
95
+ const body = { cmd, ...rest };
96
+ if (!onStdout && !onStderr)
97
+ return (await this.client.request("POST", this.path("/exec"), body)).json();
98
+ const res = await this.client.request("POST", this.path("/exec"), { ...body, stream: true }, "text/event-stream");
99
+ const decoder = new TextDecoder();
100
+ let buf = "";
101
+ for await (const chunk of res.body) {
102
+ buf += decoder.decode(chunk, { stream: true });
103
+ let end;
104
+ while ((end = buf.indexOf("\n\n")) >= 0) {
105
+ const block = buf.slice(0, end);
106
+ buf = buf.slice(end + 2);
107
+ const event = /^event: ?(.*)$/m.exec(block)?.[1] ?? "message";
108
+ const data = block
109
+ .split("\n")
110
+ .filter((l) => l.startsWith("data:"))
111
+ .map((l) => l.slice(l[5] === " " ? 6 : 5))
112
+ .join("\n");
113
+ const payload = JSON.parse(data);
114
+ if (event === "stdout")
115
+ onStdout?.(payload);
116
+ else if (event === "stderr")
117
+ onStderr?.(payload);
118
+ else if (event === "result")
119
+ return payload;
120
+ else if (event === "error")
121
+ throw new ClientError(502, payload.error ?? data);
122
+ }
123
+ }
124
+ throw new ClientError(502, "stream ended before the command finished");
125
+ }
126
+ async writeFile(path, content) {
127
+ const data = typeof content === "string" ? new TextEncoder().encode(content) : content;
128
+ await this.client.request("PUT", this.path(`/files?path=${encodeURIComponent(path)}`), data);
129
+ }
130
+ async readFile(path) {
131
+ const res = await this.client.request("GET", this.path(`/files?path=${encodeURIComponent(path)}`), undefined, "application/octet-stream");
132
+ return new Uint8Array(await res.arrayBuffer());
133
+ }
134
+ async pause() {
135
+ await this.client.request("POST", this.path("/pause"));
136
+ }
137
+ async kill() {
138
+ await this.client.request("DELETE", this.path());
139
+ }
140
+ }
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;
package/dist/index.d.ts CHANGED
@@ -4,7 +4,8 @@ export { conformanceTable, loadConformance, type ConformanceRow, type SavedCheck
4
4
  export { gc, listOpen, isGone, type GcResult, type OpenSandbox } from "./gc.js";
5
5
  export { Router, RoutedSandbox, defaultProviders, defaultComputerProviders, parseId, type Candidate, type RouterOptions } from "./router.js";
6
6
  export { CATALOG, hourlyCost, unfit, info, type ProviderInfo } from "./catalog.js";
7
- export { createApp } from "./server.js";
7
+ export { createApp, HttpError, type ServerHooks } from "./server.js";
8
+ export { Client, ClientError, RemoteSandbox } from "./client.js";
8
9
  export { RoutedComputer, createComputer, planComputer, computerCatalog, computerHourly, COMPUTER_ENTRIES, type ComputerCatalogEntry, type ComputerCandidate, type ComputerProviderId, type ActionResult, type ComputerAction, type ComputerActionName, type ComputerInfo, type ComputerOs, type ComputerProvider, type ComputerSpec, type ProviderComputer, } from "./computers.js";
9
10
  export { bench, benchMarkdown, type BenchRow } from "./bench.js";
10
11
  export { aggregate, loadReports, makeReport, renderLeaderboard, type BenchReport, type LeaderRow } from "./leaderboard.js";
package/dist/index.js CHANGED
@@ -4,7 +4,8 @@ export { conformanceTable, loadConformance } from "./conformance.js";
4
4
  export { gc, listOpen, isGone } from "./gc.js";
5
5
  export { Router, RoutedSandbox, defaultProviders, defaultComputerProviders, parseId } from "./router.js";
6
6
  export { CATALOG, hourlyCost, unfit, info } from "./catalog.js";
7
- export { createApp } from "./server.js";
7
+ export { createApp, HttpError } from "./server.js";
8
+ export { Client, ClientError, RemoteSandbox } from "./client.js";
8
9
  export { RoutedComputer, createComputer, planComputer, computerCatalog, computerHourly, COMPUTER_ENTRIES, } from "./computers.js";
9
10
  export { bench, benchMarkdown } from "./bench.js";
10
11
  export { aggregate, loadReports, makeReport, renderLeaderboard } from "./leaderboard.js";
@@ -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,12 +13,29 @@ 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. */
19
35
  const SECRET_SHAPES = [
20
36
  ["Stripe-style or Sail key", /\bsk_(?:live_|test_)?[A-Za-z0-9]{16,}/],
21
37
  ["OpenAI-style key", /\bsk-[A-Za-z0-9_-]{20,}/],
38
+ ["sandboxrouter key", /\bsr_live_[A-Za-z0-9_-]{20,}/],
22
39
  ["E2B key", /\be2b_[A-Za-z0-9]{20,}/],
23
40
  ["Daytona key", /\bdtn_[A-Za-z0-9]{20,}/],
24
41
  ["LangSmith key", /\blsv2_(?:sk|pt)_[A-Za-z0-9_]{20,}/],
@@ -88,6 +105,8 @@ export function validateResult(j, now = Date.now()) {
88
105
  out.push("rows is empty");
89
106
  for (const r of Array.isArray(j.rows) ? j.rows : []) {
90
107
  provider(r?.provider, "row provider");
108
+ if (noBench(r?.provider))
109
+ out.push(`${r.provider}: ${NO_BENCH}`);
91
110
  if (!isNum(r?.runs, 1, 1000))
92
111
  out.push(`${r?.provider}: runs is not a positive number`);
93
112
  if (!isNum(r?.failures, 0, r?.runs ?? 0))
@@ -113,8 +132,11 @@ export function validateResult(j, now = Date.now()) {
113
132
  out.push(`unknown check step "${r?.step}"`);
114
133
  if (!["pass", "fail", "skip", "info"].includes(r?.status))
115
134
  out.push(`${r?.step}: unknown status "${r?.status}"`);
116
- if (!isNum(r?.ms, 0, MAX_MS))
135
+ if (r?.ms !== undefined && !isNum(r.ms, 0, MAX_MS))
117
136
  out.push(`${r?.step}: ms must be between 0 and 15 min`);
137
+ if (noBench(j.provider) && (r?.ms !== undefined || (typeof r?.detail === "string" && TIMING.test(r.detail)))) {
138
+ out.push(`${r?.step}: has timings, but ${j.provider}'s terms don't allow publishing performance information; save with check --save`);
139
+ }
118
140
  }
119
141
  }
120
142
  else if (j.tool === "sandboxrouter-cost") {
@@ -126,6 +148,8 @@ export function validateResult(j, now = Date.now()) {
126
148
  out.push("entries is empty");
127
149
  for (const e of Array.isArray(j.entries) ? j.entries : []) {
128
150
  provider(e?.provider, "entry provider");
151
+ if (noBench(e?.provider))
152
+ out.push(`${e.provider}: ${NO_BENCH}`);
129
153
  if (e?.seconds !== undefined && !isNum(e.seconds, 0, 2 * 24 * 3600))
130
154
  out.push(`${e?.provider}: seconds is out of range`);
131
155
  if (e?.predictedUsd !== undefined && !isNum(e.predictedUsd, 0, 1000))
@@ -206,10 +230,32 @@ export function validateResults(dir = "results", opts = {}) {
206
230
  continue;
207
231
  if (p.startsWith(`${dir}/conformance/`) || p.startsWith(`${dir}/cost/`))
208
232
  continue;
209
- if (/^[MR]/.test(c.status))
233
+ if (/^[MR]/.test(c.status) && !onlyDropsUnpublishable(opts.previous?.(p), p)) {
210
234
  issues.push({ file: p, problem: "edits an existing bench report; add your run as a new file instead" });
235
+ }
211
236
  if (c.status.startsWith("D"))
212
237
  issues.push({ file: p, problem: "deletes a bench report; reports are only removed by a maintainer" });
213
238
  }
214
239
  return issues;
215
240
  }
241
+ /**
242
+ * True when a bench report's only change is removing rows for providers whose terms forbid publishing
243
+ * benchmarks: the one edit to someone's report that's allowed, so those numbers can be taken down.
244
+ */
245
+ function onlyDropsUnpublishable(before, path) {
246
+ if (before === undefined || !existsSync(path))
247
+ return false;
248
+ try {
249
+ const a = JSON.parse(before);
250
+ const b = JSON.parse(readFileSync(path, "utf8"));
251
+ const { rows: ra, ...restA } = a;
252
+ const { rows: rb, ...restB } = b;
253
+ if (JSON.stringify(restA) !== JSON.stringify(restB))
254
+ return false;
255
+ const kept = ra.filter((r) => benchmarksPublishable(r.provider));
256
+ return JSON.stringify(kept) === JSON.stringify(rb);
257
+ }
258
+ catch {
259
+ return false;
260
+ }
261
+ }
package/dist/router.d.ts CHANGED
@@ -5,6 +5,8 @@ export interface Candidate {
5
5
  provider: ProviderId;
6
6
  estimatedHourlyUsd: number;
7
7
  medianCreateMs?: number;
8
+ /** Passed the real-account check (as of this release, or `RouterOptions.verified`). */
9
+ verified: boolean;
8
10
  /** Why this provider won't be tried, if it won't. */
9
11
  skip?: string;
10
12
  }
@@ -21,6 +23,11 @@ export interface RouterOptions {
21
23
  * false to keep no record. SANDBOXROUTER_LEDGER=off also turns it off.
22
24
  */
23
25
  ledger?: string | false;
26
+ /**
27
+ * Providers the default "verified" strategy tries first. Default: those whose last real-account check
28
+ * passed as of this release (`VERIFIED`). Pass your own list, e.g. after running `check` yourself.
29
+ */
30
+ verified?: ProviderId[];
24
31
  }
25
32
  /** Desktop-only providers. Filled in as adapters are added. */
26
33
  export declare function defaultComputerProviders(): ComputerProvider[];
@@ -28,6 +35,7 @@ export declare function defaultProviders(): Provider[];
28
35
  export declare class Router {
29
36
  private providers;
30
37
  private benchFile?;
38
+ private readonly verified;
31
39
  private computerOnly;
32
40
  /** The local record of sandboxes this router created, or undefined when turned off. */
33
41
  readonly ledger?: Ledger;
package/dist/router.js CHANGED
@@ -3,6 +3,7 @@ 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
5
  import { DEFAULT_EXEC_TIMEOUT_S, errorStatus, timeoutError } from "./util.js";
6
+ import { VERIFIED } from "./verified.js";
6
7
  import { DaytonaProvider } from "./providers/daytona.js";
7
8
  import { E2BProvider } from "./providers/e2b.js";
8
9
  import { MaritimeProvider } from "./providers/maritime.js";
@@ -87,6 +88,7 @@ export function defaultProviders() {
87
88
  export class Router {
88
89
  providers;
89
90
  benchFile;
91
+ verified;
90
92
  computerOnly;
91
93
  /** The local record of sandboxes this router created, or undefined when turned off. */
92
94
  ledger;
@@ -96,6 +98,7 @@ export class Router {
96
98
  this.ledger = new Ledger(file);
97
99
  this.providers = new Map((opts.providers ?? defaultProviders()).map((p) => [p.id, p]));
98
100
  this.benchFile = opts.benchFile;
101
+ this.verified = new Set(opts.verified ?? Object.keys(VERIFIED));
99
102
  this.computerOnly = opts.computers ?? (opts.providers ? [] : defaultComputerProviders());
100
103
  }
101
104
  /** Registered and has credentials. False for providers this router doesn't know. */
@@ -122,12 +125,13 @@ export class Router {
122
125
  const list = ids.map((id) => {
123
126
  // An id the catalog has never heard of (a typo in an explicit list) has no price to look up.
124
127
  if (!CATALOG.some((c) => c.id === id))
125
- return { provider: id, estimatedHourlyUsd: 0, skip: "unknown provider" };
128
+ return { provider: id, estimatedHourlyUsd: 0, verified: false, skip: "unknown provider" };
126
129
  const p = this.providers.get(id);
127
130
  const c = {
128
131
  provider: id,
129
132
  estimatedHourlyUsd: round(hourlyCost(id, spec)),
130
133
  medianCreateMs: median(bench[id]?.createMs),
134
+ verified: this.verified.has(id),
131
135
  };
132
136
  if (!p)
133
137
  c.skip = "no adapter registered";
@@ -137,16 +141,20 @@ export class Router {
137
141
  }
138
142
  return c;
139
143
  });
140
- // Neutral by construction: price (or measured speed) decides, ties break alphabetically by id.
144
+ // Neutral by construction: the same rule for every provider, ties alphabetical by id. The default
145
+ // puts providers that passed the real-account check first; any provider gets there by passing it.
141
146
  // An explicit provider list keeps the caller's order.
142
147
  if (!explicit) {
143
148
  const byPrice = (a, b) => a.estimatedHourlyUsd - b.estimatedHourlyUsd || a.provider.localeCompare(b.provider);
144
149
  if (route.strategy === "fastest") {
145
150
  list.sort((a, b) => (a.medianCreateMs ?? Infinity) - (b.medianCreateMs ?? Infinity) || byPrice(a, b));
146
151
  }
147
- else {
152
+ else if (route.strategy === "cheapest") {
148
153
  list.sort(byPrice);
149
154
  }
155
+ else {
156
+ list.sort((a, b) => Number(b.verified) - Number(a.verified) || byPrice(a, b));
157
+ }
150
158
  }
151
159
  return list;
152
160
  }
@@ -211,7 +219,7 @@ export class Router {
211
219
  const setup = err instanceof SetupError || (status !== undefined && SETUP_ERRORS.has(status));
212
220
  if (route.fallback !== "any" && setup) {
213
221
  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), ` +
222
+ 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
223
  `so the router stopped instead of moving the work to another provider; pass fallback: "any" to fall back on every error`, attempts);
216
224
  }
217
225
  }
@@ -336,8 +344,12 @@ function round(n) {
336
344
  }
337
345
  /** Seconds to wait for one provider's create before trying the next (RouteOptions.createTimeoutSeconds). */
338
346
  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]);
347
+ /**
348
+ * HTTP statuses that mean the request or the key is wrong, not that the provider is down. 402 (out of
349
+ * balance, plan needed) isn't one: an unfunded account is exactly when the next provider should take the
350
+ * sandbox, and the failed attempt stays on `attempts` (the CLI prints it), so it isn't hidden.
351
+ */
352
+ const SETUP_ERRORS = new Set([400, 401, 403, 404, 422]);
341
353
  /** Seconds past a command's timeout before the router stops waiting for the adapter. */
342
354
  export const EXEC_GRACE_S = 30;
343
355
  /** Resolve with `work`, or reject with `onExpire()` after `ms`. A late `work` rejection is swallowed. */
package/dist/server.d.ts CHANGED
@@ -1,5 +1,28 @@
1
1
  import { type IncomingMessage, type ServerResponse } from "node:http";
2
- import { Router } from "./router.js";
2
+ import { Router, type RoutedSandbox } from "./router.js";
3
+ import { type RouteOptions, type SandboxSpec } from "./types.js";
4
+ /**
5
+ * Hooks for running this API for many users, as the hosted service does (hosted/). With none set,
6
+ * the server is single-user: one SANDBOXROUTER_TOKEN, every sandbox reachable by anyone holding it.
7
+ */
8
+ export interface ServerHooks {
9
+ /** Who is calling, or null for 401. Replaces the single-token check. */
10
+ authorize?(req: IncomingMessage): Promise<unknown | null>;
11
+ /** Routes outside /v1 (sign-in, billing webhooks, pages), checked before auth. Return true if handled. */
12
+ extra?(req: IncomingMessage, res: ServerResponse, url: URL): Promise<boolean>;
13
+ /** Before a sandbox starts. Throw HttpError to refuse (402 out of credit, 429 too many); may edit spec. */
14
+ beforeCreate?(caller: unknown, spec: SandboxSpec, route: RouteOptions): Promise<void>;
15
+ /** After it started. If this throws, the sandbox is killed and the error returned. */
16
+ afterCreate?(caller: unknown, sbx: RoutedSandbox, spec: SandboxSpec): Promise<void>;
17
+ /** May this caller use this sandbox? No means 404. */
18
+ owns?(caller: unknown, id: string): Promise<boolean>;
19
+ /** After DELETE killed it. */
20
+ afterKill?(caller: unknown, id: string): Promise<void>;
21
+ /** false turns off /v1/computers. */
22
+ computers?: boolean;
23
+ /** false turns off pause. */
24
+ pause?: boolean;
25
+ }
3
26
  /**
4
27
  * OpenRouter-style HTTP API in front of every provider. One token for your clients; provider keys
5
28
  * stay on this host.
@@ -28,4 +51,8 @@ import { Router } from "./router.js";
28
51
  */
29
52
  export declare function createApp(router?: Router, token?: string | undefined,
30
53
  /** Browser origins allowed to call this server (e.g. the website's Playground). Off by default. */
31
- corsOrigins?: string[]): import("node:http").Server<typeof IncomingMessage, typeof ServerResponse>;
54
+ corsOrigins?: string[], hooks?: ServerHooks): import("node:http").Server<typeof IncomingMessage, typeof ServerResponse>;
55
+ export declare class HttpError extends Error {
56
+ readonly status: number;
57
+ constructor(status: number, message: string);
58
+ }