@sandboxrouter/sdk 0.3.1 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +19 -6
- package/dist/conformance.d.ts +4 -0
- package/dist/conformance.js +1 -0
- package/dist/cost.d.ts +3 -0
- package/dist/cost.js +1 -1
- package/dist/leaderboard.d.ts +3 -0
- package/dist/leaderboard.js +1 -0
- package/dist/providers/blaxel.js +2 -2
- package/dist/providers/deno.js +2 -2
- package/dist/results.d.ts +33 -0
- package/dist/results.js +215 -0
- package/dist/router.js +7 -5
- package/dist/types.d.ts +7 -0
- package/dist/types.js +10 -0
- package/dist/util.d.ts +7 -0
- package/dist/util.js +28 -2
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -6,6 +6,7 @@ import { bench, benchMarkdown } from "./bench.js";
|
|
|
6
6
|
import { costMarkdown, costRun, settle } from "./cost.js";
|
|
7
7
|
import { checkProvider } from "./check.js";
|
|
8
8
|
import { gc, listOpen } from "./gc.js";
|
|
9
|
+
import { contributor } from "./results.js";
|
|
9
10
|
import { loadReports, makeReport, renderLeaderboard } from "./leaderboard.js";
|
|
10
11
|
import { DEFAULT_MEMORY_GB, DEFAULT_VCPU, accessLabel, hourlyCost, listingOrder, planLabel, unfit } from "./catalog.js";
|
|
11
12
|
import { Router } from "./router.js";
|
|
@@ -17,15 +18,15 @@ Usage:
|
|
|
17
18
|
sandboxrouter providers [--vcpu N] [--memory GB] price table + which keys are set
|
|
18
19
|
sandboxrouter route [spec flags] [route flags] dry run: the order providers would be tried
|
|
19
20
|
sandboxrouter run "<cmd>" [spec flags] [route flags] [--keep]
|
|
20
|
-
sandboxrouter check --provider ID[,ID] | --all [--json] [--save [dir]]
|
|
21
|
+
sandboxrouter check --provider ID[,ID] | --all [--json] [--save [dir]] [--by GITHUB_USER]
|
|
21
22
|
test providers for real: exec, env, UTF-8, streaming, files, timeout, pause,
|
|
22
23
|
kill; exits 1 if any step fails. --all = every configured provider;
|
|
23
24
|
--save writes results/conformance/<id>.json for the compatibility table
|
|
24
25
|
sandboxrouter ls [--json] sandboxes this machine started that weren't killed
|
|
25
26
|
sandboxrouter gc [--all | --older-than MIN] [--dry-run]
|
|
26
27
|
kill leaked sandboxes: by default those past their lifetime
|
|
27
|
-
sandboxrouter bench [--runs 3] [--provider a,b] [--json] [--share [--label "where you ran it"]]
|
|
28
|
-
sandboxrouter cost run [--provider a,b] [--minutes 10] [--vcpu 2 --memory 4] [--no-load] [--out FILE]
|
|
28
|
+
sandboxrouter bench [--runs 3] [--provider a,b] [--json] [--share [--label "where you ran it"] [--by GITHUB_USER]]
|
|
29
|
+
sandboxrouter cost run [--provider a,b] [--minutes 10] [--vcpu 2 --memory 4] [--no-load] [--out FILE] [--by GITHUB_USER]
|
|
29
30
|
start one sandbox per provider for a fixed time with every core busy, record
|
|
30
31
|
the size it really got and the list-price prediction
|
|
31
32
|
sandboxrouter cost settle FILE
|
|
@@ -42,7 +43,8 @@ Route flags: --provider e2b[,modal,...] --strategy cheapest|fastest
|
|
|
42
43
|
--no-fallback --fallback-any (also on setup errors like a bad key)
|
|
43
44
|
--create-timeout SEC (per provider, default 120) --require gpu,custom-image
|
|
44
45
|
|
|
45
|
-
Keys: one env var group per provider; "sandboxrouter providers" lists them and shows which are set
|
|
46
|
+
Keys: one env var group per provider; "sandboxrouter providers" lists them and shows which are set.
|
|
47
|
+
--by puts your GitHub username on a saved result, so the website can credit it.`;
|
|
46
48
|
const { values, positionals } = parseArgs({
|
|
47
49
|
allowPositionals: true,
|
|
48
50
|
options: {
|
|
@@ -75,6 +77,7 @@ const { values, positionals } = parseArgs({
|
|
|
75
77
|
"dry-run": { type: "boolean" },
|
|
76
78
|
minutes: { type: "string" },
|
|
77
79
|
"no-load": { type: "boolean" },
|
|
80
|
+
by: { type: "string" },
|
|
78
81
|
},
|
|
79
82
|
});
|
|
80
83
|
const list = (s) => (s ? s.split(",").map((x) => x.trim()).filter(Boolean) : undefined);
|
|
@@ -92,6 +95,15 @@ const spec = {
|
|
|
92
95
|
timeoutMinutes: num(values.timeout),
|
|
93
96
|
};
|
|
94
97
|
const providers = list(values.provider);
|
|
98
|
+
const by = (() => {
|
|
99
|
+
try {
|
|
100
|
+
return contributor(values.by);
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
console.error(`error: ${e.message}`);
|
|
104
|
+
process.exit(2);
|
|
105
|
+
}
|
|
106
|
+
})();
|
|
95
107
|
const route = {
|
|
96
108
|
provider: providers,
|
|
97
109
|
strategy: values.strategy,
|
|
@@ -203,7 +215,7 @@ async function main() {
|
|
|
203
215
|
reports.push(report);
|
|
204
216
|
if (saveDir) {
|
|
205
217
|
mkdirSync(saveDir, { recursive: true });
|
|
206
|
-
const saved = { tool: "sandboxrouter-check", version, date: new Date().toISOString(), platform: `${platform()}-${arch()}`, ...report };
|
|
218
|
+
const saved = { tool: "sandboxrouter-check", version, date: new Date().toISOString(), platform: `${platform()}-${arch()}`, ...(by ? { by } : {}), ...report };
|
|
207
219
|
writeFileSync(`${saveDir}/${p}.json`, JSON.stringify(saved, null, 2) + "\n");
|
|
208
220
|
}
|
|
209
221
|
}
|
|
@@ -264,7 +276,7 @@ async function main() {
|
|
|
264
276
|
console.error(`\n${r.provider} last error: ${r.lastError}`);
|
|
265
277
|
if (values.share) {
|
|
266
278
|
const version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
267
|
-
const report = makeReport(rows, { version, runs: rows[0].runs, spec, label: values.label });
|
|
279
|
+
const report = makeReport(rows, { version, runs: rows[0].runs, spec, label: values.label, by });
|
|
268
280
|
const file = values.out ?? `sandboxrouter-bench-${report.date.slice(0, 10)}-${Math.random().toString(36).slice(2, 8)}.json`;
|
|
269
281
|
writeFileSync(file, JSON.stringify(report, null, 2) + "\n");
|
|
270
282
|
console.log(`\nwrote ${file}. Add it to results/ in a PR to github.com/gigialc/sandboxrouter to put it on the leaderboard.`);
|
|
@@ -284,6 +296,7 @@ async function main() {
|
|
|
284
296
|
spec,
|
|
285
297
|
load: !values["no-load"],
|
|
286
298
|
version,
|
|
299
|
+
by,
|
|
287
300
|
onProgress: (m) => console.error(m),
|
|
288
301
|
});
|
|
289
302
|
const file = values.out ?? `results/cost/cost-${run.date.slice(0, 16).replace(/[:T]/g, "-")}.json`;
|
package/dist/conformance.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export interface SavedCheck extends CheckReport {
|
|
|
6
6
|
version: string;
|
|
7
7
|
date: string;
|
|
8
8
|
platform: string;
|
|
9
|
+
/** GitHub username of whoever ran it, from `--by`. */
|
|
10
|
+
by?: string;
|
|
9
11
|
}
|
|
10
12
|
export interface ConformanceRow {
|
|
11
13
|
provider: ProviderId;
|
|
@@ -14,6 +16,8 @@ export interface ConformanceRow {
|
|
|
14
16
|
run: null | {
|
|
15
17
|
date: string;
|
|
16
18
|
version: string;
|
|
19
|
+
/** GitHub username of whoever ran it, when they gave one. */
|
|
20
|
+
by: string | null;
|
|
17
21
|
passed: number;
|
|
18
22
|
/** Steps that ran and failed, with why. */
|
|
19
23
|
failed: {
|
package/dist/conformance.js
CHANGED
|
@@ -52,6 +52,7 @@ export function conformanceTable(saved = loadConformance()) {
|
|
|
52
52
|
run: {
|
|
53
53
|
date: r.date,
|
|
54
54
|
version: r.version,
|
|
55
|
+
by: r.by ?? null,
|
|
55
56
|
passed: pick("pass").length + pick("info").length,
|
|
56
57
|
failed: pick("fail").map((x) => ({ step: x.step, detail: x.detail })),
|
|
57
58
|
skipped: pick("skip").map((x) => x.step),
|
package/dist/cost.d.ts
CHANGED
|
@@ -48,6 +48,8 @@ export interface CostRun {
|
|
|
48
48
|
date: string;
|
|
49
49
|
minutes: number;
|
|
50
50
|
load: boolean;
|
|
51
|
+
/** GitHub username of whoever ran it, from `--by`. */
|
|
52
|
+
by?: string;
|
|
51
53
|
entries: CostEntry[];
|
|
52
54
|
}
|
|
53
55
|
export declare function costRun(opts: {
|
|
@@ -57,6 +59,7 @@ export declare function costRun(opts: {
|
|
|
57
59
|
load?: boolean;
|
|
58
60
|
router?: Router;
|
|
59
61
|
version: string;
|
|
62
|
+
by?: string;
|
|
60
63
|
onProgress?: (msg: string) => void;
|
|
61
64
|
}): Promise<CostRun>;
|
|
62
65
|
type Reader = (e: CostEntry) => Promise<Omit<Bill, "at">>;
|
package/dist/cost.js
CHANGED
|
@@ -46,7 +46,7 @@ export async function costRun(opts) {
|
|
|
46
46
|
return entry;
|
|
47
47
|
};
|
|
48
48
|
const entries = await Promise.all(opts.providers.map(one));
|
|
49
|
-
return { tool: "sandboxrouter-cost", version: opts.version, date: new Date().toISOString(), minutes: opts.minutes, load, entries };
|
|
49
|
+
return { tool: "sandboxrouter-cost", version: opts.version, date: new Date().toISOString(), minutes: opts.minutes, load, ...(opts.by ? { by: opts.by } : {}), entries };
|
|
50
50
|
}
|
|
51
51
|
async function getJson(url, headers) {
|
|
52
52
|
const r = await fetch(url, { headers, signal: AbortSignal.timeout(30_000) });
|
package/dist/leaderboard.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ export interface BenchReport {
|
|
|
11
11
|
date: string;
|
|
12
12
|
/** Free text from the submitter, e.g. "us-east-1 c7g.large" or "Berlin, home fiber". */
|
|
13
13
|
label?: string;
|
|
14
|
+
/** GitHub username of whoever ran it, from `--by`. */
|
|
15
|
+
by?: string;
|
|
14
16
|
runs: number;
|
|
15
17
|
spec: SandboxSpec;
|
|
16
18
|
node: string;
|
|
@@ -22,6 +24,7 @@ export declare function makeReport(rows: BenchRow[], opts: {
|
|
|
22
24
|
runs: number;
|
|
23
25
|
spec: SandboxSpec;
|
|
24
26
|
label?: string;
|
|
27
|
+
by?: string;
|
|
25
28
|
}): BenchReport;
|
|
26
29
|
export declare function loadReports(dir: string): BenchReport[];
|
|
27
30
|
export interface LeaderRow {
|
package/dist/leaderboard.js
CHANGED
|
@@ -8,6 +8,7 @@ export function makeReport(rows, opts) {
|
|
|
8
8
|
version: opts.version,
|
|
9
9
|
date: new Date().toISOString(),
|
|
10
10
|
label: opts.label,
|
|
11
|
+
...(opts.by ? { by: opts.by } : {}),
|
|
11
12
|
runs: opts.runs,
|
|
12
13
|
// Only the shape matters for comparison; never ship env or metadata.
|
|
13
14
|
spec: { vcpu: opts.spec.vcpu, memoryGb: opts.spec.memoryGb },
|
package/dist/providers/blaxel.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { DEFAULT_MEMORY_GB, DEFAULT_VCPU } from "../catalog.js";
|
|
2
2
|
import { ProviderError } from "../types.js";
|
|
3
|
-
import { DEFAULT_EXEC_TIMEOUT_S, optionalImport, timeoutError, toBytes } from "../util.js";
|
|
3
|
+
import { DEFAULT_EXEC_TIMEOUT_S, errorStatus, optionalImport, timeoutError, toBytes } from "../util.js";
|
|
4
4
|
/** Blaxel's public base image (the SDK's own default). Set image.blaxel for another image or a `bl deploy` template. */
|
|
5
5
|
const DEFAULT_IMAGE = "blaxel/base-image:latest";
|
|
6
6
|
/** Blaxel rejects a max-age ttl under 5 minutes. */
|
|
@@ -155,7 +155,7 @@ class BlaxelBox {
|
|
|
155
155
|
function wrap(err) {
|
|
156
156
|
if (err instanceof ProviderError)
|
|
157
157
|
return err;
|
|
158
|
-
const status =
|
|
158
|
+
const status = errorStatus(err) ?? (typeof err?.code === "number" ? err.code : undefined);
|
|
159
159
|
// The SDK sometimes throws the plain API body ({ code, error }), not an Error.
|
|
160
160
|
const message = err?.message ?? err?.error ?? (typeof err === "object" ? JSON.stringify(err) : String(err));
|
|
161
161
|
return new ProviderError("blaxel", message, status);
|
package/dist/providers/deno.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ProviderError } from "../types.js";
|
|
1
|
+
import { ProviderError, SetupError } from "../types.js";
|
|
2
2
|
import { dirname, optionalImport, toBytes } from "../util.js";
|
|
3
3
|
/**
|
|
4
4
|
* Deno Sandbox (Linux microVMs on Deno Deploy) via the official `@deno/sandbox` SDK. The SDK is
|
|
@@ -43,7 +43,7 @@ export class DenoProvider {
|
|
|
43
43
|
if (!this.token)
|
|
44
44
|
throw new ProviderError(ID, "DENO_DEPLOY_TOKEN is not set");
|
|
45
45
|
if (!this.token.startsWith("ddo_") && !this.org)
|
|
46
|
-
throw new
|
|
46
|
+
throw new SetupError(`${ID}: a personal DENO_DEPLOY_TOKEN needs DENO_DEPLOY_ORG`);
|
|
47
47
|
return { token: this.token, ...(this.org ? { org: this.org } : {}) };
|
|
48
48
|
}
|
|
49
49
|
async create(spec) {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks for the files people submit to results/: bench reports, saved `check` runs and `cost` runs.
|
|
3
|
+
*
|
|
4
|
+
* Submissions are self-reported, so nothing here can prove a number is real. What it can do is keep
|
|
5
|
+
* the obvious problems out before a maintainer reviews the PR: files that aren't one of the three
|
|
6
|
+
* formats, unknown providers, impossible numbers, anything that looks like a key, and labels that
|
|
7
|
+
* look like a home address or an email. CI runs it on every push and pull request
|
|
8
|
+
* (`npm run results:check`), and on a pull request it also refuses edits to someone else's bench
|
|
9
|
+
* report: a new run is a new file.
|
|
10
|
+
*/
|
|
11
|
+
export interface Issue {
|
|
12
|
+
file: string;
|
|
13
|
+
problem: string;
|
|
14
|
+
}
|
|
15
|
+
/** A GitHub handle, without the "@": what `--by` accepts and what reports store. */
|
|
16
|
+
export declare function contributor(by?: string): string | undefined;
|
|
17
|
+
/** Problems with one parsed result file. `now` is injectable for tests. */
|
|
18
|
+
export declare function validateResult(j: any, now?: number): string[];
|
|
19
|
+
/** Problems found anywhere in a file's raw text: secrets, emails, IP addresses. */
|
|
20
|
+
export declare function scanText(text: string): string[];
|
|
21
|
+
/**
|
|
22
|
+
* Every problem in a results directory. `changed` is the PR's `git diff --name-status` against its
|
|
23
|
+
* base, when there is one: a modified bench report means someone edited a run, which is refused.
|
|
24
|
+
* Saved checks (one per provider, replaced by each new run) and cost runs (`cost settle` fills in the
|
|
25
|
+
* bill later) may change.
|
|
26
|
+
*/
|
|
27
|
+
export declare function validateResults(dir?: string, opts?: {
|
|
28
|
+
changed?: {
|
|
29
|
+
status: string;
|
|
30
|
+
path: string;
|
|
31
|
+
}[];
|
|
32
|
+
now?: number;
|
|
33
|
+
}): Issue[];
|
package/dist/results.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import { CATALOG } from "./catalog.js";
|
|
4
|
+
import { CHECK_STEPS } from "./check.js";
|
|
5
|
+
/** A GitHub handle, without the "@": what `--by` accepts and what reports store. */
|
|
6
|
+
export function contributor(by) {
|
|
7
|
+
if (by === undefined)
|
|
8
|
+
return undefined;
|
|
9
|
+
const h = by.trim().replace(/^@/, "");
|
|
10
|
+
if (!/^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/.test(h)) {
|
|
11
|
+
throw new Error(`--by takes a GitHub username, e.g. --by octocat (got "${by}")`);
|
|
12
|
+
}
|
|
13
|
+
return h;
|
|
14
|
+
}
|
|
15
|
+
const IDS = new Set(CATALOG.map((c) => c.id));
|
|
16
|
+
const STEPS = new Set(CHECK_STEPS);
|
|
17
|
+
const MAX_MS = 15 * 60_000;
|
|
18
|
+
/** Key and token shapes from the providers here and the usual suspects. Matches print as a prefix only. */
|
|
19
|
+
const SECRET_SHAPES = [
|
|
20
|
+
["Stripe-style or Sail key", /\bsk_(?:live_|test_)?[A-Za-z0-9]{16,}/],
|
|
21
|
+
["OpenAI-style key", /\bsk-[A-Za-z0-9_-]{20,}/],
|
|
22
|
+
["E2B key", /\be2b_[A-Za-z0-9]{20,}/],
|
|
23
|
+
["Daytona key", /\bdtn_[A-Za-z0-9]{20,}/],
|
|
24
|
+
["LangSmith key", /\blsv2_(?:sk|pt)_[A-Za-z0-9_]{20,}/],
|
|
25
|
+
["AWS access key", /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/],
|
|
26
|
+
["GitHub token", /\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,})/],
|
|
27
|
+
["Slack token", /\bxox[abprs]-[A-Za-z0-9-]{10,}/],
|
|
28
|
+
["JWT", /\beyJ[A-Za-z0-9_-]{15,}\.eyJ[A-Za-z0-9_-]{15,}\./],
|
|
29
|
+
["bearer token", /\bBearer\s+[A-Za-z0-9._~+/-]{20,}/],
|
|
30
|
+
["private key", /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
|
|
31
|
+
["key=value secret", /\b[A-Z][A-Z0-9_]*(?:API_KEY|TOKEN|SECRET)["']?\s*[:=]\s*["']?[A-Za-z0-9_\-./+]{16,}/],
|
|
32
|
+
];
|
|
33
|
+
const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}/;
|
|
34
|
+
const IPV4 = /\b(?:25[0-5]|2[0-4]\d|1?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}\b/;
|
|
35
|
+
/** "12 Main Street", "Calle Mayor 5", "Apt 4B": a street or a flat, which a label never needs. */
|
|
36
|
+
const ADDRESS = /\b\d+[A-Za-z]?\s+(?:[A-Za-zÀ-ÿ'.-]+\s+){0,3}(?:street|st|road|rd|avenue|ave|lane|ln|drive|dr|way|court|ct|place|pl|boulevard|blvd|terrace|close)\b\.?|\b(?:calle|carrer|rue|via|avenida|straße|strasse|str)\.?\s+[A-Za-zÀ-ÿ'.-]+(?:\s+[A-Za-zÀ-ÿ'.-]+){0,3}\s+\d+|\b(?:apt|apartment|flat|suite|unit|piso|puerta)\.?\s*#?\d+/i;
|
|
37
|
+
const isNum = (n, min = 0, max = Infinity) => typeof n === "number" && Number.isFinite(n) && n >= min && n <= max;
|
|
38
|
+
function checkDate(iso, now) {
|
|
39
|
+
if (typeof iso !== "string" || Number.isNaN(Date.parse(iso)))
|
|
40
|
+
return "date is missing or not an ISO date";
|
|
41
|
+
if (Date.parse(iso) > now + 86_400_000)
|
|
42
|
+
return "date is in the future";
|
|
43
|
+
if (Date.parse(iso) < Date.parse("2026-01-01"))
|
|
44
|
+
return "date is before sandboxrouter existed";
|
|
45
|
+
}
|
|
46
|
+
function checkLabel(label) {
|
|
47
|
+
if (label === undefined || label === null)
|
|
48
|
+
return;
|
|
49
|
+
if (typeof label !== "string")
|
|
50
|
+
return "label must be text";
|
|
51
|
+
if (label.length > 80)
|
|
52
|
+
return "label is longer than 80 characters";
|
|
53
|
+
if (EMAIL.test(label))
|
|
54
|
+
return "label contains an email address";
|
|
55
|
+
if (IPV4.test(label))
|
|
56
|
+
return "label contains an IP address";
|
|
57
|
+
if (ADDRESS.test(label))
|
|
58
|
+
return 'label looks like a street address; use a city or cloud region, e.g. "Berlin" or "us-east-1"';
|
|
59
|
+
}
|
|
60
|
+
function checkBy(by) {
|
|
61
|
+
if (by === undefined)
|
|
62
|
+
return;
|
|
63
|
+
try {
|
|
64
|
+
if (typeof by !== "string" || contributor(by) !== by)
|
|
65
|
+
return 'by must be a GitHub username without the "@"';
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
return e.message;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Problems with one parsed result file. `now` is injectable for tests. */
|
|
72
|
+
export function validateResult(j, now = Date.now()) {
|
|
73
|
+
const out = [];
|
|
74
|
+
const add = (p) => p && out.push(p);
|
|
75
|
+
const provider = (id, where = "provider") => {
|
|
76
|
+
if (typeof id !== "string" || !IDS.has(id))
|
|
77
|
+
out.push(`${where} "${String(id)}" is not in the catalog`);
|
|
78
|
+
};
|
|
79
|
+
if (!j || typeof j !== "object" || Array.isArray(j))
|
|
80
|
+
return ["not a result file: expected a JSON object with a \"tool\" field"];
|
|
81
|
+
if (j.tool === "sandboxrouter") {
|
|
82
|
+
add(checkDate(j.date, now));
|
|
83
|
+
add(checkLabel(j.label));
|
|
84
|
+
add(checkBy(j.by));
|
|
85
|
+
if (!isNum(j.runs, 3, 1000))
|
|
86
|
+
out.push("runs must be at least 3");
|
|
87
|
+
if (!Array.isArray(j.rows) || !j.rows.length)
|
|
88
|
+
out.push("rows is empty");
|
|
89
|
+
for (const r of Array.isArray(j.rows) ? j.rows : []) {
|
|
90
|
+
provider(r?.provider, "row provider");
|
|
91
|
+
if (!isNum(r?.runs, 1, 1000))
|
|
92
|
+
out.push(`${r?.provider}: runs is not a positive number`);
|
|
93
|
+
if (!isNum(r?.failures, 0, r?.runs ?? 0))
|
|
94
|
+
out.push(`${r?.provider}: failures must be between 0 and runs`);
|
|
95
|
+
for (const k of ["medianCreateMs", "medianExecMs"]) {
|
|
96
|
+
if (r?.[k] !== undefined && !isNum(r[k], 1, MAX_MS))
|
|
97
|
+
out.push(`${r?.provider}: ${k} must be between 1 ms and 15 min`);
|
|
98
|
+
}
|
|
99
|
+
if (r?.failures < r?.runs && r?.medianCreateMs === undefined)
|
|
100
|
+
out.push(`${r?.provider}: successful runs but no medianCreateMs`);
|
|
101
|
+
if ("lastError" in (r ?? {}))
|
|
102
|
+
out.push(`${r?.provider}: lastError must not be shared (it can hold account details)`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
else if (j.tool === "sandboxrouter-check") {
|
|
106
|
+
add(checkDate(j.date, now));
|
|
107
|
+
add(checkBy(j.by));
|
|
108
|
+
provider(j.provider);
|
|
109
|
+
if (!Array.isArray(j.results) || !j.results.length)
|
|
110
|
+
out.push("results is empty");
|
|
111
|
+
for (const r of Array.isArray(j.results) ? j.results : []) {
|
|
112
|
+
if (!STEPS.has(r?.step))
|
|
113
|
+
out.push(`unknown check step "${r?.step}"`);
|
|
114
|
+
if (!["pass", "fail", "skip", "info"].includes(r?.status))
|
|
115
|
+
out.push(`${r?.step}: unknown status "${r?.status}"`);
|
|
116
|
+
if (!isNum(r?.ms, 0, MAX_MS))
|
|
117
|
+
out.push(`${r?.step}: ms must be between 0 and 15 min`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
else if (j.tool === "sandboxrouter-cost") {
|
|
121
|
+
add(checkDate(j.date, now));
|
|
122
|
+
add(checkBy(j.by));
|
|
123
|
+
if (!isNum(j.minutes, 1, 24 * 60))
|
|
124
|
+
out.push("minutes must be between 1 and 1440");
|
|
125
|
+
if (!Array.isArray(j.entries) || !j.entries.length)
|
|
126
|
+
out.push("entries is empty");
|
|
127
|
+
for (const e of Array.isArray(j.entries) ? j.entries : []) {
|
|
128
|
+
provider(e?.provider, "entry provider");
|
|
129
|
+
if (e?.seconds !== undefined && !isNum(e.seconds, 0, 2 * 24 * 3600))
|
|
130
|
+
out.push(`${e?.provider}: seconds is out of range`);
|
|
131
|
+
if (e?.predictedUsd !== undefined && !isNum(e.predictedUsd, 0, 1000))
|
|
132
|
+
out.push(`${e?.provider}: predictedUsd is out of range`);
|
|
133
|
+
if (e?.bill?.usd !== undefined && !isNum(e.bill.usd, 0, 1000))
|
|
134
|
+
out.push(`${e?.provider}: bill.usd is out of range`);
|
|
135
|
+
if (e?.delivered && !(isNum(e.delivered.vcpu, 0.1, 1024) && isNum(e.delivered.memoryMb, 64, 4_194_304))) {
|
|
136
|
+
out.push(`${e?.provider}: delivered size is out of range`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
out.push(`unknown tool "${String(j.tool)}": expected a bench report, a saved check or a cost run`);
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
/** Problems found anywhere in a file's raw text: secrets, emails, IP addresses. */
|
|
146
|
+
export function scanText(text) {
|
|
147
|
+
const out = [];
|
|
148
|
+
for (const [what, re] of SECRET_SHAPES) {
|
|
149
|
+
const m = text.match(re);
|
|
150
|
+
if (m)
|
|
151
|
+
out.push(`looks like a ${what} ("${m[0].slice(0, 6)}…"); never commit keys`);
|
|
152
|
+
}
|
|
153
|
+
const email = text.match(EMAIL);
|
|
154
|
+
if (email)
|
|
155
|
+
out.push(`contains an email address ("${email[0].replace(/^(.).*@/, "$1…@")}")`);
|
|
156
|
+
const ip = text.match(IPV4);
|
|
157
|
+
if (ip && !/^0\.|^127\./.test(ip[0]))
|
|
158
|
+
out.push(`contains an IP address ("${ip[0]}")`);
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
/** Files in results/ that aren't submissions: notes and the maintainers' price audits. */
|
|
162
|
+
const NOT_A_SUBMISSION = (rel) => /(^|\/)README\.md$/.test(rel) || /(^|\/)price-audit-[^/]+$/.test(rel);
|
|
163
|
+
function walk(dir) {
|
|
164
|
+
if (!existsSync(dir))
|
|
165
|
+
return [];
|
|
166
|
+
return readdirSync(dir).flatMap((f) => {
|
|
167
|
+
const p = join(dir, f);
|
|
168
|
+
return statSync(p).isDirectory() ? walk(p) : [p];
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Every problem in a results directory. `changed` is the PR's `git diff --name-status` against its
|
|
173
|
+
* base, when there is one: a modified bench report means someone edited a run, which is refused.
|
|
174
|
+
* Saved checks (one per provider, replaced by each new run) and cost runs (`cost settle` fills in the
|
|
175
|
+
* bill later) may change.
|
|
176
|
+
*/
|
|
177
|
+
export function validateResults(dir = "results", opts = {}) {
|
|
178
|
+
const issues = [];
|
|
179
|
+
for (const path of walk(dir)) {
|
|
180
|
+
const rel = relative(".", path);
|
|
181
|
+
if (NOT_A_SUBMISSION(relative(dir, path)))
|
|
182
|
+
continue;
|
|
183
|
+
if (path.endsWith(".DS_Store"))
|
|
184
|
+
continue;
|
|
185
|
+
if (!path.endsWith(".json")) {
|
|
186
|
+
issues.push({ file: rel, problem: "only .json result files belong here" });
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const text = readFileSync(path, "utf8");
|
|
190
|
+
for (const problem of scanText(text))
|
|
191
|
+
issues.push({ file: rel, problem });
|
|
192
|
+
let j;
|
|
193
|
+
try {
|
|
194
|
+
j = JSON.parse(text);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
issues.push({ file: rel, problem: "is not valid JSON" });
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
for (const problem of validateResult(j, opts.now))
|
|
201
|
+
issues.push({ file: rel, problem });
|
|
202
|
+
}
|
|
203
|
+
for (const c of opts.changed ?? []) {
|
|
204
|
+
const p = c.path;
|
|
205
|
+
if (!p.startsWith(`${dir}/`) || NOT_A_SUBMISSION(p.slice(dir.length + 1)))
|
|
206
|
+
continue;
|
|
207
|
+
if (p.startsWith(`${dir}/conformance/`) || p.startsWith(`${dir}/cost/`))
|
|
208
|
+
continue;
|
|
209
|
+
if (/^[MR]/.test(c.status))
|
|
210
|
+
issues.push({ file: p, problem: "edits an existing bench report; add your run as a new file instead" });
|
|
211
|
+
if (c.status.startsWith("D"))
|
|
212
|
+
issues.push({ file: p, problem: "deletes a bench report; reports are only removed by a maintainer" });
|
|
213
|
+
}
|
|
214
|
+
return issues;
|
|
215
|
+
}
|
package/dist/router.js
CHANGED
|
@@ -2,7 +2,7 @@ import { CATALOG, accessLabel, hourlyCost, unfit } from "./catalog.js";
|
|
|
2
2
|
import { RoutedComputer, createComputer, planComputer, } from "./computers.js";
|
|
3
3
|
import { loadBench, median } from "./bench-store.js";
|
|
4
4
|
import { Ledger, defaultLedgerFile } from "./ledger.js";
|
|
5
|
-
import { DEFAULT_EXEC_TIMEOUT_S, timeoutError } from "./util.js";
|
|
5
|
+
import { DEFAULT_EXEC_TIMEOUT_S, errorStatus, timeoutError } from "./util.js";
|
|
6
6
|
import { DaytonaProvider } from "./providers/daytona.js";
|
|
7
7
|
import { E2BProvider } from "./providers/e2b.js";
|
|
8
8
|
import { MaritimeProvider } from "./providers/maritime.js";
|
|
@@ -41,7 +41,7 @@ import { OrgoProvider } from "./providers/orgo.js";
|
|
|
41
41
|
import { MobilerunProvider } from "./providers/mobilerun.js";
|
|
42
42
|
import { LimrunProvider } from "./providers/limrun.js";
|
|
43
43
|
import { WorkspacesAgentProvider } from "./providers/workspaces.js";
|
|
44
|
-
import { ProviderError, RouterError, } from "./types.js";
|
|
44
|
+
import { ProviderError, SetupError, RouterError, } from "./types.js";
|
|
45
45
|
/** Desktop-only providers. Filled in as adapters are added. */
|
|
46
46
|
export function defaultComputerProviders() {
|
|
47
47
|
return [new OrgoProvider(), new MobilerunProvider(), new LimrunProvider(), new WorkspacesAgentProvider()];
|
|
@@ -207,9 +207,11 @@ export class Router {
|
|
|
207
207
|
if (timedOut)
|
|
208
208
|
pending.then((late) => this.discard(c.provider, late, c.estimatedHourlyUsd, spec.timeoutMinutes), () => { });
|
|
209
209
|
attempts.push({ provider: c.provider, ok: false, ms: Date.now() - t0, error: err.message });
|
|
210
|
-
const status = err
|
|
211
|
-
|
|
212
|
-
|
|
210
|
+
const status = errorStatus(err);
|
|
211
|
+
const setup = err instanceof SetupError || (status !== undefined && SETUP_ERRORS.has(status));
|
|
212
|
+
if (route.fallback !== "any" && setup) {
|
|
213
|
+
const msg = err.message.replace(/[.\s]+$/, "");
|
|
214
|
+
throw new RouterError(`${msg.startsWith(c.provider) ? msg : `${c.provider}: ${msg}`}. This looks like a setup problem with ${c.provider} (key, billing, image, request or a missing package), ` +
|
|
213
215
|
`so the router stopped instead of moving the work to another provider; pass fallback: "any" to fall back on every error`, attempts);
|
|
214
216
|
}
|
|
215
217
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -93,3 +93,10 @@ export declare class ProviderError extends Error {
|
|
|
93
93
|
readonly status?: number | undefined;
|
|
94
94
|
constructor(provider: ProviderId | (string & {}), message: string, status?: number | undefined);
|
|
95
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* A problem with local setup, such as a provider SDK that isn't installed or a missing setting:
|
|
98
|
+
* something to fix here, not a provider being down. The router stops on it instead of falling back.
|
|
99
|
+
*/
|
|
100
|
+
export declare class SetupError extends Error {
|
|
101
|
+
constructor(message: string);
|
|
102
|
+
}
|
package/dist/types.js
CHANGED
|
@@ -17,3 +17,13 @@ export class ProviderError extends Error {
|
|
|
17
17
|
this.name = "ProviderError";
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* A problem with local setup, such as a provider SDK that isn't installed or a missing setting:
|
|
22
|
+
* something to fix here, not a provider being down. The router stops on it instead of falling back.
|
|
23
|
+
*/
|
|
24
|
+
export class SetupError extends Error {
|
|
25
|
+
constructor(message) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = "SetupError";
|
|
28
|
+
}
|
|
29
|
+
}
|
package/dist/util.d.ts
CHANGED
|
@@ -36,3 +36,10 @@ export declare function toBytes(content: string | Uint8Array): Uint8Array;
|
|
|
36
36
|
export declare function dirname(path: string): string;
|
|
37
37
|
/** Load an optional peer dependency with a helpful error. */
|
|
38
38
|
export declare function optionalImport<T>(pkg: string, provider: string): Promise<T>;
|
|
39
|
+
/**
|
|
40
|
+
* The HTTP status an error stands for, read from the shapes provider SDKs use: `status`,
|
|
41
|
+
* `statusCode` (Daytona), `status_code` (Blaxel), `response.status` (Vercel), AWS's
|
|
42
|
+
* `$metadata.httpStatusCode`, gRPC codes (Modal), and error classes that only say it by name
|
|
43
|
+
* (E2B's AuthenticationError, AWS's CredentialsProviderError). Undefined when there's none.
|
|
44
|
+
*/
|
|
45
|
+
export declare function errorStatus(err: unknown, depth?: number): number | undefined;
|
package/dist/util.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ProviderError } from "./types.js";
|
|
1
|
+
import { ProviderError, SetupError } from "./types.js";
|
|
2
2
|
/** Per-command timeout when ExecOptions.timeoutSeconds is not given. */
|
|
3
3
|
export const DEFAULT_EXEC_TIMEOUT_S = 60;
|
|
4
4
|
/** Single-quote a string for POSIX sh. */
|
|
@@ -79,8 +79,34 @@ export async function optionalImport(pkg, provider) {
|
|
|
79
79
|
// Bundlers and test runners (Vite, esbuild) report a missing package without Node's error code.
|
|
80
80
|
const missing = /Cannot find (module|package)|Could not resolve|Failed to (resolve|load url)/.test(String(err?.message));
|
|
81
81
|
if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND" || missing) {
|
|
82
|
-
throw new
|
|
82
|
+
throw new SetupError(`${provider} needs the "${pkg}" package: npm i ${pkg}`);
|
|
83
83
|
}
|
|
84
84
|
throw err;
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
|
+
/** gRPC status codes (Modal's SDK) as the HTTP status they correspond to. */
|
|
88
|
+
const GRPC_TO_HTTP = { 3: 400, 5: 404, 7: 403, 8: 429, 14: 503, 16: 401 };
|
|
89
|
+
/**
|
|
90
|
+
* The HTTP status an error stands for, read from the shapes provider SDKs use: `status`,
|
|
91
|
+
* `statusCode` (Daytona), `status_code` (Blaxel), `response.status` (Vercel), AWS's
|
|
92
|
+
* `$metadata.httpStatusCode`, gRPC codes (Modal), and error classes that only say it by name
|
|
93
|
+
* (E2B's AuthenticationError, AWS's CredentialsProviderError). Undefined when there's none.
|
|
94
|
+
*/
|
|
95
|
+
export function errorStatus(err, depth = 0) {
|
|
96
|
+
if (!err || typeof err !== "object" || depth > 3)
|
|
97
|
+
return undefined;
|
|
98
|
+
const e = err;
|
|
99
|
+
for (const v of [e.status, e.statusCode, e.status_code, e.response?.status, e.$metadata?.httpStatusCode])
|
|
100
|
+
if (typeof v === "number" && v >= 100 && v < 600)
|
|
101
|
+
return v;
|
|
102
|
+
if (e.name === "ClientError" && typeof e.code === "number" && GRPC_TO_HTTP[e.code])
|
|
103
|
+
return GRPC_TO_HTTP[e.code];
|
|
104
|
+
const name = String(e.name && e.name !== "Error" ? e.name : (e.constructor?.name ?? ""));
|
|
105
|
+
if (/AuthenticationError$/.test(name) || name === "CredentialsProviderError")
|
|
106
|
+
return 401;
|
|
107
|
+
if (/PermissionDenied|ForbiddenError$/.test(name))
|
|
108
|
+
return 403;
|
|
109
|
+
if (/RateLimitError$/.test(name))
|
|
110
|
+
return 429;
|
|
111
|
+
return errorStatus(e.cause, depth + 1);
|
|
112
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sandboxrouter/sdk",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "One API for every sandbox provider: route AI agents' code by price, speed or capability, with automatic fallback. Free and open source.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|