@sandboxrouter/sdk 0.3.4 → 0.4.0
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 +18 -18
- package/dist/bench-store.d.ts +2 -1
- package/dist/bench-store.js +6 -3
- package/dist/billing.d.ts +5 -0
- package/dist/billing.js +3 -0
- package/dist/check.d.ts +5 -0
- package/dist/check.js +12 -1
- package/dist/cli.js +17 -6
- package/dist/cost.d.ts +11 -0
- package/dist/cost.js +43 -1
- package/dist/providers/agentcore.d.ts +9 -0
- package/dist/providers/agentcore.js +44 -3
- package/dist/providers/freestyle.js +11 -4
- package/dist/providers/modal.js +5 -1
- package/dist/providers/sprites.js +4 -3
- package/dist/providers/vercel.js +5 -1
- package/dist/router.d.ts +39 -3
- package/dist/router.js +72 -13
- package/dist/server.d.ts +1 -1
- package/dist/types.d.ts +11 -6
- package/dist/verified.d.ts +2 -0
- package/dist/verified.js +18 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ try {
|
|
|
21
21
|
|
|
22
22
|
Runs as plain JavaScript on Node 20+. (In TypeScript 5.2+, or Node 24+, `await using sbx = ...` kills the sandbox for you.)
|
|
23
23
|
|
|
24
|
-
Set the keys of the providers you use. `create()` starts the sandbox on the cheapest one that fits
|
|
24
|
+
Set the keys of the providers you use. `create()` starts the sandbox on the cheapest one that fits: list price for the size you asked for, corrected where we measured what a provider really bills. Providers within 10% of each other on price go fastest first, by start times measured where you run the router. Providers that failed their last real-account check go last. Ties are alphabetical, and no provider pays for placement. If that provider is down, busy or out of credit, the next one takes it, and every attempt is on `sbx.attempts`. The sandbox stays on that provider until you `kill()` it.
|
|
25
25
|
|
|
26
26
|
Site and docs: [sandboxrouter.vercel.app](https://sandboxrouter.vercel.app) · npm: [`@sandboxrouter/sdk`](https://www.npmjs.com/package/@sandboxrouter/sdk)
|
|
27
27
|
|
|
@@ -30,7 +30,7 @@ Site and docs: [sandboxrouter.vercel.app](https://sandboxrouter.vercel.app) · n
|
|
|
30
30
|
AI agents need a disposable computer to run code a model wrote: install packages, run tests, do an RL rollout. Dozens of providers sell one, each with its own SDK and auth, and the same 2 vCPU / 4 GB sandbox costs from under a cent to over thirty cents an hour. sandboxrouter is one interface over all of them:
|
|
31
31
|
|
|
32
32
|
- **Same code everywhere.** `create`, `exec` (streamed), `writeFile`, `readFile`, `pause`, `kill`. Switch providers by changing which keys are set.
|
|
33
|
-
- **
|
|
33
|
+
- **Cheapest real cost, then speed, with fallback.** Or `cheapest` for list price alone, `fastest` by start times, `verified` for tested providers first, or a list of providers you name.
|
|
34
34
|
- **Your keys.** Your accounts and your bills; nothing is proxied through anyone else.
|
|
35
35
|
- **Cleanup.** A local ledger of every sandbox started, so `sandboxrouter gc` kills what a crashed script left behind.
|
|
36
36
|
- **TypeScript, Python, HTTP and a CLI.** Plus desktops and phones for computer-use models.
|
|
@@ -41,39 +41,39 @@ List price while running, 2 vCPU / 4 GB, cheapest first; waitlisted sign-ups fol
|
|
|
41
41
|
|
|
42
42
|
| Provider | $/hr | Plan needed | Real-account check | Source |
|
|
43
43
|
|---|---|---|---|---|
|
|
44
|
-
| [Agent37](https://sandboxrouter.vercel.app/providers/agent37) | $0.007 | none |
|
|
44
|
+
| [Agent37](https://sandboxrouter.vercel.app/providers/agent37) | $0.007 | none | passed (2026-11-11) | [pricing](https://www.agent37.com/docs/agents-api/billing) |
|
|
45
45
|
| [Boat](https://sandboxrouter.vercel.app/providers/boat) | $0.018 | $20/mo (credited) | not run yet | [pricing](https://docs.boat.dev/pricing.md) |
|
|
46
|
-
| [Maritime](https://sandboxrouter.vercel.app/providers/maritime) | $0.022 | $20/mo (credited) | passed (2026-11-
|
|
47
|
-
| [Sailboxes (Sail Research)](https://sandboxrouter.vercel.app/providers/sail) | up to $0.062 | none | passed (2026-11-
|
|
46
|
+
| [Maritime](https://sandboxrouter.vercel.app/providers/maritime) | $0.022 | $20/mo (credited) | passed (2026-11-11) | [pricing](https://maritime.sh/pricing) |
|
|
47
|
+
| [Sailboxes (Sail Research)](https://sandboxrouter.vercel.app/providers/sail) | up to $0.062 | none | passed (2026-11-11) | [pricing](https://docs.sailresearch.com/sailboxes-pricing.md) |
|
|
48
48
|
| [Northflank](https://sandboxrouter.vercel.app/providers/northflank) | $0.067 | none | not run yet | [pricing](https://northflank.com/pricing) |
|
|
49
49
|
| [Morph Cloud](https://sandboxrouter.vercel.app/providers/morph) | $0.100 | none | not run yet | [pricing](https://cloud.morph.so/web/pricing) |
|
|
50
50
|
| [exe.dev](https://sandboxrouter.vercel.app/providers/exedev) | $0.105 | $15/mo | not run yet | [pricing](https://exe.dev/docs/standalone-vms.md) |
|
|
51
|
-
| [Novita AI Agent Sandbox](https://sandboxrouter.vercel.app/providers/novita) | $0.117 | none |
|
|
52
|
-
| [Freestyle](https://sandboxrouter.vercel.app/providers/freestyle) | $0.134 | none |
|
|
51
|
+
| [Novita AI Agent Sandbox](https://sandboxrouter.vercel.app/providers/novita) | $0.117 | none | passed (2026-11-11) | [pricing](https://docs.novita.ai/guides/sandbox-pricing) |
|
|
52
|
+
| [Freestyle](https://sandboxrouter.vercel.app/providers/freestyle) | $0.134 | none | passed (2026-11-11) | [pricing](https://www.freestyle.sh/docs/vms/pricing-and-limits) |
|
|
53
53
|
| [Beam](https://sandboxrouter.vercel.app/providers/beam) | $0.140 | $20/mo (credited) | not run yet | [pricing](https://www.beam.cloud/pricing) |
|
|
54
54
|
| [CodeSandbox SDK (Together)](https://sandboxrouter.vercel.app/providers/codesandbox) | $0.149 | none | not run yet | [pricing](https://docs.together.ai/docs/together-code-sandbox-legacy) |
|
|
55
|
-
| [Fly.io Sprites](https://sandboxrouter.vercel.app/providers/sprites) | up to $0.164 | none |
|
|
56
|
-
| [Blaxel](https://sandboxrouter.vercel.app/providers/blaxel) | $0.166 | none | passed (2026-11-
|
|
55
|
+
| [Fly.io Sprites](https://sandboxrouter.vercel.app/providers/sprites) | up to $0.164 | none | passed (2026-11-11) | [pricing](https://fly.io/sprites) |
|
|
56
|
+
| [Blaxel](https://sandboxrouter.vercel.app/providers/blaxel) | $0.166 | none | passed (2026-11-11) | [pricing](https://blaxel.ai/pricing) |
|
|
57
57
|
| [HOPX](https://sandboxrouter.vercel.app/providers/hopx) | $0.166 | none | not run yet | [pricing](https://hopx.ai/pricing) |
|
|
58
|
-
| [Superserve](https://sandboxrouter.vercel.app/providers/superserve) | $0.166 | maybe $20/mo (credited) |
|
|
58
|
+
| [Superserve](https://sandboxrouter.vercel.app/providers/superserve) | $0.166 | maybe $20/mo (credited) | passed (2026-11-11) | [pricing](https://superserve.ai/pricing) |
|
|
59
59
|
| [Tensorlake](https://sandboxrouter.vercel.app/providers/tensorlake) | up to $0.200 | none | not run yet | [pricing](https://www.tensorlake.ai/pricing) |
|
|
60
60
|
| [Upstash Box](https://sandboxrouter.vercel.app/providers/upstash) | up to $0.200 | none | not run yet | [pricing](https://upstash.com/pricing/box) |
|
|
61
|
-
| [Cloudflare Sandbox](https://sandboxrouter.vercel.app/providers/cloudflare) | up to $0.216 | $5/mo |
|
|
62
|
-
| [Amazon Bedrock AgentCore Code Interpreter](https://sandboxrouter.vercel.app/providers/agentcore) | up to $0.217 | none |
|
|
63
|
-
| [Modal](https://sandboxrouter.vercel.app/providers/modal) | up to $0.238 | none |
|
|
64
|
-
| [AWS Lambda MicroVMs](https://sandboxrouter.vercel.app/providers/lambda) | $0.252 | none |
|
|
61
|
+
| [Cloudflare Sandbox](https://sandboxrouter.vercel.app/providers/cloudflare) | up to $0.216 | $5/mo | passed (2026-11-11) | [pricing](https://developers.cloudflare.com/containers/platform/pricing/) |
|
|
62
|
+
| [Amazon Bedrock AgentCore Code Interpreter](https://sandboxrouter.vercel.app/providers/agentcore) | up to $0.217 | none | passed (2026-11-11) | [pricing](https://aws.amazon.com/bedrock/agentcore/pricing/) |
|
|
63
|
+
| [Modal](https://sandboxrouter.vercel.app/providers/modal) | up to $0.238 | none | passed (2026-11-11) | [pricing](https://modal.com/pricing) |
|
|
64
|
+
| [AWS Lambda MicroVMs](https://sandboxrouter.vercel.app/providers/lambda) | $0.252 | none | passed (2026-11-11) | [pricing](https://aws.amazon.com/lambda/pricing/) |
|
|
65
65
|
| [Deno Sandbox](https://sandboxrouter.vercel.app/providers/deno) | up to $0.300 | $20/mo | not run yet | [pricing](https://deno.com/sandbox) |
|
|
66
66
|
| [Runloop](https://sandboxrouter.vercel.app/providers/runloop) | $0.317 | none | not run yet | [pricing](https://www.runloop.ai/pricing) |
|
|
67
|
-
| [Vercel Sandbox](https://sandboxrouter.vercel.app/providers/vercel) | up to $0.341 | none |
|
|
67
|
+
| [Vercel Sandbox](https://sandboxrouter.vercel.app/providers/vercel) | up to $0.341 | none | passed (2026-11-11) | [pricing](https://vercel.com/docs/vercel-sandbox/pricing) |
|
|
68
68
|
| [InstaBox (sign-up waitlisted)](https://sandboxrouter.vercel.app/providers/instabox) | $0.017 | none | not run yet | [pricing](https://instabox.sh/docs/pricing.md) |
|
|
69
69
|
| [Koyeb Sandboxes (not taking new accounts)](https://sandboxrouter.vercel.app/providers/koyeb) | $0.058 | $29/mo | not run yet | [pricing](https://www.koyeb.com/docs/reference/instances) |
|
|
70
70
|
| [Dedalus Machines (sign-up waitlisted)](https://sandboxrouter.vercel.app/providers/dedalus) | $0.149 | none | not run yet | [pricing](https://docs.dedaluslabs.ai/dcs/dm/pricing) |
|
|
71
71
|
| [LangSmith Sandboxes (sign-up waitlisted)](https://sandboxrouter.vercel.app/providers/langsmith) | $0.189 | none | not run yet | [pricing](https://www.langchain.com/pricing) |
|
|
72
|
-
| [OpenScale Sandboxes](https://sandboxrouter.vercel.app/providers/openscale) | skipped (default image is 2 vCPU / 1 GB; a bigger size needs image.openscale) | none |
|
|
72
|
+
| [OpenScale Sandboxes](https://sandboxrouter.vercel.app/providers/openscale) | skipped (default image is 2 vCPU / 1 GB; a bigger size needs image.openscale) | none | passed (2026-11-11) | [pricing](https://openscale.so/sandboxes) |
|
|
73
73
|
| [Azure Container Apps dynamic sessions](https://sandboxrouter.vercel.app/providers/azure) | skipped (max 1 vCPU) | none | not run yet | [pricing](https://learn.microsoft.com/en-us/azure/container-apps/billing#dynamic-sessions) |
|
|
74
|
-
| [Daytona](https://sandboxrouter.vercel.app/providers/daytona) | skipped (default image is 1 vCPU / 1 GB; a bigger size needs image.daytona) | none | passed (2026-11-
|
|
74
|
+
| [Daytona](https://sandboxrouter.vercel.app/providers/daytona) | skipped (default image is 1 vCPU / 1 GB; a bigger size needs image.daytona) | none | passed (2026-11-11) | [pricing](https://www.daytona.io/pricing) |
|
|
75
75
|
| [Alibaba Cloud Agent Sandbox](https://sandboxrouter.vercel.app/providers/alibaba) | skipped (max 2 GB memory) | none | not run yet | [pricing](https://www.alibabacloud.com/help/en/agent-sandbox/product-overview/billing-overview) |
|
|
76
|
-
| [E2B](https://sandboxrouter.vercel.app/providers/e2b) | skipped (default image is 2 vCPU / 0.5 GB; a bigger size needs image.e2b) | none | passed (2026-11-
|
|
76
|
+
| [E2B](https://sandboxrouter.vercel.app/providers/e2b) | skipped (default image is 2 vCPU / 0.5 GB; a bigger size needs image.e2b) | none | passed (2026-11-11) | [pricing](https://e2b.dev/pricing) |
|
|
77
77
|
| [Docker (local)](https://sandboxrouter.vercel.app/providers/docker) | free | none | passed (2026-11-09) | [pricing](https://docs.docker.com/engine/) |
|
|
78
78
|
|
|
79
79
|
Checked 2026-11-04 to 2026-11-06.
|
package/dist/bench-store.d.ts
CHANGED
|
@@ -6,7 +6,8 @@ export interface BenchSample {
|
|
|
6
6
|
at: string;
|
|
7
7
|
}
|
|
8
8
|
export type BenchData = Partial<Record<ProviderId, BenchSample>>;
|
|
9
|
-
|
|
9
|
+
/** $SANDBOXROUTER_BENCH_FILE, else bench.json next to the ledger: $SANDBOXROUTER_HOME, else ~/.sandboxrouter/. */
|
|
10
|
+
export declare function defaultBenchFile(): string;
|
|
10
11
|
export declare function loadBench(file?: string): BenchData;
|
|
11
12
|
export declare function saveBench(data: BenchData, file?: string): void;
|
|
12
13
|
export declare function median(xs?: number[]): number | undefined;
|
package/dist/bench-store.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
|
-
|
|
5
|
-
export function
|
|
4
|
+
/** $SANDBOXROUTER_BENCH_FILE, else bench.json next to the ledger: $SANDBOXROUTER_HOME, else ~/.sandboxrouter/. */
|
|
5
|
+
export function defaultBenchFile() {
|
|
6
|
+
return process.env.SANDBOXROUTER_BENCH_FILE || join(process.env.SANDBOXROUTER_HOME || join(homedir(), ".sandboxrouter"), "bench.json");
|
|
7
|
+
}
|
|
8
|
+
export function loadBench(file = defaultBenchFile()) {
|
|
6
9
|
try {
|
|
7
10
|
return JSON.parse(readFileSync(file, "utf8"));
|
|
8
11
|
}
|
|
@@ -10,7 +13,7 @@ export function loadBench(file = DEFAULT_BENCH_FILE) {
|
|
|
10
13
|
return {};
|
|
11
14
|
}
|
|
12
15
|
}
|
|
13
|
-
export function saveBench(data, file =
|
|
16
|
+
export function saveBench(data, file = defaultBenchFile()) {
|
|
14
17
|
mkdirSync(dirname(file), { recursive: true });
|
|
15
18
|
writeFileSync(file, JSON.stringify(data, null, 2));
|
|
16
19
|
}
|
package/dist/billing.js
ADDED
package/dist/check.d.ts
CHANGED
|
@@ -15,6 +15,11 @@ export interface CheckReport {
|
|
|
15
15
|
results: CheckResult[];
|
|
16
16
|
/** True when no step failed. */
|
|
17
17
|
ok: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Set when the run says nothing about the provider: the account running it couldn't pay (402: no
|
|
20
|
+
* balance, plan not started). Such a run isn't saved, so the provider keeps its last real result.
|
|
21
|
+
*/
|
|
22
|
+
inconclusive?: string;
|
|
18
23
|
}
|
|
19
24
|
/**
|
|
20
25
|
* One real pass over a provider: create, exec (stdout, stderr, exit code, env, cwd), streaming,
|
package/dist/check.js
CHANGED
|
@@ -42,10 +42,21 @@ export async function checkProvider(router, provider, opts = {}) {
|
|
|
42
42
|
}
|
|
43
43
|
};
|
|
44
44
|
let sbx;
|
|
45
|
+
let unpaid;
|
|
45
46
|
await step("create", async () => {
|
|
46
|
-
|
|
47
|
+
try {
|
|
48
|
+
sbx = await router.create({ timeoutMinutes: 10, ...fixedSize(provider), ...opts.spec }, { provider, fallback: false });
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
if (err.attempts?.at(-1)?.status !== 402)
|
|
52
|
+
throw err;
|
|
53
|
+
unpaid = err.message;
|
|
54
|
+
return { status: "skip", detail: `the account can't pay (402), so this run says nothing about ${provider}: ${unpaid}` };
|
|
55
|
+
}
|
|
47
56
|
return `${sbx.id}, ~$${sbx.estimatedHourlyUsd}/hr`;
|
|
48
57
|
});
|
|
58
|
+
if (unpaid)
|
|
59
|
+
return { provider, results, ok: false, inconclusive: "the account running the check can't pay (402)" };
|
|
49
60
|
if (!sbx)
|
|
50
61
|
return { provider, results, ok: false };
|
|
51
62
|
const box = sbx;
|
package/dist/cli.js
CHANGED
|
@@ -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 verified|cheapest|fastest (default
|
|
42
|
+
Route flags: --provider e2b[,modal,...] --strategy balanced|verified|cheapest|fastest (default balanced: cheapest real cost, speed breaks near-ties)
|
|
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,8 +156,11 @@ 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
|
-
const tested = c.verified ? "tested
|
|
160
|
-
|
|
159
|
+
const tested = c.failing ? "check failed " : c.verified ? "tested " : " ";
|
|
160
|
+
// The billed column only shows where a measured bill moved it off list price.
|
|
161
|
+
const real = c.effectiveHourlyUsd !== c.estimatedHourlyUsd ? ` (billed ~$${c.effectiveHourlyUsd.toFixed(4)})` : "";
|
|
162
|
+
const speed = c.medianCreateMs !== undefined ? `${c.medianCreateMs} ms ` : "";
|
|
163
|
+
console.log(`${mark} ${c.provider.padEnd(11)} $${c.estimatedHourlyUsd.toFixed(4)}/hr${real} ${speed}${tested}${c.skip ? `(skipped: ${c.skip})` : ""}`.trimEnd());
|
|
161
164
|
}
|
|
162
165
|
return;
|
|
163
166
|
}
|
|
@@ -214,6 +217,11 @@ async function main() {
|
|
|
214
217
|
},
|
|
215
218
|
});
|
|
216
219
|
reports.push(report);
|
|
220
|
+
if (report.inconclusive) {
|
|
221
|
+
if (!values.json)
|
|
222
|
+
console.log(` not saved: ${report.inconclusive}; ${p} keeps its last saved result`);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
217
225
|
if (saveDir) {
|
|
218
226
|
mkdirSync(saveDir, { recursive: true });
|
|
219
227
|
const saved = publishableCheck({ tool: "sandboxrouter-check", version, date: new Date().toISOString(), platform: `${platform()}-${arch()}`, ...(by ? { by } : {}), ...report });
|
|
@@ -222,9 +230,12 @@ async function main() {
|
|
|
222
230
|
}
|
|
223
231
|
if (values.json)
|
|
224
232
|
console.log(JSON.stringify(reports, null, 2));
|
|
225
|
-
else
|
|
226
|
-
|
|
227
|
-
|
|
233
|
+
else {
|
|
234
|
+
const unpaid = reports.filter((r) => r.inconclusive).length;
|
|
235
|
+
const ran = reports.length - unpaid;
|
|
236
|
+
console.log(`\n${reports.filter((r) => r.ok).length}/${ran} passed${unpaid ? `; ${unpaid} not run (the account can't pay)` : ""}`);
|
|
237
|
+
}
|
|
238
|
+
if (reports.some((r) => !r.ok && !r.inconclusive))
|
|
228
239
|
process.exitCode = 1;
|
|
229
240
|
return;
|
|
230
241
|
}
|
package/dist/cost.d.ts
CHANGED
|
@@ -74,4 +74,15 @@ type Reader = (e: CostEntry) => Promise<Omit<Bill, "at">>;
|
|
|
74
74
|
export declare const BILL_READERS: Partial<Record<ProviderId, Reader>>;
|
|
75
75
|
export declare function settle(run: CostRun): Promise<CostRun>;
|
|
76
76
|
export declare function costMarkdown(run: CostRun): string;
|
|
77
|
+
/**
|
|
78
|
+
* Billed / predicted per provider, from the saved cost runs in `dir` (default results/cost): the latest
|
|
79
|
+
* entry whose bill is precise enough to compare. A bill rounded to whole cents only counts when the
|
|
80
|
+
* prediction is at least $1, so rounding can't move the ratio by more than 1%. Providers whose terms
|
|
81
|
+
* forbid publishing benchmarks are left out, like everywhere else results are published.
|
|
82
|
+
*/
|
|
83
|
+
export declare function billedVsList(dir?: string): {
|
|
84
|
+
provider: ProviderId;
|
|
85
|
+
ratio: number;
|
|
86
|
+
date: string;
|
|
87
|
+
}[];
|
|
77
88
|
export {};
|
package/dist/cost.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { benchmarksPublishable, fittingSpec, hourlyCost } from "./catalog.js";
|
|
2
4
|
import { Router } from "./router.js";
|
|
3
5
|
/**
|
|
4
6
|
* The size the sandbox really has, as "<vcpus> <memoryMb>". nproc and MemTotal can show the whole host
|
|
@@ -163,3 +165,43 @@ export function costMarkdown(run) {
|
|
|
163
165
|
}
|
|
164
166
|
return lines.join("\n");
|
|
165
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* Billed / predicted per provider, from the saved cost runs in `dir` (default results/cost): the latest
|
|
170
|
+
* entry whose bill is precise enough to compare. A bill rounded to whole cents only counts when the
|
|
171
|
+
* prediction is at least $1, so rounding can't move the ratio by more than 1%. Providers whose terms
|
|
172
|
+
* forbid publishing benchmarks are left out, like everywhere else results are published.
|
|
173
|
+
*/
|
|
174
|
+
export function billedVsList(dir = "results/cost") {
|
|
175
|
+
let files;
|
|
176
|
+
try {
|
|
177
|
+
files = readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return [];
|
|
181
|
+
}
|
|
182
|
+
const latest = new Map();
|
|
183
|
+
for (const f of files) {
|
|
184
|
+
let run;
|
|
185
|
+
try {
|
|
186
|
+
run = JSON.parse(readFileSync(join(dir, f), "utf8"));
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (run?.tool !== "sandboxrouter-cost" || !Array.isArray(run.entries))
|
|
192
|
+
continue;
|
|
193
|
+
for (const e of run.entries) {
|
|
194
|
+
const usd = e.bill?.usd;
|
|
195
|
+
const predicted = e.predictedUsd;
|
|
196
|
+
if (!usd || !predicted || usd <= 0 || predicted <= 0 || !benchmarksPublishable(e.provider))
|
|
197
|
+
continue;
|
|
198
|
+
if (/integer cents/i.test(e.bill.source) && predicted < 1)
|
|
199
|
+
continue;
|
|
200
|
+
const date = run.date.slice(0, 10);
|
|
201
|
+
const prev = latest.get(e.provider);
|
|
202
|
+
if (!prev || date >= prev.date)
|
|
203
|
+
latest.set(e.provider, { ratio: Math.round((usd / predicted) * 1000) / 1000, date });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return [...latest].map(([provider, v]) => ({ provider, ...v })).sort((a, b) => a.provider.localeCompare(b.provider));
|
|
207
|
+
}
|
|
@@ -69,3 +69,12 @@ export declare class AgentCoreProvider implements Provider {
|
|
|
69
69
|
create(spec: SandboxSpec): Promise<ProviderSandbox>;
|
|
70
70
|
connect(nativeId: string): Promise<ProviderSandbox>;
|
|
71
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Splits exec's combined terminal output at the stderr marker: the command's stdout comes before
|
|
74
|
+
* it, its stderr (base64) after. If the terminal still translated newlines (stty unavailable), the
|
|
75
|
+
* marker is preceded by \r\n and stdout's \r\n are turned back into \n.
|
|
76
|
+
*/
|
|
77
|
+
export declare function splitOutput(combined: string, marker: string): {
|
|
78
|
+
stdout: string;
|
|
79
|
+
stderr: string;
|
|
80
|
+
};
|
|
@@ -386,14 +386,39 @@ class AgentCoreSandbox {
|
|
|
386
386
|
const timeout = Math.max(1, Math.round(opts.timeoutSeconds ?? 60));
|
|
387
387
|
const full = wrapCommand(cmd, { cwd: opts.cwd, env: { ...this.env, ...opts.env } });
|
|
388
388
|
const b64 = Buffer.from(full).toString("base64");
|
|
389
|
+
// The service runs commands in a terminal: stdout and stderr come back as one stream (labelled
|
|
390
|
+
// "stdout" on exit 0 and "stderr" otherwise), with \n turned into \r\n. So stderr goes to a file
|
|
391
|
+
// and is printed base64-encoded after a marker, and `stty -onlcr` keeps newlines as they are.
|
|
392
|
+
const nonce = randomBytes(6).toString("hex");
|
|
393
|
+
const errFile = `/tmp/.sbr-stderr-${nonce}`;
|
|
394
|
+
const marker = `__SBR_STDERR_${nonce}__`;
|
|
389
395
|
// base64 keeps the user's command intact through whatever quoting the service applies.
|
|
390
|
-
const command = `timeout -k 5 ${timeout} bash -c "$(echo ${b64} | base64 -d)"
|
|
391
|
-
|
|
396
|
+
const command = `stty -onlcr 2>/dev/null; timeout -k 5 ${timeout} bash -c "$(echo ${b64} | base64 -d)" 2>${errFile}; c=$?; ` +
|
|
397
|
+
`printf '\\n${marker}\\n'; base64 < ${errFile} | tr -d '\\r\\n'; rm -f ${errFile}; exit $c`;
|
|
398
|
+
// Stream stdout as it arrives, holding back only what could be the start of the marker.
|
|
399
|
+
let combined = "";
|
|
400
|
+
let sent = 0;
|
|
401
|
+
const feed = (chunk) => {
|
|
402
|
+
combined += chunk;
|
|
403
|
+
if (!opts.onStdout)
|
|
404
|
+
return;
|
|
405
|
+
const at = combined.indexOf(`\n${marker}`);
|
|
406
|
+
const safe = at >= 0 ? at : Math.max(sent, combined.length - marker.length - 2);
|
|
407
|
+
if (safe > sent)
|
|
408
|
+
opts.onStdout(combined.slice(sent, safe));
|
|
409
|
+
sent = Math.max(sent, safe);
|
|
410
|
+
};
|
|
411
|
+
const r = await this.command(command, { onStdout: feed, onStderr: feed, timeoutMs: (timeout + 60) * 1000 });
|
|
392
412
|
const durationMs = Date.now() - t0;
|
|
413
|
+
const { stdout, stderr } = splitOutput(combined || r.stdout + r.stderr, marker);
|
|
414
|
+
if (opts.onStdout && stdout.length > sent)
|
|
415
|
+
opts.onStdout(stdout.slice(sent));
|
|
416
|
+
if (stderr)
|
|
417
|
+
opts.onStderr?.(stderr);
|
|
393
418
|
// `timeout` leaves exit 124 (or 137 after SIGKILL) when it had to stop the command.
|
|
394
419
|
if (killedByTimeout(r.exitCode, durationMs, timeout))
|
|
395
420
|
throw timeoutError(ID, timeout);
|
|
396
|
-
return {
|
|
421
|
+
return { stdout, stderr, exitCode: r.exitCode, durationMs };
|
|
397
422
|
}
|
|
398
423
|
async writeFile(path, content) {
|
|
399
424
|
const bytes = toBytes(content);
|
|
@@ -426,6 +451,22 @@ class AgentCoreSandbox {
|
|
|
426
451
|
}
|
|
427
452
|
}
|
|
428
453
|
}
|
|
454
|
+
/**
|
|
455
|
+
* Splits exec's combined terminal output at the stderr marker: the command's stdout comes before
|
|
456
|
+
* it, its stderr (base64) after. If the terminal still translated newlines (stty unavailable), the
|
|
457
|
+
* marker is preceded by \r\n and stdout's \r\n are turned back into \n.
|
|
458
|
+
*/
|
|
459
|
+
export function splitOutput(combined, marker) {
|
|
460
|
+
const at = combined.lastIndexOf(`\n${marker}`);
|
|
461
|
+
if (at < 0)
|
|
462
|
+
return { stdout: combined, stderr: "" };
|
|
463
|
+
const translated = combined[at - 1] === "\r";
|
|
464
|
+
let stdout = combined.slice(0, translated ? at - 1 : at);
|
|
465
|
+
if (translated)
|
|
466
|
+
stdout = stdout.replace(/\r\n/g, "\n");
|
|
467
|
+
const b64 = combined.slice(at + marker.length + 1).replace(/\s/g, "");
|
|
468
|
+
return { stdout, stderr: Buffer.from(b64, "base64").toString("utf8") };
|
|
469
|
+
}
|
|
429
470
|
function exceptionStatus(name) {
|
|
430
471
|
const n = name.toLowerCase();
|
|
431
472
|
if (n.startsWith("accessdenied"))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { ProviderError } from "../types.js";
|
|
3
|
-
import { dirname, toBytes, wrapCommand } from "../util.js";
|
|
3
|
+
import { dirname, shq, toBytes, wrapCommand } from "../util.js";
|
|
4
4
|
/**
|
|
5
5
|
* Freestyle VMs over the public REST API (no SDK needed). Auth: Authorization: Bearer
|
|
6
6
|
* <FREESTYLE_API_KEY>, base https://api.freestyle.sh. Errors are {code, message}.
|
|
@@ -230,11 +230,18 @@ class FreestyleBox {
|
|
|
230
230
|
}
|
|
231
231
|
async writeFile(path, content) {
|
|
232
232
|
const bytes = toBytes(content);
|
|
233
|
-
//
|
|
233
|
+
// The file API runs as root, so a folder it creates belongs to root and commands (which run as
|
|
234
|
+
// the image's user) can't write in it. Make the folder with a command instead, as that user;
|
|
235
|
+
// fall back to the file API where the user can't (e.g. under /root). Best effort: the write
|
|
234
236
|
// itself reports any real problem.
|
|
235
237
|
const dir = dirname(path);
|
|
236
|
-
if (dir !== "/")
|
|
237
|
-
await this.p
|
|
238
|
+
if (dir !== "/") {
|
|
239
|
+
const r = await this.p
|
|
240
|
+
.json("POST", `${this.path}/exec-await`, { json: { command: `mkdir -p ${shq(dir)}`, timeoutMs: 30_000 }, background: true })
|
|
241
|
+
.catch(() => undefined);
|
|
242
|
+
if (r?.statusCode !== 0)
|
|
243
|
+
await this.p.request("POST", `${this.path}/fs/mkdir`, { json: { path: dir }, background: true }).catch(() => { });
|
|
244
|
+
}
|
|
238
245
|
const q = new URLSearchParams({ path, sha256: createHash("sha256").update(bytes).digest("hex") });
|
|
239
246
|
await this.p.request("PUT", `${this.path}/fs/write?${q}`, { bytes, background: true });
|
|
240
247
|
}
|
package/dist/providers/modal.js
CHANGED
|
@@ -75,7 +75,11 @@ class ModalBox {
|
|
|
75
75
|
catch (err) {
|
|
76
76
|
if (err instanceof ProviderError)
|
|
77
77
|
throw err;
|
|
78
|
-
|
|
78
|
+
const message = err?.message ?? String(err);
|
|
79
|
+
// Newer SDKs end the output streams with "Deadline exceeded" at timeoutMs instead of a signal exit.
|
|
80
|
+
if (/deadline exceeded/i.test(message) && killedByTimeout(-1, Date.now() - t0, seconds))
|
|
81
|
+
throw timeoutError("modal", seconds);
|
|
82
|
+
throw new ProviderError("modal", message);
|
|
79
83
|
}
|
|
80
84
|
}
|
|
81
85
|
async writeFile(path, content) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { StringDecoder } from "node:string_decoder";
|
|
3
3
|
import { ProviderError } from "../types.js";
|
|
4
|
-
import { optionalImport, toBytes } from "../util.js";
|
|
4
|
+
import { optionalImport, timeoutError, toBytes } from "../util.js";
|
|
5
5
|
/**
|
|
6
6
|
* Fly.io Sprites via `@fly/sprites`. A sprite is a persistent machine named by the caller, so the
|
|
7
7
|
* name is the native id. Sprites idle on their own and have no lifetime knob: kill() deletes it.
|
|
@@ -78,9 +78,10 @@ class SpritesBox {
|
|
|
78
78
|
try {
|
|
79
79
|
const exitCode = await new Promise((resolve, reject) => {
|
|
80
80
|
const timer = setTimeout(() => {
|
|
81
|
+
// Reject first: killing can report an exit (-1) right away, and that must not count as the result.
|
|
82
|
+
reject(timeoutError("sprites", limit / 1000));
|
|
81
83
|
proc.kill("SIGKILL");
|
|
82
84
|
proc.close();
|
|
83
|
-
reject(new ProviderError("sprites", `command timed out after ${limit / 1000}s`));
|
|
84
85
|
}, limit);
|
|
85
86
|
timer.unref();
|
|
86
87
|
proc.on("exit", (code) => {
|
|
@@ -89,8 +90,8 @@ class SpritesBox {
|
|
|
89
90
|
});
|
|
90
91
|
proc.on("error", (e) => {
|
|
91
92
|
clearTimeout(timer);
|
|
92
|
-
proc.close();
|
|
93
93
|
reject(e);
|
|
94
|
+
proc.close();
|
|
94
95
|
});
|
|
95
96
|
});
|
|
96
97
|
const so = out.end();
|
package/dist/providers/vercel.js
CHANGED
|
@@ -27,6 +27,9 @@ export class VercelProvider {
|
|
|
27
27
|
resources: { vcpus },
|
|
28
28
|
env: spec.env,
|
|
29
29
|
tags: spec.metadata,
|
|
30
|
+
// v3 keeps sandboxes by default and restarts a stopped one on the next command; a router
|
|
31
|
+
// sandbox is single-use, so nothing should outlive kill().
|
|
32
|
+
persistent: false,
|
|
30
33
|
});
|
|
31
34
|
return new VercelBox(sbx);
|
|
32
35
|
}
|
|
@@ -93,6 +96,7 @@ class VercelBox {
|
|
|
93
96
|
return new Uint8Array(buf);
|
|
94
97
|
}
|
|
95
98
|
async kill() {
|
|
96
|
-
|
|
99
|
+
// stop() only ends the session: the next SDK call would start it again. delete() makes it inert.
|
|
100
|
+
await this.sbx.delete({ deleteOrphanSnapshots: true });
|
|
97
101
|
}
|
|
98
102
|
}
|
package/dist/router.d.ts
CHANGED
|
@@ -3,17 +3,28 @@ import { Ledger } from "./ledger.js";
|
|
|
3
3
|
import { type Attempt, type ExecOptions, type ExecResult, type Provider, type ProviderId, type ProviderSandbox, type RouteOptions, type SandboxSpec } from "./types.js";
|
|
4
4
|
export interface Candidate {
|
|
5
5
|
provider: ProviderId;
|
|
6
|
+
/** List price for the spec, USD/hour. What the sandbox is expected to cost at the provider's published rates. */
|
|
6
7
|
estimatedHourlyUsd: number;
|
|
8
|
+
/**
|
|
9
|
+
* List price x what the provider really billed against list price, where that was measured
|
|
10
|
+
* (`BILLED_VS_LIST`, or `RouterOptions.billedVsList`); the list price otherwise. The default strategy ranks by this.
|
|
11
|
+
*/
|
|
12
|
+
effectiveHourlyUsd: number;
|
|
13
|
+
/** Median create time: this router's own recent creates when it has a few, else your `bench` file. */
|
|
7
14
|
medianCreateMs?: number;
|
|
8
15
|
/** Passed the real-account check (as of this release, or `RouterOptions.verified`). */
|
|
9
16
|
verified: boolean;
|
|
17
|
+
/** Failed a step of its last real-account check (as of this release, or `RouterOptions.failing`). */
|
|
18
|
+
failing: boolean;
|
|
10
19
|
/** Why this provider won't be tried, if it won't. */
|
|
11
20
|
skip?: string;
|
|
12
21
|
}
|
|
22
|
+
/** Providers within this share of the cheapest remaining price count as a tie, broken by speed. */
|
|
23
|
+
export declare const PRICE_TIE = 0.1;
|
|
13
24
|
export interface RouterOptions {
|
|
14
25
|
/** Replace the built-in adapters (tests, custom providers). */
|
|
15
26
|
providers?: Provider[];
|
|
16
|
-
/** Path of the bench results file
|
|
27
|
+
/** Path of the bench results file: start times for the speed tie-break and strategy "fastest". */
|
|
17
28
|
benchFile?: string;
|
|
18
29
|
/** Desktop-only computer providers (sandbox providers that also sell computers are found automatically). */
|
|
19
30
|
computers?: ComputerProvider[];
|
|
@@ -24,10 +35,21 @@ export interface RouterOptions {
|
|
|
24
35
|
*/
|
|
25
36
|
ledger?: string | false;
|
|
26
37
|
/**
|
|
27
|
-
* Providers the
|
|
28
|
-
* passed as of this release (`VERIFIED`). Pass your own
|
|
38
|
+
* Providers that passed the real-account check: tried first by strategy "verified", and the only ones
|
|
39
|
+
* used with `requireVerified`. Default: those that passed as of this release (`VERIFIED`). Pass your own
|
|
40
|
+
* list, e.g. after running `check` yourself.
|
|
29
41
|
*/
|
|
30
42
|
verified?: ProviderId[];
|
|
43
|
+
/** Providers whose last real-account check failed. The default strategy tries them last. Default: `FAILING`. */
|
|
44
|
+
failing?: ProviderId[];
|
|
45
|
+
/**
|
|
46
|
+
* Only route to providers in `verified`; the rest are skipped. For services that run sandboxes on their own
|
|
47
|
+
* accounts, where a provider that misbehaves without failing (keeps billing after kill, garbles output)
|
|
48
|
+
* costs money. Default false.
|
|
49
|
+
*/
|
|
50
|
+
requireVerified?: boolean;
|
|
51
|
+
/** Billed / list-price ratio per provider, for the default ranking. Default: `BILLED_VS_LIST` from saved cost runs. */
|
|
52
|
+
billedVsList?: Partial<Record<ProviderId, number>>;
|
|
31
53
|
}
|
|
32
54
|
/** Desktop-only providers. Filled in as adapters are added. */
|
|
33
55
|
export declare function defaultComputerProviders(): ComputerProvider[];
|
|
@@ -36,6 +58,11 @@ export declare class Router {
|
|
|
36
58
|
private providers;
|
|
37
59
|
private benchFile?;
|
|
38
60
|
private readonly verified;
|
|
61
|
+
private readonly failing;
|
|
62
|
+
private readonly requireVerified;
|
|
63
|
+
private readonly billed;
|
|
64
|
+
/** Create times this router measured, most recent last. */
|
|
65
|
+
private readonly live;
|
|
39
66
|
private computerOnly;
|
|
40
67
|
/** The local record of sandboxes this router created, or undefined when turned off. */
|
|
41
68
|
readonly ledger?: Ledger;
|
|
@@ -54,6 +81,8 @@ export declare class Router {
|
|
|
54
81
|
private whyNone;
|
|
55
82
|
/** Start a sandbox on the best available provider, falling back down the list on failure. */
|
|
56
83
|
create(spec?: SandboxSpec, route?: RouteOptions): Promise<RoutedSandbox>;
|
|
84
|
+
/** Remember a successful create's time, for the speed tie-break and strategy "fastest". */
|
|
85
|
+
private observe;
|
|
57
86
|
private record;
|
|
58
87
|
private discard;
|
|
59
88
|
/** Every provider that sells computers (desktops/phones): sandbox providers that do, plus desktop-only ones. */
|
|
@@ -103,6 +132,13 @@ export declare function parseId(id: string): {
|
|
|
103
132
|
provider: ProviderId;
|
|
104
133
|
nativeId: string;
|
|
105
134
|
};
|
|
135
|
+
/**
|
|
136
|
+
* The default order. Providers whose last real-account check failed go last. The rest go cheapest
|
|
137
|
+
* first by effective price (list price x what the provider really billed, where measured), except that
|
|
138
|
+
* providers within PRICE_TIE of the cheapest remaining one count as a tie and go fastest first (by
|
|
139
|
+
* median create time; unknown speed after known), then cheapest, then alphabetical.
|
|
140
|
+
*/
|
|
141
|
+
export declare function balanced(list: Candidate[]): Candidate[];
|
|
106
142
|
/** Seconds to wait for one provider's create before trying the next (RouteOptions.createTimeoutSeconds). */
|
|
107
143
|
export declare const DEFAULT_CREATE_TIMEOUT_S = 120;
|
|
108
144
|
/** Seconds past a command's timeout before the router stops waiting for the adapter. */
|
package/dist/router.js
CHANGED
|
@@ -3,7 +3,8 @@ 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 {
|
|
6
|
+
import { BILLED_VS_LIST } from "./billing.js";
|
|
7
|
+
import { FAILING, VERIFIED } from "./verified.js";
|
|
7
8
|
import { DaytonaProvider } from "./providers/daytona.js";
|
|
8
9
|
import { E2BProvider } from "./providers/e2b.js";
|
|
9
10
|
import { MaritimeProvider } from "./providers/maritime.js";
|
|
@@ -43,6 +44,12 @@ import { MobilerunProvider } from "./providers/mobilerun.js";
|
|
|
43
44
|
import { LimrunProvider } from "./providers/limrun.js";
|
|
44
45
|
import { WorkspacesAgentProvider } from "./providers/workspaces.js";
|
|
45
46
|
import { ProviderError, SetupError, RouterError, } from "./types.js";
|
|
47
|
+
/** Providers within this share of the cheapest remaining price count as a tie, broken by speed. */
|
|
48
|
+
export const PRICE_TIE = 0.1;
|
|
49
|
+
/** Creates this router must have seen for a provider before its own timings replace the bench file's. */
|
|
50
|
+
const LIVE_MIN_SAMPLES = 3;
|
|
51
|
+
/** Recent creates kept per provider for the live median. */
|
|
52
|
+
const LIVE_SAMPLES = 20;
|
|
46
53
|
/** Desktop-only providers. Filled in as adapters are added. */
|
|
47
54
|
export function defaultComputerProviders() {
|
|
48
55
|
return [new OrgoProvider(), new MobilerunProvider(), new LimrunProvider(), new WorkspacesAgentProvider()];
|
|
@@ -89,6 +96,11 @@ export class Router {
|
|
|
89
96
|
providers;
|
|
90
97
|
benchFile;
|
|
91
98
|
verified;
|
|
99
|
+
failing;
|
|
100
|
+
requireVerified;
|
|
101
|
+
billed;
|
|
102
|
+
/** Create times this router measured, most recent last. */
|
|
103
|
+
live = new Map();
|
|
92
104
|
computerOnly;
|
|
93
105
|
/** The local record of sandboxes this router created, or undefined when turned off. */
|
|
94
106
|
ledger;
|
|
@@ -99,6 +111,10 @@ export class Router {
|
|
|
99
111
|
this.providers = new Map((opts.providers ?? defaultProviders()).map((p) => [p.id, p]));
|
|
100
112
|
this.benchFile = opts.benchFile;
|
|
101
113
|
this.verified = new Set(opts.verified ?? Object.keys(VERIFIED));
|
|
114
|
+
this.failing = new Set(opts.failing ?? Object.keys(FAILING));
|
|
115
|
+
this.requireVerified = opts.requireVerified ?? false;
|
|
116
|
+
this.billed =
|
|
117
|
+
opts.billedVsList ?? Object.fromEntries(Object.entries(BILLED_VS_LIST).map(([id, b]) => [id, b.ratio]));
|
|
102
118
|
this.computerOnly = opts.computers ?? (opts.providers ? [] : defaultComputerProviders());
|
|
103
119
|
}
|
|
104
120
|
/** Registered and has credentials. False for providers this router doesn't know. */
|
|
@@ -121,40 +137,52 @@ export class Router {
|
|
|
121
137
|
// Local providers (Docker) are never picked automatically: they always "cost" $0 and isolate
|
|
122
138
|
// less than a cloud micro-VM. Name them explicitly to use them.
|
|
123
139
|
const ids = explicit ?? CATALOG.filter((c) => !c.local && this.providers.has(c.id)).map((c) => c.id);
|
|
124
|
-
const
|
|
140
|
+
const strategy = route.strategy ?? "default";
|
|
141
|
+
const speed = strategy === "default" || strategy === "balanced" || strategy === "fastest";
|
|
142
|
+
const bench = speed ? loadBench(this.benchFile) : {};
|
|
125
143
|
const list = ids.map((id) => {
|
|
126
144
|
// An id the catalog has never heard of (a typo in an explicit list) has no price to look up.
|
|
127
145
|
if (!CATALOG.some((c) => c.id === id))
|
|
128
|
-
return { provider: id, estimatedHourlyUsd: 0, verified: false, skip: "unknown provider" };
|
|
146
|
+
return { provider: id, estimatedHourlyUsd: 0, effectiveHourlyUsd: 0, verified: false, failing: false, skip: "unknown provider" };
|
|
129
147
|
const p = this.providers.get(id);
|
|
148
|
+
const list = round(hourlyCost(id, spec));
|
|
149
|
+
const live = this.live.get(id);
|
|
130
150
|
const c = {
|
|
131
151
|
provider: id,
|
|
132
|
-
estimatedHourlyUsd:
|
|
133
|
-
|
|
152
|
+
estimatedHourlyUsd: list,
|
|
153
|
+
effectiveHourlyUsd: round(list * (this.billed[id] ?? 1)),
|
|
154
|
+
medianCreateMs: speed ? (live && live.length >= LIVE_MIN_SAMPLES ? median(live) : median(bench[id]?.createMs)) : undefined,
|
|
134
155
|
verified: this.verified.has(id),
|
|
156
|
+
failing: this.failing.has(id),
|
|
135
157
|
};
|
|
136
158
|
if (!p)
|
|
137
159
|
c.skip = "no adapter registered";
|
|
138
160
|
else {
|
|
139
161
|
const gated = accessLabel(id);
|
|
140
|
-
c.skip =
|
|
162
|
+
c.skip =
|
|
163
|
+
unfit(id, spec, route.require) ??
|
|
164
|
+
(p.configured() ? undefined : gated ? `not configured; ${gated}` : "not configured") ??
|
|
165
|
+
(this.requireVerified && !c.verified ? "not tested (this router only uses providers that passed the real-account check)" : undefined);
|
|
141
166
|
}
|
|
142
167
|
return c;
|
|
143
168
|
});
|
|
144
|
-
// Neutral by construction: the same rule for every provider, ties alphabetical by id.
|
|
145
|
-
//
|
|
146
|
-
// An explicit
|
|
169
|
+
// Neutral by construction: the same rule for every provider, ties alphabetical by id. Nothing here
|
|
170
|
+
// depends on who the provider is, only on its price, what it really billed, how fast it started for
|
|
171
|
+
// this router and whether its last real-account check failed. An explicit list keeps the caller's order.
|
|
147
172
|
if (!explicit) {
|
|
148
173
|
const byPrice = (a, b) => a.estimatedHourlyUsd - b.estimatedHourlyUsd || a.provider.localeCompare(b.provider);
|
|
149
|
-
if (
|
|
174
|
+
if (strategy === "fastest") {
|
|
150
175
|
list.sort((a, b) => (a.medianCreateMs ?? Infinity) - (b.medianCreateMs ?? Infinity) || byPrice(a, b));
|
|
151
176
|
}
|
|
152
|
-
else if (
|
|
177
|
+
else if (strategy === "cheapest") {
|
|
153
178
|
list.sort(byPrice);
|
|
154
179
|
}
|
|
155
|
-
else {
|
|
180
|
+
else if (strategy === "verified") {
|
|
156
181
|
list.sort((a, b) => Number(b.verified) - Number(a.verified) || byPrice(a, b));
|
|
157
182
|
}
|
|
183
|
+
else {
|
|
184
|
+
return balanced(list);
|
|
185
|
+
}
|
|
158
186
|
}
|
|
159
187
|
return list;
|
|
160
188
|
}
|
|
@@ -205,6 +233,7 @@ export class Router {
|
|
|
205
233
|
return new ProviderError(c.provider, `create timed out after ${limitS}s`);
|
|
206
234
|
});
|
|
207
235
|
attempts.push({ provider: c.provider, ok: true, ms: Date.now() - t0 });
|
|
236
|
+
this.observe(c.provider, Date.now() - t0);
|
|
208
237
|
const sbx = new RoutedSandbox(c.provider, box, attempts, c.estimatedHourlyUsd, this.ledger);
|
|
209
238
|
this.record(sbx, c.estimatedHourlyUsd, spec.timeoutMinutes);
|
|
210
239
|
return sbx;
|
|
@@ -214,8 +243,8 @@ export class Router {
|
|
|
214
243
|
// so it can't bill unseen. If the kill fails, the ledger entry is left for `gc`.
|
|
215
244
|
if (timedOut)
|
|
216
245
|
pending.then((late) => this.discard(c.provider, late, c.estimatedHourlyUsd, spec.timeoutMinutes), () => { });
|
|
217
|
-
attempts.push({ provider: c.provider, ok: false, ms: Date.now() - t0, error: err.message });
|
|
218
246
|
const status = errorStatus(err);
|
|
247
|
+
attempts.push({ provider: c.provider, ok: false, ms: Date.now() - t0, error: err.message, ...(status ? { status } : {}) });
|
|
219
248
|
const setup = err instanceof SetupError || (status !== undefined && SETUP_ERRORS.has(status));
|
|
220
249
|
if (route.fallback !== "any" && setup) {
|
|
221
250
|
const msg = err.message.replace(/[.\s]+$/, "");
|
|
@@ -226,6 +255,14 @@ export class Router {
|
|
|
226
255
|
}
|
|
227
256
|
throw new RouterError(`all providers failed: ${attempts.map((a) => `${a.provider}: ${a.error}`).join(" | ")}`, attempts);
|
|
228
257
|
}
|
|
258
|
+
/** Remember a successful create's time, for the speed tie-break and strategy "fastest". */
|
|
259
|
+
observe(id, ms) {
|
|
260
|
+
const xs = this.live.get(id) ?? [];
|
|
261
|
+
xs.push(ms);
|
|
262
|
+
if (xs.length > LIVE_SAMPLES)
|
|
263
|
+
xs.shift();
|
|
264
|
+
this.live.set(id, xs);
|
|
265
|
+
}
|
|
229
266
|
record(sbx, usdHour, timeoutMinutes) {
|
|
230
267
|
const now = Date.now();
|
|
231
268
|
this.ledger?.open({
|
|
@@ -342,6 +379,28 @@ export function parseId(id) {
|
|
|
342
379
|
function round(n) {
|
|
343
380
|
return Math.round(n * 10000) / 10000;
|
|
344
381
|
}
|
|
382
|
+
/**
|
|
383
|
+
* The default order. Providers whose last real-account check failed go last. The rest go cheapest
|
|
384
|
+
* first by effective price (list price x what the provider really billed, where measured), except that
|
|
385
|
+
* providers within PRICE_TIE of the cheapest remaining one count as a tie and go fastest first (by
|
|
386
|
+
* median create time; unknown speed after known), then cheapest, then alphabetical.
|
|
387
|
+
*/
|
|
388
|
+
export function balanced(list) {
|
|
389
|
+
const byCost = (a, b) => a.effectiveHourlyUsd - b.effectiveHourlyUsd || a.provider.localeCompare(b.provider);
|
|
390
|
+
const bySpeed = (a, b) => (a.medianCreateMs ?? Infinity) - (b.medianCreateMs ?? Infinity) || byCost(a, b);
|
|
391
|
+
const order = (group) => {
|
|
392
|
+
const rest = [...group].sort(byCost);
|
|
393
|
+
const out = [];
|
|
394
|
+
while (rest.length) {
|
|
395
|
+
const limit = rest[0].effectiveHourlyUsd * (1 + PRICE_TIE);
|
|
396
|
+
const tie = rest.filter((c) => c.effectiveHourlyUsd <= limit);
|
|
397
|
+
out.push(...tie.sort(bySpeed));
|
|
398
|
+
rest.splice(0, tie.length);
|
|
399
|
+
}
|
|
400
|
+
return out;
|
|
401
|
+
};
|
|
402
|
+
return [...order(list.filter((c) => !c.failing)), ...order(list.filter((c) => c.failing))];
|
|
403
|
+
}
|
|
345
404
|
/** Seconds to wait for one provider's create before trying the next (RouteOptions.createTimeoutSeconds). */
|
|
346
405
|
export const DEFAULT_CREATE_TIMEOUT_S = 120;
|
|
347
406
|
/**
|
package/dist/server.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
|
2
2
|
import { Router, type RoutedSandbox } from "./router.js";
|
|
3
3
|
import { type RouteOptions, type SandboxSpec } from "./types.js";
|
|
4
4
|
/**
|
|
5
|
-
* Hooks for running this API for many users,
|
|
5
|
+
* Hooks for running this API for many users (accounts, billing, per-user sandboxes). With none set,
|
|
6
6
|
* the server is single-user: one SANDBOXROUTER_TOKEN, every sandbox reachable by anyone holding it.
|
|
7
7
|
*/
|
|
8
8
|
export interface ServerHooks {
|
package/dist/types.d.ts
CHANGED
|
@@ -22,17 +22,20 @@ export interface SandboxSpec {
|
|
|
22
22
|
metadata?: Record<string, string>;
|
|
23
23
|
}
|
|
24
24
|
/**
|
|
25
|
-
* "
|
|
26
|
-
*
|
|
27
|
-
*
|
|
25
|
+
* "balanced" (and "default", the same thing): cheapest first by effective price (list price x what the
|
|
26
|
+
* provider really billed, where measured; `BILLED_VS_LIST`), with providers within 10% of each other on
|
|
27
|
+
* price ordered by speed (this router's own recent create times, else your `bench` file). Providers
|
|
28
|
+
* whose last real-account check failed (`FAILING`) go last.
|
|
29
|
+
* "verified": providers that passed the real-account check first (`VERIFIED`), then the rest; cheapest
|
|
30
|
+
* list price first within each.
|
|
28
31
|
* "cheapest": lowest list price for the spec, tested or not, ties alphabetical.
|
|
29
|
-
* "fastest": lowest median
|
|
32
|
+
* "fastest": lowest median create time (this router's own, else your `bench` file), then cheapest.
|
|
30
33
|
*/
|
|
31
|
-
export type Strategy = "default" | "verified" | "cheapest" | "fastest";
|
|
34
|
+
export type Strategy = "default" | "balanced" | "verified" | "cheapest" | "fastest";
|
|
32
35
|
export interface RouteOptions {
|
|
33
36
|
/** Pin to one provider, or give an ordered list to try. Omit to let the router choose. */
|
|
34
37
|
provider?: ProviderId | ProviderId[];
|
|
35
|
-
/** How to order candidates when no explicit list is given. Default: "
|
|
38
|
+
/** How to order candidates when no explicit list is given. Default: "balanced" (cheapest real cost, speed breaks near-ties). */
|
|
36
39
|
strategy?: Strategy;
|
|
37
40
|
/**
|
|
38
41
|
* What to do when a create fails. `true` (default): try the next candidate when the provider
|
|
@@ -86,6 +89,8 @@ export interface Attempt {
|
|
|
86
89
|
ok: boolean;
|
|
87
90
|
ms: number;
|
|
88
91
|
error?: string;
|
|
92
|
+
/** HTTP status of the failure, when the provider gave one (402: the account can't pay). */
|
|
93
|
+
status?: number;
|
|
89
94
|
}
|
|
90
95
|
export declare class RouterError extends Error {
|
|
91
96
|
readonly attempts: Attempt[];
|
package/dist/verified.d.ts
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
import type { ProviderId } from "./types.js";
|
|
2
2
|
export declare const VERIFIED: Partial<Record<ProviderId, string>>;
|
|
3
|
+
/** Providers whose last saved real-account check failed a step. The default strategy tries them last. */
|
|
4
|
+
export declare const FAILING: Partial<Record<ProviderId, string>>;
|
package/dist/verified.js
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
export const VERIFIED = {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
agent37: "2026-11-11",
|
|
3
|
+
agentcore: "2026-11-11",
|
|
4
|
+
lambda: "2026-11-11",
|
|
5
|
+
blaxel: "2026-11-11",
|
|
6
|
+
cloudflare: "2026-11-11",
|
|
7
|
+
daytona: "2026-11-11",
|
|
8
|
+
e2b: "2026-11-11",
|
|
9
|
+
sprites: "2026-11-11",
|
|
10
|
+
freestyle: "2026-11-11",
|
|
11
|
+
maritime: "2026-11-11",
|
|
12
|
+
modal: "2026-11-11",
|
|
13
|
+
novita: "2026-11-11",
|
|
14
|
+
openscale: "2026-11-11",
|
|
15
|
+
sail: "2026-11-11",
|
|
16
|
+
superserve: "2026-11-11",
|
|
17
|
+
vercel: "2026-11-11",
|
|
7
18
|
};
|
|
19
|
+
/** Providers whose last saved real-account check failed a step. The default strategy tries them last. */
|
|
20
|
+
export const FAILING = {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sandboxrouter/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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",
|