@sandboxrouter/sdk 0.5.0 → 0.7.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 +9 -9
- package/dist/cli.js +23 -10
- package/dist/client.d.ts +9 -3
- package/dist/client.js +12 -5
- package/dist/computers.d.ts +47 -4
- package/dist/computers.js +130 -17
- package/dist/gc.js +5 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/ledger.d.ts +3 -1
- package/dist/router.d.ts +21 -4
- package/dist/router.js +31 -31
- package/dist/server.d.ts +17 -1
- package/dist/server.js +141 -38
- package/dist/types.d.ts +2 -1
- package/dist/util.d.ts +8 -0
- package/dist/util.js +21 -0
- package/dist/verified.js +8 -8
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -44,8 +44,8 @@ List price while running, 2 vCPU / 4 GB, cheapest first; waitlisted sign-ups fol
|
|
|
44
44
|
|---|---|---|---|---|
|
|
45
45
|
| [Agent37](https://sandboxrouter.vercel.app/providers/agent37) | $0.007 | none | passed (2026-11-11) | [pricing](https://www.agent37.com/docs/agents-api/billing) |
|
|
46
46
|
| [Boat](https://sandboxrouter.vercel.app/providers/boat) | $0.018 | $20/mo (credited) | not run yet | [pricing](https://docs.boat.dev/pricing.md) |
|
|
47
|
-
| [Maritime](https://sandboxrouter.vercel.app/providers/maritime) | $0.022 | $20/mo (credited) | passed (2026-11-
|
|
48
|
-
| [Sailboxes (Sail Research)](https://sandboxrouter.vercel.app/providers/sail) | up to $0.062 | none | passed (2026-11-
|
|
47
|
+
| [Maritime](https://sandboxrouter.vercel.app/providers/maritime) | $0.022 | $20/mo (credited) | passed (2026-11-13) | [pricing](https://maritime.sh/pricing) |
|
|
48
|
+
| [Sailboxes (Sail Research)](https://sandboxrouter.vercel.app/providers/sail) | up to $0.062 | none | passed (2026-11-13) | [pricing](https://docs.sailresearch.com/sailboxes-pricing.md) |
|
|
49
49
|
| [Northflank](https://sandboxrouter.vercel.app/providers/northflank) | $0.067 | none | not run yet | [pricing](https://northflank.com/pricing) |
|
|
50
50
|
| [Morph Cloud](https://sandboxrouter.vercel.app/providers/morph) | $0.100 | none | not run yet | [pricing](https://cloud.morph.so/web/pricing) |
|
|
51
51
|
| [exe.dev](https://sandboxrouter.vercel.app/providers/exedev) | $0.105 | $15/mo | not run yet | [pricing](https://exe.dev/docs/standalone-vms.md) |
|
|
@@ -54,15 +54,15 @@ List price while running, 2 vCPU / 4 GB, cheapest first; waitlisted sign-ups fol
|
|
|
54
54
|
| [Beam](https://sandboxrouter.vercel.app/providers/beam) | $0.140 | $20/mo (credited) | not run yet | [pricing](https://www.beam.cloud/pricing) |
|
|
55
55
|
| [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) |
|
|
56
56
|
| [Fly.io Sprites](https://sandboxrouter.vercel.app/providers/sprites) | up to $0.164 | none | passed (2026-11-11) | [pricing](https://fly.io/sprites) |
|
|
57
|
-
| [Blaxel](https://sandboxrouter.vercel.app/providers/blaxel) | $0.166 | none | passed (2026-11-
|
|
57
|
+
| [Blaxel](https://sandboxrouter.vercel.app/providers/blaxel) | $0.166 | none | passed (2026-11-13) | [pricing](https://blaxel.ai/pricing) |
|
|
58
58
|
| [HOPX](https://sandboxrouter.vercel.app/providers/hopx) | $0.166 | none | not run yet | [pricing](https://hopx.ai/pricing) |
|
|
59
59
|
| [Superserve](https://sandboxrouter.vercel.app/providers/superserve) | $0.166 | maybe $20/mo (credited) | passed (2026-11-11) | [pricing](https://superserve.ai/pricing) |
|
|
60
60
|
| [Tensorlake](https://sandboxrouter.vercel.app/providers/tensorlake) | up to $0.200 | none | not run yet | [pricing](https://www.tensorlake.ai/pricing) |
|
|
61
61
|
| [Upstash Box](https://sandboxrouter.vercel.app/providers/upstash) | up to $0.200 | none | not run yet | [pricing](https://upstash.com/pricing/box) |
|
|
62
62
|
| [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/) |
|
|
63
|
-
| [Amazon Bedrock AgentCore Code Interpreter](https://sandboxrouter.vercel.app/providers/agentcore) | up to $0.217 | none | passed (2026-11-
|
|
63
|
+
| [Amazon Bedrock AgentCore Code Interpreter](https://sandboxrouter.vercel.app/providers/agentcore) | up to $0.217 | none | passed (2026-11-13) | [pricing](https://aws.amazon.com/bedrock/agentcore/pricing/) |
|
|
64
64
|
| [Modal](https://sandboxrouter.vercel.app/providers/modal) | up to $0.238 | none | passed (2026-11-11) | [pricing](https://modal.com/pricing) |
|
|
65
|
-
| [AWS Lambda MicroVMs](https://sandboxrouter.vercel.app/providers/lambda) | $0.252 | none | passed (2026-11-
|
|
65
|
+
| [AWS Lambda MicroVMs](https://sandboxrouter.vercel.app/providers/lambda) | $0.252 | none | passed (2026-11-13) | [pricing](https://aws.amazon.com/lambda/pricing/) |
|
|
66
66
|
| [Deno Sandbox](https://sandboxrouter.vercel.app/providers/deno) | up to $0.300 | $20/mo | not run yet | [pricing](https://deno.com/sandbox) |
|
|
67
67
|
| [Runloop](https://sandboxrouter.vercel.app/providers/runloop) | $0.317 | none | not run yet | [pricing](https://www.runloop.ai/pricing) |
|
|
68
68
|
| [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) |
|
|
@@ -70,12 +70,12 @@ List price while running, 2 vCPU / 4 GB, cheapest first; waitlisted sign-ups fol
|
|
|
70
70
|
| [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) |
|
|
71
71
|
| [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) |
|
|
72
72
|
| [LangSmith Sandboxes (sign-up waitlisted)](https://sandboxrouter.vercel.app/providers/langsmith) | $0.189 | none | not run yet | [pricing](https://www.langchain.com/pricing) |
|
|
73
|
-
| [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-
|
|
73
|
+
| [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-13) | [pricing](https://openscale.so/sandboxes) |
|
|
74
74
|
| [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) |
|
|
75
|
-
| [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-
|
|
75
|
+
| [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-13) | [pricing](https://www.daytona.io/pricing) |
|
|
76
76
|
| [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) |
|
|
77
|
-
| [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-
|
|
78
|
-
| [Docker (local)](https://sandboxrouter.vercel.app/providers/docker) | free | none | passed (2026-11-
|
|
77
|
+
| [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-13) | [pricing](https://e2b.dev/pricing) |
|
|
78
|
+
| [Docker (local)](https://sandboxrouter.vercel.app/providers/docker) | free | none | passed (2026-11-13) | [pricing](https://docs.docker.com/engine/) |
|
|
79
79
|
|
|
80
80
|
Checked 2026-11-04 to 2026-11-06.
|
|
81
81
|
|
package/dist/cli.js
CHANGED
|
@@ -18,10 +18,12 @@ Usage:
|
|
|
18
18
|
sandboxrouter providers [--vcpu N] [--memory GB] price table + which keys are set
|
|
19
19
|
sandboxrouter route [spec flags] [route flags] dry run: the order providers would be tried
|
|
20
20
|
sandboxrouter run "<cmd>" [spec flags] [route flags] [--keep]
|
|
21
|
-
sandboxrouter check --provider ID[,ID] | --all [--json] [--save [dir]] [--by GITHUB_USER]
|
|
21
|
+
sandboxrouter check --provider ID[,ID] | --all [--json] [--save [dir]] [--retry] [--out FILE] [--by GITHUB_USER]
|
|
22
22
|
test providers for real: exec, env, UTF-8, streaming, files, timeout, pause,
|
|
23
23
|
kill; exits 1 if any step fails. --all = every configured provider;
|
|
24
|
-
--save writes results/conformance/<id>.json for the compatibility table
|
|
24
|
+
--save writes results/conformance/<id>.json for the compatibility table;
|
|
25
|
+
--retry runs a failed provider once more and keeps that result;
|
|
26
|
+
--out writes every report as JSON (with the first try's failure, if retried)
|
|
25
27
|
sandboxrouter ls [--json] sandboxes this machine started that weren't killed
|
|
26
28
|
sandboxrouter gc [--all | --older-than MIN] [--dry-run]
|
|
27
29
|
kill leaked sandboxes: by default those past their lifetime
|
|
@@ -73,6 +75,7 @@ const { values, positionals } = parseArgs({
|
|
|
73
75
|
label: { type: "string" },
|
|
74
76
|
all: { type: "boolean" },
|
|
75
77
|
save: { type: "boolean" },
|
|
78
|
+
retry: { type: "boolean" },
|
|
76
79
|
"older-than": { type: "string" },
|
|
77
80
|
"dry-run": { type: "boolean" },
|
|
78
81
|
minutes: { type: "string" },
|
|
@@ -208,16 +211,23 @@ async function main() {
|
|
|
208
211
|
const saveDir = values.save ? (rest[0] ?? "results/conformance") : undefined;
|
|
209
212
|
const mark = { pass: "ok ", fail: "FAIL", skip: "skip", info: "note" };
|
|
210
213
|
const reports = [];
|
|
214
|
+
const run = (p) => checkProvider(router, p, {
|
|
215
|
+
spec,
|
|
216
|
+
onStep: (r) => {
|
|
217
|
+
if (!values.json)
|
|
218
|
+
console.log(` ${mark[r.status]} ${r.step.padEnd(32)} ${String(r.ms).padStart(6)} ms ${r.detail ?? ""}`);
|
|
219
|
+
},
|
|
220
|
+
});
|
|
211
221
|
for (const p of ids) {
|
|
212
222
|
if (!values.json)
|
|
213
223
|
console.log(`\n${p}`);
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
220
|
-
}
|
|
224
|
+
let report = await run(p);
|
|
225
|
+
if (values.retry && !report.ok && !report.inconclusive) {
|
|
226
|
+
const failed = report.results.find((r) => r.status === "fail");
|
|
227
|
+
if (!values.json)
|
|
228
|
+
console.log(` failed; trying ${p} once more`);
|
|
229
|
+
report = { ...(await run(p)), firstTry: failed ? `${failed.step}: ${failed.detail ?? "failed"}` : "failed" };
|
|
230
|
+
}
|
|
221
231
|
reports.push(report);
|
|
222
232
|
if (report.inconclusive) {
|
|
223
233
|
if (!values.json)
|
|
@@ -226,10 +236,13 @@ async function main() {
|
|
|
226
236
|
}
|
|
227
237
|
if (saveDir) {
|
|
228
238
|
mkdirSync(saveDir, { recursive: true });
|
|
229
|
-
const
|
|
239
|
+
const { firstTry: _, ...result } = report;
|
|
240
|
+
const saved = publishableCheck({ tool: "sandboxrouter-check", version, date: new Date().toISOString(), platform: `${platform()}-${arch()}`, ...(by ? { by } : {}), ...result });
|
|
230
241
|
writeFileSync(`${saveDir}/${p}.json`, JSON.stringify(saved, null, 2) + "\n");
|
|
231
242
|
}
|
|
232
243
|
}
|
|
244
|
+
if (values.out)
|
|
245
|
+
writeFileSync(values.out, JSON.stringify(reports, null, 2) + "\n");
|
|
233
246
|
if (values.json)
|
|
234
247
|
console.log(JSON.stringify(reports, null, 2));
|
|
235
248
|
else {
|
package/dist/client.d.ts
CHANGED
|
@@ -19,7 +19,7 @@ export declare class Client {
|
|
|
19
19
|
fetch?: typeof fetch;
|
|
20
20
|
});
|
|
21
21
|
/** @internal */
|
|
22
|
-
request(method: string, path: string, body?: unknown, accept?: string): Promise<Response>;
|
|
22
|
+
request(method: string, path: string, body?: unknown, accept?: string, headers?: Record<string, string>): Promise<Response>;
|
|
23
23
|
private json;
|
|
24
24
|
/** Providers this server can route to, with prices at the default size. */
|
|
25
25
|
providers(): Promise<{
|
|
@@ -31,8 +31,14 @@ export declare class Client {
|
|
|
31
31
|
}[]>;
|
|
32
32
|
/** Dry run: which providers would be tried, in order, each with its price, size and any reason it's skipped. */
|
|
33
33
|
route(spec?: SandboxSpec, route?: RouteOptions): Promise<Candidate[]>;
|
|
34
|
-
/**
|
|
35
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Start a sandbox on the first provider that works, as the server routes it. With `idempotencyKey`,
|
|
36
|
+
* sending the same create again (say, after a timeout) returns the same sandbox instead of starting
|
|
37
|
+
* a second one; use a new key for each sandbox you mean to start.
|
|
38
|
+
*/
|
|
39
|
+
create(spec?: SandboxSpec, route?: RouteOptions, opts?: {
|
|
40
|
+
idempotencyKey?: string;
|
|
41
|
+
}): Promise<RemoteSandbox>;
|
|
36
42
|
/** A handle on a sandbox you created earlier. */
|
|
37
43
|
sandbox(id: string): RemoteSandbox;
|
|
38
44
|
}
|
package/dist/client.js
CHANGED
|
@@ -17,7 +17,7 @@ export class Client {
|
|
|
17
17
|
this.fetch = opts.fetch ?? globalThis.fetch;
|
|
18
18
|
}
|
|
19
19
|
/** @internal */
|
|
20
|
-
async request(method, path, body, accept = "application/json") {
|
|
20
|
+
async request(method, path, body, accept = "application/json", headers = {}) {
|
|
21
21
|
const raw = body instanceof Uint8Array;
|
|
22
22
|
const res = await this.fetch(this.baseUrl + path, {
|
|
23
23
|
method,
|
|
@@ -25,6 +25,7 @@ export class Client {
|
|
|
25
25
|
Accept: accept,
|
|
26
26
|
...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}),
|
|
27
27
|
...(body === undefined ? {} : { "Content-Type": raw ? "application/octet-stream" : "application/json" }),
|
|
28
|
+
...headers,
|
|
28
29
|
},
|
|
29
30
|
body: body === undefined ? undefined : raw ? body : JSON.stringify(body),
|
|
30
31
|
});
|
|
@@ -50,10 +51,16 @@ export class Client {
|
|
|
50
51
|
async route(spec = {}, route = {}) {
|
|
51
52
|
return (await this.json("POST", "/v1/route", { ...spec, ...route })).candidates;
|
|
52
53
|
}
|
|
53
|
-
/**
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Start a sandbox on the first provider that works, as the server routes it. With `idempotencyKey`,
|
|
56
|
+
* sending the same create again (say, after a timeout) returns the same sandbox instead of starting
|
|
57
|
+
* a second one; use a new key for each sandbox you mean to start.
|
|
58
|
+
*/
|
|
59
|
+
async create(spec = {}, route = {}, opts = {}) {
|
|
60
|
+
const headers = opts.idempotencyKey ? { "Idempotency-Key": opts.idempotencyKey } : {};
|
|
61
|
+
const res = await this.request("POST", "/v1/sandboxes", { ...spec, ...route }, "application/json", headers);
|
|
62
|
+
const r = (await res.json());
|
|
63
|
+
return new RemoteSandbox(this, r.id, r.provider, { ...r, replayed: res.headers.get("idempotent-replayed") === "true" });
|
|
57
64
|
}
|
|
58
65
|
/** A handle on a sandbox you created earlier. */
|
|
59
66
|
sandbox(id) {
|
package/dist/computers.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { Ledger } from "./ledger.js";
|
|
2
|
+
import { type Attempt } from "./types.js";
|
|
1
3
|
/**
|
|
2
4
|
* Computers: machines with a screen that a model drives with clicks and keys (computer use).
|
|
3
5
|
* Linux and Windows desktops, Android phones. Routed like sandboxes: cheapest published price for
|
|
@@ -99,8 +101,17 @@ export interface ComputerProvider {
|
|
|
99
101
|
export declare class RoutedComputer {
|
|
100
102
|
readonly provider: ComputerProviderId;
|
|
101
103
|
private c;
|
|
104
|
+
/** Every create attempt, including failed fallbacks (empty for a handle from getComputer). */
|
|
105
|
+
readonly attempts: Attempt[];
|
|
106
|
+
/** List price per hour for this OS, where the provider publishes one. */
|
|
107
|
+
readonly estimatedHourlyUsd?: number | undefined;
|
|
108
|
+
private ledger?;
|
|
102
109
|
readonly id: string;
|
|
103
|
-
constructor(provider: ComputerProviderId, c: ProviderComputer
|
|
110
|
+
constructor(provider: ComputerProviderId, c: ProviderComputer,
|
|
111
|
+
/** Every create attempt, including failed fallbacks (empty for a handle from getComputer). */
|
|
112
|
+
attempts?: Attempt[],
|
|
113
|
+
/** List price per hour for this OS, where the provider publishes one. */
|
|
114
|
+
estimatedHourlyUsd?: number | undefined, ledger?: Ledger | undefined);
|
|
104
115
|
get nativeId(): string;
|
|
105
116
|
info(): Promise<ComputerInfo>;
|
|
106
117
|
act(action: ComputerAction): Promise<ActionResult>;
|
|
@@ -109,7 +120,10 @@ export declare class RoutedComputer {
|
|
|
109
120
|
format?: "png" | "jpeg";
|
|
110
121
|
quality?: number;
|
|
111
122
|
}): Promise<Uint8Array<ArrayBufferLike>>;
|
|
112
|
-
|
|
123
|
+
/** Run a shell command (max 30 s). The second argument is `{ timeoutSeconds }`, or the number alone. */
|
|
124
|
+
exec(command: string, opts?: number | {
|
|
125
|
+
timeoutSeconds?: number;
|
|
126
|
+
}): Promise<{
|
|
113
127
|
exitCode: number;
|
|
114
128
|
stdout: string;
|
|
115
129
|
stderr: string;
|
|
@@ -129,7 +143,12 @@ export declare class RoutedComputer {
|
|
|
129
143
|
expiresAt: string | null;
|
|
130
144
|
}>;
|
|
131
145
|
sleep(): Promise<void>;
|
|
146
|
+
/** Delete the computer and everything on it (its files and logins). */
|
|
132
147
|
delete(): Promise<void>;
|
|
148
|
+
/** Same as delete(), named like a sandbox's. */
|
|
149
|
+
kill(): Promise<void>;
|
|
150
|
+
/** `await using pc = await router.computer(...)` deletes it when the block ends. */
|
|
151
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
133
152
|
}
|
|
134
153
|
/** Published prices per OS for every computer provider. Same rules as the sandbox catalog. */
|
|
135
154
|
export interface ComputerCatalogEntry {
|
|
@@ -159,8 +178,32 @@ export interface ComputerCandidate {
|
|
|
159
178
|
}
|
|
160
179
|
/** The order a computer create would try, with the reason any provider is skipped. */
|
|
161
180
|
export declare function planComputer(providers: ComputerProvider[], os?: ComputerOs, prefer?: ComputerProviderId[]): ComputerCandidate[];
|
|
181
|
+
/** How a computer create is routed: the same knobs and rules as a sandbox's (see RouteOptions). */
|
|
182
|
+
export interface ComputerRoute {
|
|
183
|
+
provider?: ComputerProviderId | ComputerProviderId[];
|
|
184
|
+
/** true (default): move on when a provider is down or can't bill, stop on a setup error. "any": move on always. false: first only. */
|
|
185
|
+
fallback?: boolean | "any";
|
|
186
|
+
/** Seconds to wait for one provider before trying the next (default 120). */
|
|
187
|
+
createTimeoutSeconds?: number;
|
|
188
|
+
/** Seconds the whole create may take (default 240). */
|
|
189
|
+
deadlineSeconds?: number;
|
|
190
|
+
}
|
|
191
|
+
/** Split and check a POST /v1/computers body, like splitRequest does for sandboxes. */
|
|
192
|
+
export declare function splitComputerRequest(body: Record<string, unknown>): {
|
|
193
|
+
spec: ComputerSpec;
|
|
194
|
+
route: ComputerRoute;
|
|
195
|
+
};
|
|
162
196
|
/**
|
|
163
197
|
* Start a computer on the cheapest provider that offers this OS and has credentials (ties
|
|
164
|
-
* alphabetical), falling back down the list
|
|
198
|
+
* alphabetical), falling back down the list like a sandbox create: on to the next when a provider is
|
|
199
|
+
* down, slow or can't bill, but stopping on a setup error (a bad key or request). An explicit
|
|
200
|
+
* provider list keeps its order. Each computer goes in the ledger, so `ls` and `gc --all` find it;
|
|
201
|
+
* computers have no lifetime of their own, so plain `gc` leaves them.
|
|
165
202
|
*/
|
|
166
|
-
export declare function createComputer(providers: ComputerProvider[], spec?: ComputerSpec, prefer?: ComputerProviderId[]
|
|
203
|
+
export declare function createComputer(providers: ComputerProvider[], spec?: ComputerSpec, prefer?: ComputerProviderId[], route?: ComputerRoute & {
|
|
204
|
+
ledger?: Ledger;
|
|
205
|
+
}): Promise<RoutedComputer>;
|
|
206
|
+
/** Seconds to wait for one provider's computer before trying the next. */
|
|
207
|
+
export declare const DEFAULT_COMPUTER_TIMEOUT_S = 120;
|
|
208
|
+
/** Seconds a whole computer create may take. */
|
|
209
|
+
export declare const DEFAULT_COMPUTER_DEADLINE_S = 240;
|
package/dist/computers.js
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
|
-
import { RouterError } from "./types.js";
|
|
1
|
+
import { ProviderError, RouterError, SetupError } from "./types.js";
|
|
2
|
+
import { SETUP_ERRORS, errorStatus, withDeadline } from "./util.js";
|
|
2
3
|
export class RoutedComputer {
|
|
3
4
|
provider;
|
|
4
5
|
c;
|
|
6
|
+
attempts;
|
|
7
|
+
estimatedHourlyUsd;
|
|
8
|
+
ledger;
|
|
5
9
|
id;
|
|
6
|
-
constructor(provider, c
|
|
10
|
+
constructor(provider, c,
|
|
11
|
+
/** Every create attempt, including failed fallbacks (empty for a handle from getComputer). */
|
|
12
|
+
attempts = [],
|
|
13
|
+
/** List price per hour for this OS, where the provider publishes one. */
|
|
14
|
+
estimatedHourlyUsd, ledger) {
|
|
7
15
|
this.provider = provider;
|
|
8
16
|
this.c = c;
|
|
17
|
+
this.attempts = attempts;
|
|
18
|
+
this.estimatedHourlyUsd = estimatedHourlyUsd;
|
|
19
|
+
this.ledger = ledger;
|
|
9
20
|
this.id = `${provider}:${c.nativeId}`;
|
|
10
21
|
}
|
|
11
22
|
get nativeId() {
|
|
@@ -25,8 +36,9 @@ export class RoutedComputer {
|
|
|
25
36
|
screenshot(opts) {
|
|
26
37
|
return this.c.screenshot(opts);
|
|
27
38
|
}
|
|
28
|
-
|
|
29
|
-
|
|
39
|
+
/** Run a shell command (max 30 s). The second argument is `{ timeoutSeconds }`, or the number alone. */
|
|
40
|
+
exec(command, opts) {
|
|
41
|
+
return this.c.exec(command, typeof opts === "number" ? opts : opts?.timeoutSeconds);
|
|
30
42
|
}
|
|
31
43
|
readFile(path) {
|
|
32
44
|
return this.c.readFile(path);
|
|
@@ -48,8 +60,18 @@ export class RoutedComputer {
|
|
|
48
60
|
throw new RouterError(`${this.provider} can't sleep a computer; delete it instead`);
|
|
49
61
|
await this.c.sleep();
|
|
50
62
|
}
|
|
51
|
-
|
|
52
|
-
|
|
63
|
+
/** Delete the computer and everything on it (its files and logins). */
|
|
64
|
+
async delete() {
|
|
65
|
+
await this.c.delete();
|
|
66
|
+
this.ledger?.close(this.id, "deleted");
|
|
67
|
+
}
|
|
68
|
+
/** Same as delete(), named like a sandbox's. */
|
|
69
|
+
kill() {
|
|
70
|
+
return this.delete();
|
|
71
|
+
}
|
|
72
|
+
/** `await using pc = await router.computer(...)` deletes it when the block ends. */
|
|
73
|
+
async [Symbol.asyncDispose]() {
|
|
74
|
+
await this.delete();
|
|
53
75
|
}
|
|
54
76
|
}
|
|
55
77
|
export const COMPUTER_ENTRIES = [
|
|
@@ -166,27 +188,118 @@ export function planComputer(providers, os = "linux", prefer) {
|
|
|
166
188
|
}
|
|
167
189
|
return list;
|
|
168
190
|
}
|
|
191
|
+
const OS_LIST = ["linux", "windows", "android", "ios"];
|
|
192
|
+
const SPEC_FIELDS = ["os", "externalUserId", "name", "screen", "arch"];
|
|
193
|
+
const ROUTE_FIELDS = ["provider", "fallback", "createTimeoutSeconds", "deadlineSeconds"];
|
|
194
|
+
/** Split and check a POST /v1/computers body, like splitRequest does for sandboxes. */
|
|
195
|
+
export function splitComputerRequest(body) {
|
|
196
|
+
const bad = (msg) => new RouterError(msg, [], "bad_request");
|
|
197
|
+
const spec = {};
|
|
198
|
+
const route = {};
|
|
199
|
+
const unknown = [];
|
|
200
|
+
for (const [k, v] of Object.entries(body ?? {})) {
|
|
201
|
+
if (SPEC_FIELDS.includes(k))
|
|
202
|
+
spec[k] = v;
|
|
203
|
+
else if (ROUTE_FIELDS.includes(k))
|
|
204
|
+
route[k] = v;
|
|
205
|
+
else
|
|
206
|
+
unknown.push(k);
|
|
207
|
+
}
|
|
208
|
+
if (unknown.length)
|
|
209
|
+
throw bad(`unknown field${unknown.length > 1 ? "s" : ""} ${unknown.join(", ")}: a computer takes ${[...SPEC_FIELDS, ...ROUTE_FIELDS].join(", ")}`);
|
|
210
|
+
if (spec.os !== undefined && !OS_LIST.includes(spec.os))
|
|
211
|
+
throw bad(`os must be one of ${OS_LIST.join(", ")}`);
|
|
212
|
+
for (const k of ["externalUserId", "name"])
|
|
213
|
+
if (spec[k] !== undefined && typeof spec[k] !== "string")
|
|
214
|
+
throw bad(`${k} must be a string`);
|
|
215
|
+
const screen = spec.screen;
|
|
216
|
+
if (screen !== undefined && !(typeof screen === "object" && [screen.width, screen.height].every((n) => typeof n === "number" && Number.isFinite(n) && n > 0))) {
|
|
217
|
+
throw bad("screen must be { width, height } in pixels");
|
|
218
|
+
}
|
|
219
|
+
if (spec.arch !== undefined && spec.arch !== "x86_64" && spec.arch !== "arm64")
|
|
220
|
+
throw bad('arch must be "x86_64" or "arm64"');
|
|
221
|
+
for (const k of ["createTimeoutSeconds", "deadlineSeconds"]) {
|
|
222
|
+
const v = route[k];
|
|
223
|
+
if (v !== undefined && !(typeof v === "number" && Number.isFinite(v) && v > 0))
|
|
224
|
+
throw bad(`${k} must be a positive number`);
|
|
225
|
+
}
|
|
226
|
+
if (route.fallback !== undefined && route.fallback !== true && route.fallback !== false && route.fallback !== "any")
|
|
227
|
+
throw bad('fallback must be true, false or "any"');
|
|
228
|
+
return { spec: spec, route: route };
|
|
229
|
+
}
|
|
169
230
|
/**
|
|
170
231
|
* Start a computer on the cheapest provider that offers this OS and has credentials (ties
|
|
171
|
-
* alphabetical), falling back down the list
|
|
232
|
+
* alphabetical), falling back down the list like a sandbox create: on to the next when a provider is
|
|
233
|
+
* down, slow or can't bill, but stopping on a setup error (a bad key or request). An explicit
|
|
234
|
+
* provider list keeps its order. Each computer goes in the ledger, so `ls` and `gc --all` find it;
|
|
235
|
+
* computers have no lifetime of their own, so plain `gc` leaves them.
|
|
172
236
|
*/
|
|
173
|
-
export async function createComputer(providers, spec = {}, prefer) {
|
|
237
|
+
export async function createComputer(providers, spec = {}, prefer, route = {}) {
|
|
174
238
|
const os = spec.os ?? "linux";
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
.map((c) => providers.find((p) => p.id === c.provider));
|
|
239
|
+
const plan = planComputer(providers, os, prefer);
|
|
240
|
+
const usable = plan.filter((c) => !c.skip);
|
|
178
241
|
if (usable.length === 0) {
|
|
179
242
|
const offering = providers.filter((p) => p.computerOs.includes(os)).map((p) => p.id);
|
|
180
|
-
throw new RouterError(offering.length ? `no configured provider for a ${os} computer (offered by: ${offering.join(", ")})` : `no provider offers a ${os} computer
|
|
243
|
+
throw new RouterError(offering.length ? `no configured provider for a ${os} computer (offered by: ${offering.join(", ")})` : `no provider offers a ${os} computer`, [], "no_provider");
|
|
244
|
+
}
|
|
245
|
+
if (prefer && route.fallback === false && plan[0].skip) {
|
|
246
|
+
throw new RouterError(`${plan[0].provider} can't start this computer: ${plan[0].skip}`, [], "no_provider");
|
|
181
247
|
}
|
|
182
|
-
const
|
|
183
|
-
|
|
248
|
+
const tries = route.fallback === false ? usable.slice(0, 1) : usable;
|
|
249
|
+
const limitS = route.createTimeoutSeconds ?? DEFAULT_COMPUTER_TIMEOUT_S;
|
|
250
|
+
const deadline = Date.now() + (route.deadlineSeconds ?? DEFAULT_COMPUTER_DEADLINE_S) * 1000;
|
|
251
|
+
const attempts = [];
|
|
252
|
+
for (const c of tries) {
|
|
253
|
+
const left = deadline - Date.now();
|
|
254
|
+
if (left <= 0)
|
|
255
|
+
throw new RouterError(`computer create ran out of time: ${attempts.map((a) => `${a.provider}: ${a.error}`).join(" | ")}`, attempts, "timeout");
|
|
256
|
+
const p = providers.find((x) => x.id === c.provider);
|
|
257
|
+
const t0 = Date.now();
|
|
258
|
+
const pending = p.createComputer({ ...spec, os });
|
|
259
|
+
const waitS = Math.min(limitS, left / 1000);
|
|
260
|
+
let timedOut = false;
|
|
184
261
|
try {
|
|
185
|
-
|
|
262
|
+
const made = await withDeadline(pending, waitS * 1000, () => {
|
|
263
|
+
timedOut = true;
|
|
264
|
+
return new ProviderError(p.id, `create timed out after ${Number(waitS.toFixed(1))}s`);
|
|
265
|
+
});
|
|
266
|
+
attempts.push({ provider: p.id, ok: true, ms: Date.now() - t0 });
|
|
267
|
+
const pc = new RoutedComputer(p.id, made, attempts, c.estimatedHourlyUsd, route.ledger);
|
|
268
|
+
record(route.ledger, pc);
|
|
269
|
+
return pc;
|
|
186
270
|
}
|
|
187
271
|
catch (err) {
|
|
188
|
-
|
|
272
|
+
// One that turns up after we gave up is deleted, so it can't bill unseen (or left in the ledger for gc).
|
|
273
|
+
if (timedOut) {
|
|
274
|
+
pending.then((late) => {
|
|
275
|
+
const pc = new RoutedComputer(p.id, late, [], c.estimatedHourlyUsd, route.ledger);
|
|
276
|
+
record(route.ledger, pc);
|
|
277
|
+
return pc.delete();
|
|
278
|
+
}).catch(() => { });
|
|
279
|
+
}
|
|
280
|
+
const status = errorStatus(err);
|
|
281
|
+
attempts.push({ provider: p.id, ok: false, ms: Date.now() - t0, error: err.message, ...(status ? { status } : {}) });
|
|
282
|
+
const setup = err instanceof SetupError || (status !== undefined && SETUP_ERRORS.has(status));
|
|
283
|
+
if (route.fallback !== "any" && setup) {
|
|
284
|
+
throw new RouterError(`${err.message.replace(/[.\s]+$/, "")}. This looks like a setup problem with ${p.id} (key or request), so the router stopped instead of moving to another provider; pass fallback: "any" to fall back on every error`, attempts, "setup");
|
|
285
|
+
}
|
|
189
286
|
}
|
|
190
287
|
}
|
|
191
|
-
throw new RouterError(`all computer providers failed: ${
|
|
288
|
+
throw new RouterError(`all computer providers failed: ${attempts.map((a) => `${a.provider}: ${a.error}`).join(" | ")}`, attempts, "all_failed");
|
|
289
|
+
}
|
|
290
|
+
/** Computers keep running until deleted: the ledger gives them no lifetime, so only `gc --all` takes them. */
|
|
291
|
+
const NO_LIFETIME = "9999-12-31T00:00:00.000Z";
|
|
292
|
+
function record(ledger, pc) {
|
|
293
|
+
ledger?.open({
|
|
294
|
+
id: pc.id,
|
|
295
|
+
provider: pc.provider,
|
|
296
|
+
kind: "computer",
|
|
297
|
+
createdAt: new Date().toISOString(),
|
|
298
|
+
expiresAt: NO_LIFETIME,
|
|
299
|
+
usdHour: pc.estimatedHourlyUsd ?? 0,
|
|
300
|
+
});
|
|
192
301
|
}
|
|
302
|
+
/** Seconds to wait for one provider's computer before trying the next. */
|
|
303
|
+
export const DEFAULT_COMPUTER_TIMEOUT_S = 120;
|
|
304
|
+
/** Seconds a whole computer create may take. */
|
|
305
|
+
export const DEFAULT_COMPUTER_DEADLINE_S = 240;
|
package/dist/gc.js
CHANGED
|
@@ -34,8 +34,11 @@ export async function gc(router, opts = {}) {
|
|
|
34
34
|
continue;
|
|
35
35
|
}
|
|
36
36
|
try {
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
// Both close the ledger entry.
|
|
38
|
+
if (s.kind === "computer")
|
|
39
|
+
await (await router.getComputer(s.id)).delete();
|
|
40
|
+
else
|
|
41
|
+
await (await router.get(s.id)).kill();
|
|
39
42
|
results.push({ id: s.id, action: "killed" });
|
|
40
43
|
}
|
|
41
44
|
catch (err) {
|
package/dist/index.d.ts
CHANGED
|
@@ -7,9 +7,9 @@ export { VERIFIED, FAILING } from "./verified.js";
|
|
|
7
7
|
export { BILLED_VS_LIST } from "./billing.js";
|
|
8
8
|
export { validate, splitRequest, SPEC_KEYS, ROUTE_KEYS, STRATEGIES } from "./validate.js";
|
|
9
9
|
export { CATALOG, hourlyCost, unfit, info, type ProviderInfo } from "./catalog.js";
|
|
10
|
-
export { createApp, HttpError, ERROR_STATUS, type ServerHooks } from "./server.js";
|
|
10
|
+
export { createApp, HttpError, ERROR_STATUS, ORPHAN_GRACE_MS, type ServerHooks } from "./server.js";
|
|
11
11
|
export { Client, ClientError, RemoteSandbox, type CreateResponse } from "./client.js";
|
|
12
|
-
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";
|
|
12
|
+
export { RoutedComputer, createComputer, splitComputerRequest, DEFAULT_COMPUTER_TIMEOUT_S, DEFAULT_COMPUTER_DEADLINE_S, type ComputerRoute, 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";
|
|
13
13
|
export { bench, benchMarkdown, type BenchRow } from "./bench.js";
|
|
14
14
|
export { aggregate, loadReports, makeReport, renderLeaderboard, type BenchReport, type LeaderRow } from "./leaderboard.js";
|
|
15
15
|
export { MaritimeProvider } from "./providers/maritime.js";
|
package/dist/index.js
CHANGED
|
@@ -7,9 +7,9 @@ export { VERIFIED, FAILING } from "./verified.js";
|
|
|
7
7
|
export { BILLED_VS_LIST } from "./billing.js";
|
|
8
8
|
export { validate, splitRequest, SPEC_KEYS, ROUTE_KEYS, STRATEGIES } from "./validate.js";
|
|
9
9
|
export { CATALOG, hourlyCost, unfit, info } from "./catalog.js";
|
|
10
|
-
export { createApp, HttpError, ERROR_STATUS } from "./server.js";
|
|
10
|
+
export { createApp, HttpError, ERROR_STATUS, ORPHAN_GRACE_MS } from "./server.js";
|
|
11
11
|
export { Client, ClientError, RemoteSandbox } from "./client.js";
|
|
12
|
-
export { RoutedComputer, createComputer, planComputer, computerCatalog, computerHourly, COMPUTER_ENTRIES, } from "./computers.js";
|
|
12
|
+
export { RoutedComputer, createComputer, splitComputerRequest, DEFAULT_COMPUTER_TIMEOUT_S, DEFAULT_COMPUTER_DEADLINE_S, planComputer, computerCatalog, computerHourly, COMPUTER_ENTRIES, } from "./computers.js";
|
|
13
13
|
export { bench, benchMarkdown } from "./bench.js";
|
|
14
14
|
export { aggregate, loadReports, makeReport, renderLeaderboard } from "./leaderboard.js";
|
|
15
15
|
export { MaritimeProvider } from "./providers/maritime.js";
|
package/dist/ledger.d.ts
CHANGED
|
@@ -3,7 +3,9 @@ import type { ProviderId } from "./types.js";
|
|
|
3
3
|
export interface LedgerEntry {
|
|
4
4
|
/** Routed id, `provider:nativeId`. */
|
|
5
5
|
id: string;
|
|
6
|
-
provider: ProviderId;
|
|
6
|
+
provider: ProviderId | (string & {});
|
|
7
|
+
/** "computer" for desktops and phones (deleted with getComputer); sandboxes leave it out. */
|
|
8
|
+
kind?: "computer";
|
|
7
9
|
/** ISO time the create returned. */
|
|
8
10
|
createdAt: string;
|
|
9
11
|
/** ISO time its requested lifetime (spec.timeoutMinutes) runs out. */
|
package/dist/router.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { RoutedComputer, type ComputerOs, type ComputerProvider, type ComputerProviderId, type ComputerSpec } from "./computers.js";
|
|
1
|
+
import { type ComputerRoute, RoutedComputer, type ComputerOs, type ComputerProvider, type ComputerProviderId, type ComputerSpec } from "./computers.js";
|
|
2
2
|
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
|
/** vCPUs and memory a sandbox gets. */
|
|
@@ -63,6 +63,18 @@ export interface RouterOptions {
|
|
|
63
63
|
requireVerified?: boolean;
|
|
64
64
|
/** Billed / list-price ratio per provider, behind `estimatedHourlyUsd`. Default: the ratios in `BILLED_VS_LIST`, from saved cost runs. */
|
|
65
65
|
billedVsList?: Partial<Record<ProviderId, number>>;
|
|
66
|
+
/**
|
|
67
|
+
* When a create runs well past a provider's usual start time, try the next provider sooner than
|
|
68
|
+
* `createTimeoutSeconds`. The wait becomes `factor` x the provider's median start time (or 1.5x the
|
|
69
|
+
* slowest of its recent starts, if longer), at least `minSeconds`. It applies only when the caller
|
|
70
|
+
* didn't set `createTimeoutSeconds`, the router has a start time for the provider (3 of its own creates,
|
|
71
|
+
* or the bench file), and another provider is left to try. Default `{ factor: 5, minSeconds: 10 }`;
|
|
72
|
+
* `false` always waits the full `createTimeoutSeconds`.
|
|
73
|
+
*/
|
|
74
|
+
quickFallback?: false | {
|
|
75
|
+
factor?: number;
|
|
76
|
+
minSeconds?: number;
|
|
77
|
+
};
|
|
66
78
|
}
|
|
67
79
|
/** Desktop-only providers. Filled in as adapters are added. */
|
|
68
80
|
export declare function defaultComputerProviders(): ComputerProvider[];
|
|
@@ -77,6 +89,7 @@ export declare class Router {
|
|
|
77
89
|
/** Create times this router measured, most recent last. */
|
|
78
90
|
private readonly live;
|
|
79
91
|
private computerOnly;
|
|
92
|
+
private readonly quick;
|
|
80
93
|
/** The local record of sandboxes this router created, or undefined when turned off. */
|
|
81
94
|
readonly ledger?: Ledger;
|
|
82
95
|
constructor(opts?: RouterOptions);
|
|
@@ -94,6 +107,11 @@ export declare class Router {
|
|
|
94
107
|
private whyNone;
|
|
95
108
|
/** Start a sandbox on the best available provider, falling back down the list on failure. */
|
|
96
109
|
create(spec?: SandboxSpec, route?: RouteOptions): Promise<RoutedSandbox>;
|
|
110
|
+
/**
|
|
111
|
+
* Seconds to wait for this provider's create: the caller's `createTimeoutSeconds`, or (see
|
|
112
|
+
* `quickFallback`) a few times its usual start time when another provider is left to try.
|
|
113
|
+
*/
|
|
114
|
+
private waitFor;
|
|
97
115
|
/** Remember a successful create's time, for the speed tie-break and strategy "fastest". */
|
|
98
116
|
private observe;
|
|
99
117
|
private record;
|
|
@@ -105,9 +123,8 @@ export declare class Router {
|
|
|
105
123
|
provider?: ComputerProviderId | ComputerProviderId[];
|
|
106
124
|
}): import("./computers.js").ComputerCandidate[];
|
|
107
125
|
/** Start a desktop or phone on the cheapest provider for its OS (ties alphabetical), with fallback. */
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}): Promise<RoutedComputer>;
|
|
126
|
+
/** Start a desktop or phone, routed like a sandbox (see createComputer). */
|
|
127
|
+
computer(spec?: ComputerSpec, route?: ComputerRoute): Promise<RoutedComputer>;
|
|
111
128
|
getComputer(id: string): Promise<RoutedComputer>;
|
|
112
129
|
/** Reattach to a sandbox by its routed id (`provider:nativeId`). */
|
|
113
130
|
get(id: string): Promise<RoutedSandbox>;
|
package/dist/router.js
CHANGED
|
@@ -2,7 +2,7 @@ import { CATALOG, DEFAULT_MEMORY_GB, DEFAULT_VCPU, accessLabel, fixedSize, hourl
|
|
|
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, errorStatus, timeoutError } from "./util.js";
|
|
5
|
+
import { DEFAULT_EXEC_TIMEOUT_S, SETUP_ERRORS, errorStatus, timeoutError, withDeadline } from "./util.js";
|
|
6
6
|
import { BILLED_VS_LIST } from "./billing.js";
|
|
7
7
|
import { FAILING, VERIFIED } from "./verified.js";
|
|
8
8
|
import { validate } from "./validate.js";
|
|
@@ -103,6 +103,7 @@ export class Router {
|
|
|
103
103
|
/** Create times this router measured, most recent last. */
|
|
104
104
|
live = new Map();
|
|
105
105
|
computerOnly;
|
|
106
|
+
quick;
|
|
106
107
|
/** The local record of sandboxes this router created, or undefined when turned off. */
|
|
107
108
|
ledger;
|
|
108
109
|
constructor(opts = {}) {
|
|
@@ -117,6 +118,7 @@ export class Router {
|
|
|
117
118
|
this.billed =
|
|
118
119
|
opts.billedVsList ?? Object.fromEntries(Object.entries(BILLED_VS_LIST).map(([id, b]) => [id, b.ratio]));
|
|
119
120
|
this.computerOnly = opts.computers ?? (opts.providers ? [] : defaultComputerProviders());
|
|
121
|
+
this.quick = opts.quickFallback === false ? false : { factor: 5, minSeconds: 10, ...opts.quickFallback };
|
|
120
122
|
}
|
|
121
123
|
/** Registered and has credentials. False for providers this router doesn't know. */
|
|
122
124
|
isConfigured(id) {
|
|
@@ -233,22 +235,23 @@ export class Router {
|
|
|
233
235
|
}
|
|
234
236
|
const attempts = [];
|
|
235
237
|
const tries = route.fallback === false ? usable.slice(0, 1) : usable;
|
|
236
|
-
const limitS = route.createTimeoutSeconds ?? DEFAULT_CREATE_TIMEOUT_S;
|
|
237
238
|
const deadline = Date.now() + (route.deadlineSeconds ?? DEFAULT_CREATE_DEADLINE_S) * 1000;
|
|
238
|
-
for (const c of tries) {
|
|
239
|
+
for (const [i, c] of tries.entries()) {
|
|
239
240
|
const left = deadline - Date.now();
|
|
240
241
|
if (left <= 0) {
|
|
241
242
|
throw new RouterError(`create ran out of time (deadlineSeconds) after trying ${attempts.map((a) => a.provider).join(", ")}: ${attempts.map((a) => `${a.provider}: ${a.error}`).join(" | ")}`, attempts, "timeout");
|
|
242
243
|
}
|
|
243
244
|
const t0 = Date.now();
|
|
244
245
|
const pending = this.provider(c.provider).create(spec);
|
|
246
|
+
const { limitS, typicalMs } = this.waitFor(c.provider, route, i === tries.length - 1);
|
|
245
247
|
const waitS = Math.min(limitS, left / 1000);
|
|
246
248
|
const price = { estimatedHourlyUsd: c.estimatedHourlyUsd, listHourlyUsd: c.listHourlyUsd, size: c.size };
|
|
247
249
|
let timedOut = false;
|
|
248
250
|
try {
|
|
249
251
|
const box = await withDeadline(pending, waitS * 1000, () => {
|
|
250
252
|
timedOut = true;
|
|
251
|
-
|
|
253
|
+
const usual = typicalMs !== undefined && waitS < (route.createTimeoutSeconds ?? DEFAULT_CREATE_TIMEOUT_S) ? `; it usually starts in ${Number((typicalMs / 1000).toFixed(1))}s` : "";
|
|
254
|
+
return new ProviderError(c.provider, `create timed out after ${Number(waitS.toFixed(1))}s${usual}`);
|
|
252
255
|
});
|
|
253
256
|
attempts.push({ provider: c.provider, ok: true, ms: Date.now() - t0 });
|
|
254
257
|
this.observe(c.provider, Date.now() - t0);
|
|
@@ -273,6 +276,22 @@ export class Router {
|
|
|
273
276
|
}
|
|
274
277
|
throw new RouterError(`all providers failed: ${attempts.map((a) => `${a.provider}: ${a.error}`).join(" | ")}`, attempts, "all_failed");
|
|
275
278
|
}
|
|
279
|
+
/**
|
|
280
|
+
* Seconds to wait for this provider's create: the caller's `createTimeoutSeconds`, or (see
|
|
281
|
+
* `quickFallback`) a few times its usual start time when another provider is left to try.
|
|
282
|
+
*/
|
|
283
|
+
waitFor(id, route, last) {
|
|
284
|
+
const full = route.createTimeoutSeconds ?? DEFAULT_CREATE_TIMEOUT_S;
|
|
285
|
+
if (!this.quick || last || route.createTimeoutSeconds !== undefined)
|
|
286
|
+
return { limitS: full };
|
|
287
|
+
const live = this.live.get(id);
|
|
288
|
+
const samples = live && live.length >= LIVE_MIN_SAMPLES ? live : loadBench(this.benchFile)[id]?.createMs;
|
|
289
|
+
const typicalMs = median(samples);
|
|
290
|
+
if (typicalMs === undefined || !samples?.length)
|
|
291
|
+
return { limitS: full };
|
|
292
|
+
const s = Math.max(this.quick.factor * typicalMs, 1.5 * Math.max(...samples)) / 1000;
|
|
293
|
+
return { limitS: Math.min(full, Math.max(this.quick.minSeconds, s)), typicalMs };
|
|
294
|
+
}
|
|
276
295
|
/** Remember a successful create's time, for the speed tie-break and strategy "fastest". */
|
|
277
296
|
observe(id, ms) {
|
|
278
297
|
const xs = this.live.get(id) ?? [];
|
|
@@ -309,19 +328,21 @@ export class Router {
|
|
|
309
328
|
return planComputer(this.computerProviders(), os, prefer);
|
|
310
329
|
}
|
|
311
330
|
/** Start a desktop or phone on the cheapest provider for its OS (ties alphabetical), with fallback. */
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
331
|
+
/** Start a desktop or phone, routed like a sandbox (see createComputer). */
|
|
332
|
+
computer(spec = {}, route = {}) {
|
|
333
|
+
const prefer = route.provider ? [].concat(route.provider) : undefined;
|
|
334
|
+
return createComputer(this.computerProviders(), spec, prefer, { ...route, ledger: this.ledger });
|
|
315
335
|
}
|
|
316
336
|
async getComputer(id) {
|
|
317
337
|
const i = id.indexOf(":");
|
|
318
338
|
if (i <= 0)
|
|
319
|
-
throw new RouterError(`bad computer id "${id}": expected provider:id
|
|
339
|
+
throw new RouterError(`bad computer id "${id}": expected provider:id`, [], "bad_request");
|
|
320
340
|
const provider = id.slice(0, i);
|
|
321
341
|
const p = this.computerProviders().find((c) => c.id === provider);
|
|
322
342
|
if (!p)
|
|
323
|
-
throw new RouterError(`${provider} does not offer computers
|
|
324
|
-
|
|
343
|
+
throw new RouterError(`${provider} does not offer computers`, [], "bad_request");
|
|
344
|
+
const e = this.ledger?.list().find((x) => x.id === id);
|
|
345
|
+
return new RoutedComputer(provider, await p.connectComputer(id.slice(i + 1)), [], e?.usdHour, this.ledger);
|
|
325
346
|
}
|
|
326
347
|
/** Reattach to a sandbox by its routed id (`provider:nativeId`). */
|
|
327
348
|
async get(id) {
|
|
@@ -437,29 +458,8 @@ export function balanced(list) {
|
|
|
437
458
|
export const DEFAULT_CREATE_TIMEOUT_S = 120;
|
|
438
459
|
/** Seconds a whole create may take over every provider it tries (RouteOptions.deadlineSeconds). */
|
|
439
460
|
export const DEFAULT_CREATE_DEADLINE_S = 240;
|
|
440
|
-
/**
|
|
441
|
-
* HTTP statuses that mean the request or the key is wrong, not that the provider is down. 402 (out of
|
|
442
|
-
* balance, plan needed) isn't one: an unfunded account is exactly when the next provider should take the
|
|
443
|
-
* sandbox, and the failed attempt stays on `attempts` (the CLI prints it), so it isn't hidden.
|
|
444
|
-
*/
|
|
445
|
-
const SETUP_ERRORS = new Set([400, 401, 403, 404, 422]);
|
|
446
461
|
/** Seconds past a command's timeout before the router stops waiting for the adapter. */
|
|
447
462
|
export const EXEC_GRACE_S = 30;
|
|
448
|
-
/** Resolve with `work`, or reject with `onExpire()` after `ms`. A late `work` rejection is swallowed. */
|
|
449
|
-
async function withDeadline(work, ms, onExpire) {
|
|
450
|
-
let timer;
|
|
451
|
-
const expired = new Promise((_, reject) => {
|
|
452
|
-
timer = setTimeout(() => reject(onExpire()), ms);
|
|
453
|
-
timer.unref();
|
|
454
|
-
});
|
|
455
|
-
try {
|
|
456
|
-
return await Promise.race([work, expired]);
|
|
457
|
-
}
|
|
458
|
-
finally {
|
|
459
|
-
clearTimeout(timer);
|
|
460
|
-
work.catch(() => { });
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
463
|
/** The size a provider gives for a spec: its fixed default image size, else what was asked for. */
|
|
464
464
|
export function sizeOf(id, spec = {}) {
|
|
465
465
|
return fixedSize(id, spec) ?? { vcpu: spec.vcpu ?? DEFAULT_VCPU, memoryGb: spec.memoryGb ?? DEFAULT_MEMORY_GB };
|
package/dist/server.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
2
|
import { Router, type RoutedSandbox } from "./router.js";
|
|
3
|
+
import { type ComputerSpec, type RoutedComputer } from "./computers.js";
|
|
3
4
|
import { type RouteOptions, type RouterErrorCode, type SandboxSpec } from "./types.js";
|
|
4
5
|
/**
|
|
5
6
|
* Hooks for running this API for many users (accounts, billing, per-user sandboxes). With none set,
|
|
@@ -30,8 +31,21 @@ export interface ServerHooks {
|
|
|
30
31
|
afterKill?(caller: unknown, id: string, outcome?: {
|
|
31
32
|
error?: Error;
|
|
32
33
|
}): Promise<void>;
|
|
33
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* /v1/computers. Default: on for a single-user server, off when `authorize` is set. When on with
|
|
36
|
+
* `authorize`, `owns` guards computer ids too, and the computer hooks below run.
|
|
37
|
+
*/
|
|
34
38
|
computers?: boolean;
|
|
39
|
+
/** Before a computer starts. Throw HttpError to refuse; may edit spec. */
|
|
40
|
+
beforeCreateComputer?(caller: unknown, spec: ComputerSpec): Promise<void>;
|
|
41
|
+
/** After a computer started. Return fields to add to the answer. If this throws, the computer is deleted. */
|
|
42
|
+
afterCreateComputer?(caller: unknown, computer: RoutedComputer): Promise<Record<string, unknown> | void>;
|
|
43
|
+
/** After DELETE /v1/computers/:id; `error` is set when the provider's delete failed. */
|
|
44
|
+
afterDeleteComputer?(caller: unknown, id: string, outcome?: {
|
|
45
|
+
error?: Error;
|
|
46
|
+
}): Promise<void>;
|
|
47
|
+
/** Milliseconds a sandbox whose caller hung up (having sent an Idempotency-Key) waits for a retry to collect it. Default 120000. */
|
|
48
|
+
orphanGraceMs?: number;
|
|
35
49
|
/** false turns off pause. */
|
|
36
50
|
pause?: boolean;
|
|
37
51
|
}
|
|
@@ -68,5 +82,7 @@ export declare class HttpError extends Error {
|
|
|
68
82
|
readonly status: number;
|
|
69
83
|
constructor(status: number, message: string);
|
|
70
84
|
}
|
|
85
|
+
/** How long a sandbox whose caller hung up (with an Idempotency-Key) waits for a retry to collect it. */
|
|
86
|
+
export declare const ORPHAN_GRACE_MS = 120000;
|
|
71
87
|
/** The HTTP status for each RouterError code (see RouterErrorCode). */
|
|
72
88
|
export declare const ERROR_STATUS: Record<RouterErrorCode, number>;
|
package/dist/server.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { timingSafeEqual } from "node:crypto";
|
|
1
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
3
|
import { CATALOG } from "./catalog.js";
|
|
4
4
|
import { Router } from "./router.js";
|
|
5
|
+
import { splitComputerRequest } from "./computers.js";
|
|
5
6
|
import { ProviderError, RouterError } from "./types.js";
|
|
6
7
|
import { errorStatus } from "./util.js";
|
|
7
8
|
import { splitRequest } from "./validate.js";
|
|
@@ -49,6 +50,63 @@ corsOrigins = (process.env.SANDBOXROUTER_CORS ?? "").split(",").map((s) => s.tri
|
|
|
49
50
|
};
|
|
50
51
|
const live = new Map();
|
|
51
52
|
const computersOn = hooks.computers ?? !hooks.authorize;
|
|
53
|
+
/**
|
|
54
|
+
* Idempotency-Key: a create retried with the same key (by the same caller, to the same path) gets the
|
|
55
|
+
* first create's answer instead of starting another sandbox, for 24 hours. A retry that arrives while
|
|
56
|
+
* the first is still starting waits for it. The same key with a different body is refused (422).
|
|
57
|
+
* Failed creates aren't remembered, so retrying one runs it again.
|
|
58
|
+
*/
|
|
59
|
+
const replays = new Map();
|
|
60
|
+
const once = async (req, res, body, run) => {
|
|
61
|
+
const raw = req.headers["idempotency-key"];
|
|
62
|
+
if (raw === undefined) {
|
|
63
|
+
const r = await run(false);
|
|
64
|
+
return json(res, r.status, r.body);
|
|
65
|
+
}
|
|
66
|
+
const key = Array.isArray(raw) ? raw[0] : raw;
|
|
67
|
+
if (!key || key.length > 255)
|
|
68
|
+
throw new HttpError(400, "Idempotency-Key must be 1 to 255 characters");
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
for (const [k, v] of replays)
|
|
71
|
+
if (now - v.at > IDEMPOTENCY_TTL_MS)
|
|
72
|
+
replays.delete(k);
|
|
73
|
+
while (replays.size >= IDEMPOTENCY_MAX)
|
|
74
|
+
replays.delete(replays.keys().next().value);
|
|
75
|
+
const scope = sha256(`${req.headers.authorization ?? ""}\n${req.url}\n${key}`);
|
|
76
|
+
const bodyHash = sha256(JSON.stringify(body ?? {}));
|
|
77
|
+
const hit = replays.get(scope);
|
|
78
|
+
if (hit) {
|
|
79
|
+
if (hit.bodyHash !== bodyHash)
|
|
80
|
+
throw new HttpError(422, "this Idempotency-Key was already used with a different request");
|
|
81
|
+
const r = await hit.result;
|
|
82
|
+
hit.claimed = true;
|
|
83
|
+
res.setHeader("Idempotent-Replayed", "true");
|
|
84
|
+
return json(res, r.status, r.body);
|
|
85
|
+
}
|
|
86
|
+
const entry = { bodyHash, result: run(true), at: now, claimed: false };
|
|
87
|
+
replays.set(scope, entry);
|
|
88
|
+
let r;
|
|
89
|
+
try {
|
|
90
|
+
r = await entry.result;
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
replays.delete(scope);
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
// The caller hung up but sent a key, so it means to retry: keep the sandbox for a retry to
|
|
97
|
+
// collect, and only then give up on it.
|
|
98
|
+
if (!res.writableEnded && (res.destroyed || !res.writable) && r.discard) {
|
|
99
|
+
setTimeout(() => {
|
|
100
|
+
if (!entry.claimed) {
|
|
101
|
+
replays.delete(scope);
|
|
102
|
+
r.discard().catch(() => { });
|
|
103
|
+
}
|
|
104
|
+
}, hooks.orphanGraceMs ?? ORPHAN_GRACE_MS).unref();
|
|
105
|
+
}
|
|
106
|
+
else
|
|
107
|
+
entry.claimed = true;
|
|
108
|
+
return json(res, r.status, r.body);
|
|
109
|
+
};
|
|
52
110
|
const cors = (req, res) => {
|
|
53
111
|
const origin = req.headers.origin;
|
|
54
112
|
if (!origin || !(corsOrigins.includes("*") || corsOrigins.includes(origin)))
|
|
@@ -108,46 +166,59 @@ corsOrigins = (process.env.SANDBOXROUTER_CORS ?? "").split(",").map((s) => s.tri
|
|
|
108
166
|
if (parts[1] === "computers") {
|
|
109
167
|
if (!computersOn)
|
|
110
168
|
return json(res, 404, { error: "computers aren't available on this server" });
|
|
111
|
-
return computers(req, res, url, parts);
|
|
169
|
+
return computers(req, res, url, parts, caller);
|
|
112
170
|
}
|
|
113
171
|
if (parts[1] !== "sandboxes")
|
|
114
172
|
return json(res, 404, { error: "not found" });
|
|
115
173
|
// POST /v1/sandboxes
|
|
116
174
|
if (req.method === "POST" && parts.length === 2) {
|
|
117
|
-
const
|
|
118
|
-
|
|
175
|
+
const body = await readJson(req);
|
|
176
|
+
const { spec, route } = splitRequest(body);
|
|
119
177
|
// A caller that gives up (client timeout, closed connection) never learns the id: kill what
|
|
120
|
-
// starts after that rather than leave it running, and billing, unseen.
|
|
178
|
+
// starts after that rather than leave it running, and billing, unseen. With an Idempotency-Key
|
|
179
|
+
// it's kept for a retry instead (see once).
|
|
121
180
|
let hungUp = false;
|
|
122
181
|
res.on("close", () => (hungUp = !res.writableFinished));
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
182
|
+
return once(req, res, body, async (keepIfHungUp) => {
|
|
183
|
+
await hooks.beforeCreate?.(caller, spec, route);
|
|
184
|
+
let sbx;
|
|
185
|
+
let extra;
|
|
186
|
+
try {
|
|
187
|
+
sbx = await router.create(spec, route);
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
await hooks.createFailed?.(caller);
|
|
191
|
+
throw err;
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
if (hungUp && !keepIfHungUp)
|
|
195
|
+
throw new HttpError(499, "the client closed the connection before the sandbox was ready; it was killed");
|
|
196
|
+
extra = await hooks.afterCreate?.(caller, sbx, spec);
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
await sbx.kill().catch(() => { });
|
|
200
|
+
await hooks.createFailed?.(caller);
|
|
201
|
+
throw err;
|
|
202
|
+
}
|
|
203
|
+
live.set(sbx.id, sbx);
|
|
204
|
+
const made = sbx;
|
|
205
|
+
return {
|
|
206
|
+
status: 201,
|
|
207
|
+
body: {
|
|
208
|
+
id: sbx.id,
|
|
209
|
+
provider: sbx.provider,
|
|
210
|
+
size: sbx.size,
|
|
211
|
+
estimatedHourlyUsd: sbx.estimatedHourlyUsd,
|
|
212
|
+
listHourlyUsd: sbx.listHourlyUsd,
|
|
213
|
+
attempts: sbx.attempts,
|
|
214
|
+
...(extra ?? {}),
|
|
215
|
+
},
|
|
216
|
+
discard: async () => {
|
|
217
|
+
live.delete(made.id);
|
|
218
|
+
await made.kill().catch(() => { });
|
|
219
|
+
await hooks.afterKill?.(caller, made.id);
|
|
220
|
+
},
|
|
221
|
+
};
|
|
151
222
|
});
|
|
152
223
|
}
|
|
153
224
|
const id = parts[2] ? decodeURIComponent(parts[2]) : "";
|
|
@@ -220,21 +291,47 @@ corsOrigins = (process.env.SANDBOXROUTER_CORS ?? "").split(",").map((s) => s.tri
|
|
|
220
291
|
}
|
|
221
292
|
return json(res, 404, { error: "not found" });
|
|
222
293
|
};
|
|
223
|
-
const computers = async (req, res, url, parts) => {
|
|
294
|
+
const computers = async (req, res, url, parts, caller) => {
|
|
224
295
|
if (req.method === "POST" && parts.length === 2) {
|
|
225
|
-
const
|
|
226
|
-
const
|
|
227
|
-
return
|
|
296
|
+
const body = await readJson(req);
|
|
297
|
+
const { spec, route } = splitComputerRequest(body);
|
|
298
|
+
return once(req, res, body, async () => {
|
|
299
|
+
await hooks.beforeCreateComputer?.(caller, spec);
|
|
300
|
+
const c = await router.computer(spec, route);
|
|
301
|
+
let extra;
|
|
302
|
+
try {
|
|
303
|
+
extra = await hooks.afterCreateComputer?.(caller, c);
|
|
304
|
+
}
|
|
305
|
+
catch (err) {
|
|
306
|
+
await c.delete().catch(() => { });
|
|
307
|
+
throw err;
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
status: 201,
|
|
311
|
+
body: { ...(await c.info()), id: c.id, nativeId: c.nativeId, provider: c.provider, estimatedHourlyUsd: c.estimatedHourlyUsd, attempts: c.attempts, ...(extra ?? {}) },
|
|
312
|
+
};
|
|
313
|
+
});
|
|
228
314
|
}
|
|
229
315
|
const id = parts[2] ? decodeURIComponent(parts[2]) : "";
|
|
230
316
|
if (!id)
|
|
231
317
|
return json(res, 404, { error: "not found" });
|
|
318
|
+
if (hooks.owns && !(await hooks.owns(caller, id)))
|
|
319
|
+
throw new HttpError(404, "no such computer");
|
|
232
320
|
const c = await router.getComputer(id);
|
|
233
321
|
const sub = parts[3];
|
|
234
322
|
if (req.method === "GET" && !sub)
|
|
235
323
|
return json(res, 200, { ...(await c.info()), id, nativeId: c.nativeId });
|
|
236
324
|
if (req.method === "DELETE" && !sub) {
|
|
237
|
-
|
|
325
|
+
try {
|
|
326
|
+
await c.delete();
|
|
327
|
+
}
|
|
328
|
+
catch (err) {
|
|
329
|
+
if (errorStatus(err) !== 404) {
|
|
330
|
+
await hooks.afterDeleteComputer?.(caller, id, { error: err });
|
|
331
|
+
throw err;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
await hooks.afterDeleteComputer?.(caller, id);
|
|
238
335
|
return json(res, 200, { id, deleted: true });
|
|
239
336
|
}
|
|
240
337
|
if (req.method === "POST" && sub === "actions") {
|
|
@@ -306,6 +403,12 @@ function describeError(err) {
|
|
|
306
403
|
const code = err instanceof RouterError ? err.code : undefined;
|
|
307
404
|
return { status, body: { error: err?.message ?? String(err), ...(code ? { code } : {}), ...(err?.attempts?.length ? { attempts: err.attempts } : {}) } };
|
|
308
405
|
}
|
|
406
|
+
/** How long an Idempotency-Key is remembered, and how many at most. */
|
|
407
|
+
const IDEMPOTENCY_TTL_MS = 24 * 3600_000;
|
|
408
|
+
const IDEMPOTENCY_MAX = 10_000;
|
|
409
|
+
/** How long a sandbox whose caller hung up (with an Idempotency-Key) waits for a retry to collect it. */
|
|
410
|
+
export const ORPHAN_GRACE_MS = 120_000;
|
|
411
|
+
const sha256 = (s) => createHash("sha256").update(s).digest("hex");
|
|
309
412
|
/** The HTTP status for each RouterError code (see RouterErrorCode). */
|
|
310
413
|
export const ERROR_STATUS = {
|
|
311
414
|
bad_request: 400,
|
package/dist/types.d.ts
CHANGED
|
@@ -96,7 +96,8 @@ export interface Provider {
|
|
|
96
96
|
connect(nativeId: string): Promise<ProviderSandbox>;
|
|
97
97
|
}
|
|
98
98
|
export interface Attempt {
|
|
99
|
-
provider
|
|
99
|
+
/** A sandbox provider, or a computer provider (some sell only computers). */
|
|
100
|
+
provider: ProviderId | (string & {});
|
|
100
101
|
ok: boolean;
|
|
101
102
|
ms: number;
|
|
102
103
|
error?: string;
|
package/dist/util.d.ts
CHANGED
|
@@ -43,3 +43,11 @@ export declare function optionalImport<T>(pkg: string, provider: string): Promis
|
|
|
43
43
|
* (E2B's AuthenticationError, AWS's CredentialsProviderError). Undefined when there's none.
|
|
44
44
|
*/
|
|
45
45
|
export declare function errorStatus(err: unknown, depth?: number): number | undefined;
|
|
46
|
+
/**
|
|
47
|
+
* HTTP statuses that mean the request or the key is wrong, not that the provider is down. 402 (out of
|
|
48
|
+
* balance, plan needed) isn't one: an unfunded account is exactly when the next provider should take the
|
|
49
|
+
* sandbox, and the failed attempt stays on `attempts` (the CLI prints it), so it isn't hidden.
|
|
50
|
+
*/
|
|
51
|
+
export declare const SETUP_ERRORS: Set<number>;
|
|
52
|
+
/** Resolve with `work`, or reject with `onExpire()` after `ms`. A late `work` rejection is swallowed. */
|
|
53
|
+
export declare function withDeadline<T>(work: Promise<T>, ms: number, onExpire: () => Error): Promise<T>;
|
package/dist/util.js
CHANGED
|
@@ -111,3 +111,24 @@ export function errorStatus(err, depth = 0) {
|
|
|
111
111
|
return 429;
|
|
112
112
|
return errorStatus(e.cause, depth + 1);
|
|
113
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* HTTP statuses that mean the request or the key is wrong, not that the provider is down. 402 (out of
|
|
116
|
+
* balance, plan needed) isn't one: an unfunded account is exactly when the next provider should take the
|
|
117
|
+
* sandbox, and the failed attempt stays on `attempts` (the CLI prints it), so it isn't hidden.
|
|
118
|
+
*/
|
|
119
|
+
export const SETUP_ERRORS = new Set([400, 401, 403, 404, 422]);
|
|
120
|
+
/** Resolve with `work`, or reject with `onExpire()` after `ms`. A late `work` rejection is swallowed. */
|
|
121
|
+
export async function withDeadline(work, ms, onExpire) {
|
|
122
|
+
let timer;
|
|
123
|
+
const expired = new Promise((_, reject) => {
|
|
124
|
+
timer = setTimeout(() => reject(onExpire()), ms);
|
|
125
|
+
timer.unref();
|
|
126
|
+
});
|
|
127
|
+
try {
|
|
128
|
+
return await Promise.race([work, expired]);
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
clearTimeout(timer);
|
|
132
|
+
work.catch(() => { });
|
|
133
|
+
}
|
|
134
|
+
}
|
package/dist/verified.js
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
export const VERIFIED = {
|
|
2
2
|
agent37: "2026-11-11",
|
|
3
|
-
agentcore: "2026-11-
|
|
4
|
-
lambda: "2026-11-
|
|
5
|
-
blaxel: "2026-11-
|
|
3
|
+
agentcore: "2026-11-13",
|
|
4
|
+
lambda: "2026-11-13",
|
|
5
|
+
blaxel: "2026-11-13",
|
|
6
6
|
cloudflare: "2026-11-11",
|
|
7
|
-
daytona: "2026-11-
|
|
8
|
-
e2b: "2026-11-
|
|
7
|
+
daytona: "2026-11-13",
|
|
8
|
+
e2b: "2026-11-13",
|
|
9
9
|
sprites: "2026-11-11",
|
|
10
10
|
freestyle: "2026-11-11",
|
|
11
|
-
maritime: "2026-11-
|
|
11
|
+
maritime: "2026-11-13",
|
|
12
12
|
modal: "2026-11-11",
|
|
13
13
|
novita: "2026-11-11",
|
|
14
|
-
openscale: "2026-11-
|
|
15
|
-
sail: "2026-11-
|
|
14
|
+
openscale: "2026-11-13",
|
|
15
|
+
sail: "2026-11-13",
|
|
16
16
|
superserve: "2026-11-11",
|
|
17
17
|
vercel: "2026-11-11",
|
|
18
18
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sandboxrouter/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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",
|
|
@@ -175,7 +175,7 @@
|
|
|
175
175
|
"@aws-sdk/client-appstream": "^3.1162.0",
|
|
176
176
|
"@aws-sdk/credential-provider-node": "^3.972.93",
|
|
177
177
|
"@blaxel/core": "^0.3.34",
|
|
178
|
-
"@daytonaio/sdk": "^0.
|
|
178
|
+
"@daytonaio/sdk": "^0.230.0",
|
|
179
179
|
"@e2b/desktop": "^2.5.0",
|
|
180
180
|
"@fly/sprites": "^0.2.4",
|
|
181
181
|
"@hopx-ai/sdk": "^0.5.1",
|