@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/README.md +55 -434
- package/dist/catalog.d.ts +13 -0
- package/dist/catalog.js +14 -0
- package/dist/cli.js +14 -6
- package/dist/client.d.ts +58 -0
- package/dist/client.js +140 -0
- package/dist/cost.d.ts +7 -0
- package/dist/cost.js +13 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/leaderboard.js +3 -2
- package/dist/results.d.ts +12 -0
- package/dist/results.js +49 -3
- package/dist/router.d.ts +8 -0
- package/dist/router.js +18 -6
- package/dist/server.d.ts +29 -2
- package/dist/server.js +32 -6
- package/dist/types.d.ts +10 -6
- package/dist/verified.d.ts +2 -0
- package/dist/verified.js +7 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/client.d.ts
ADDED
|
@@ -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
|
-
|
|
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";
|
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 {
|
|
@@ -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
|
-
|
|
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
|
|
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:
|
|
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,
|
|
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
|
-
/**
|
|
340
|
-
|
|
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
|
+
}
|