@sandboxrouter/sdk 0.4.0 → 0.5.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 +3 -2
- package/dist/cli.js +7 -5
- package/dist/client.d.ts +33 -11
- package/dist/client.js +29 -13
- package/dist/index.d.ts +6 -3
- package/dist/index.js +5 -2
- package/dist/ledger.d.ts +8 -1
- package/dist/router.d.ts +37 -7
- package/dist/router.js +71 -33
- package/dist/server.d.ts +21 -7
- package/dist/server.js +72 -30
- package/dist/types.d.ts +26 -3
- package/dist/types.js +3 -1
- package/dist/util.js +2 -1
- package/dist/validate.d.ts +17 -0
- package/dist/validate.js +66 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -30,14 +30,15 @@ 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
|
-
- **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.
|
|
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. `requireVerified` limits a router to providers that passed the real-account check.
|
|
34
|
+
- **Errors you can act on.** Every failure has a `code` (`no_provider`, `setup`, `all_failed`, `timeout`, ...), an HTTP status, and the list of providers tried.
|
|
34
35
|
- **Your keys.** Your accounts and your bills; nothing is proxied through anyone else.
|
|
35
36
|
- **Cleanup.** A local ledger of every sandbox started, so `sandboxrouter gc` kills what a crashed script left behind.
|
|
36
37
|
- **TypeScript, Python, HTTP and a CLI.** Plus desktops and phones for computer-use models.
|
|
37
38
|
|
|
38
39
|
## Prices
|
|
39
40
|
|
|
40
|
-
List price while running, 2 vCPU / 4 GB, cheapest first; waitlisted sign-ups follow. The router
|
|
41
|
+
List price while running, 2 vCPU / 4 GB, cheapest first; waitlisted sign-ups follow. The router ranks by these, corrected where we measured what a provider really bills, and tries providers whose last real-account check failed last. "Up to" = CPU billed only while busy, shown with every core busy. "Not run yet" = built from the provider's docs and tested against mocks only. Click a provider for its billing details; more in [Reading the price table](https://sandboxrouter.vercel.app/docs#price-table).
|
|
41
42
|
|
|
42
43
|
| Provider | $/hr | Plan needed | Real-account check | Source |
|
|
43
44
|
|---|---|---|---|---|
|
package/dist/cli.js
CHANGED
|
@@ -156,11 +156,13 @@ 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.failing ? "check failed
|
|
160
|
-
// The
|
|
161
|
-
const
|
|
162
|
-
const
|
|
163
|
-
|
|
159
|
+
const tested = c.failing ? "check failed" : c.verified ? "tested" : "";
|
|
160
|
+
// The list price only shows where a measured bill moved the estimate off it.
|
|
161
|
+
const list = c.listHourlyUsd !== c.estimatedHourlyUsd ? ` (list $${c.listHourlyUsd.toFixed(4)})` : "";
|
|
162
|
+
const size = `${c.size.vcpu} vCPU / ${c.size.memoryGb} GB`;
|
|
163
|
+
const speed = c.medianCreateMs !== undefined ? `${c.medianCreateMs} ms` : "";
|
|
164
|
+
const cols = [`$${c.estimatedHourlyUsd.toFixed(4)}/hr${list}`, size, speed, tested].filter(Boolean).join(" ");
|
|
165
|
+
console.log(`${mark} ${c.provider.padEnd(11)} ${cols}${c.skip ? ` (skipped: ${c.skip})` : ""}`);
|
|
164
166
|
}
|
|
165
167
|
return;
|
|
166
168
|
}
|
package/dist/client.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Candidate, Size } from "./router.js";
|
|
2
|
+
import type { Attempt, ExecOptions, ExecResult, RouteOptions, RouterErrorCode, SandboxSpec } from "./types.js";
|
|
2
3
|
/**
|
|
3
4
|
* Client for the sandboxrouter HTTP API: the hosted service (one key, prepaid credits) or your own
|
|
4
5
|
* `sandboxrouter serve`. Provider keys live on the server; this side only needs the one key.
|
|
@@ -20,18 +21,16 @@ export declare class Client {
|
|
|
20
21
|
/** @internal */
|
|
21
22
|
request(method: string, path: string, body?: unknown, accept?: string): Promise<Response>;
|
|
22
23
|
private json;
|
|
23
|
-
/** Providers this server can route to, with
|
|
24
|
+
/** Providers this server can route to, with prices at the default size. */
|
|
24
25
|
providers(): Promise<{
|
|
25
26
|
id: string;
|
|
26
27
|
configured: boolean;
|
|
27
28
|
estimatedHourlyUsd: number;
|
|
29
|
+
listHourlyUsd: number;
|
|
30
|
+
size: Size;
|
|
28
31
|
}[]>;
|
|
29
|
-
/** Dry run: which providers would be tried, in order. */
|
|
30
|
-
route(spec?: SandboxSpec, route?: RouteOptions): Promise<
|
|
31
|
-
provider: string;
|
|
32
|
-
estimatedHourlyUsd: number;
|
|
33
|
-
skip?: string;
|
|
34
|
-
}[]>;
|
|
32
|
+
/** Dry run: which providers would be tried, in order, each with its price, size and any reason it's skipped. */
|
|
33
|
+
route(spec?: SandboxSpec, route?: RouteOptions): Promise<Candidate[]>;
|
|
35
34
|
/** Start a sandbox on the first provider that works, as the server routes it. */
|
|
36
35
|
create(spec?: SandboxSpec, route?: RouteOptions): Promise<RemoteSandbox>;
|
|
37
36
|
/** A handle on a sandbox you created earlier. */
|
|
@@ -40,19 +39,42 @@ export declare class Client {
|
|
|
40
39
|
export declare class ClientError extends Error {
|
|
41
40
|
readonly status: number;
|
|
42
41
|
readonly attempts: Attempt[];
|
|
43
|
-
|
|
42
|
+
/** The server's RouterErrorCode, when the router refused or failed (e.g. "no_provider", "all_failed"). */
|
|
43
|
+
readonly code?: RouterErrorCode | undefined;
|
|
44
|
+
constructor(status: number, message: string, attempts?: Attempt[],
|
|
45
|
+
/** The server's RouterErrorCode, when the router refused or failed (e.g. "no_provider", "all_failed"). */
|
|
46
|
+
code?: RouterErrorCode | undefined);
|
|
47
|
+
}
|
|
48
|
+
/** What POST /v1/sandboxes answers. A hosted service may add fields (e.g. what it charges). */
|
|
49
|
+
export interface CreateResponse {
|
|
50
|
+
id: string;
|
|
51
|
+
provider: string;
|
|
52
|
+
size?: Size;
|
|
53
|
+
estimatedHourlyUsd?: number;
|
|
54
|
+
listHourlyUsd?: number;
|
|
55
|
+
attempts: Attempt[];
|
|
56
|
+
[field: string]: unknown;
|
|
44
57
|
}
|
|
45
58
|
export declare class RemoteSandbox {
|
|
46
59
|
private readonly client;
|
|
47
60
|
readonly id: string;
|
|
48
61
|
readonly provider: string;
|
|
49
|
-
|
|
62
|
+
/** The full create response, including any fields the server added. Empty for `client.sandbox(id)`. */
|
|
63
|
+
readonly details: Partial<CreateResponse>;
|
|
64
|
+
readonly estimatedHourlyUsd?: number;
|
|
65
|
+
readonly listHourlyUsd?: number;
|
|
66
|
+
readonly size?: Size;
|
|
50
67
|
readonly attempts: Attempt[];
|
|
51
|
-
constructor(client: Client, id: string, provider: string,
|
|
68
|
+
constructor(client: Client, id: string, provider: string,
|
|
69
|
+
/** The full create response, including any fields the server added. Empty for `client.sandbox(id)`. */
|
|
70
|
+
details?: Partial<CreateResponse>);
|
|
52
71
|
private path;
|
|
53
72
|
exec(cmd: string, opts?: ExecOptions): Promise<ExecResult>;
|
|
54
73
|
writeFile(path: string, content: string | Uint8Array): Promise<void>;
|
|
55
74
|
readFile(path: string): Promise<Uint8Array>;
|
|
75
|
+
readText(path: string): Promise<string>;
|
|
56
76
|
pause(): Promise<void>;
|
|
57
77
|
kill(): Promise<void>;
|
|
78
|
+
/** `await using sbx = await client.create()` kills it when the block ends. */
|
|
79
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
58
80
|
}
|
package/dist/client.js
CHANGED
|
@@ -35,28 +35,25 @@ export class Client {
|
|
|
35
35
|
parsed = JSON.parse(text);
|
|
36
36
|
}
|
|
37
37
|
catch { }
|
|
38
|
-
throw new ClientError(res.status, parsed.error ?? (text || res.statusText), parsed.attempts ?? []);
|
|
38
|
+
throw new ClientError(res.status, parsed.error ?? (text || res.statusText), parsed.attempts ?? [], parsed.code);
|
|
39
39
|
}
|
|
40
40
|
return res;
|
|
41
41
|
}
|
|
42
42
|
async json(method, path, body) {
|
|
43
43
|
return (await this.request(method, path, body)).json();
|
|
44
44
|
}
|
|
45
|
-
/** Providers this server can route to, with
|
|
45
|
+
/** Providers this server can route to, with prices at the default size. */
|
|
46
46
|
async providers() {
|
|
47
47
|
return (await this.json("GET", "/v1/providers")).providers;
|
|
48
48
|
}
|
|
49
|
-
/** Dry run: which providers would be tried, in order. */
|
|
49
|
+
/** Dry run: which providers would be tried, in order, each with its price, size and any reason it's skipped. */
|
|
50
50
|
async route(spec = {}, route = {}) {
|
|
51
51
|
return (await this.json("POST", "/v1/route", { ...spec, ...route })).candidates;
|
|
52
52
|
}
|
|
53
53
|
/** Start a sandbox on the first provider that works, as the server routes it. */
|
|
54
54
|
async create(spec = {}, route = {}) {
|
|
55
|
-
const r = await this.json("POST", "/v1/sandboxes", {
|
|
56
|
-
|
|
57
|
-
...route,
|
|
58
|
-
});
|
|
59
|
-
return new RemoteSandbox(this, r.id, r.provider, r.estimatedHourlyUsd, r.attempts);
|
|
55
|
+
const r = await this.json("POST", "/v1/sandboxes", { ...spec, ...route });
|
|
56
|
+
return new RemoteSandbox(this, r.id, r.provider, r);
|
|
60
57
|
}
|
|
61
58
|
/** A handle on a sandbox you created earlier. */
|
|
62
59
|
sandbox(id) {
|
|
@@ -67,10 +64,14 @@ const DEFAULT_URL = "http://127.0.0.1:8787";
|
|
|
67
64
|
export class ClientError extends Error {
|
|
68
65
|
status;
|
|
69
66
|
attempts;
|
|
70
|
-
|
|
67
|
+
code;
|
|
68
|
+
constructor(status, message, attempts = [],
|
|
69
|
+
/** The server's RouterErrorCode, when the router refused or failed (e.g. "no_provider", "all_failed"). */
|
|
70
|
+
code) {
|
|
71
71
|
super(`${status}: ${message}`);
|
|
72
72
|
this.status = status;
|
|
73
73
|
this.attempts = attempts;
|
|
74
|
+
this.code = code;
|
|
74
75
|
this.name = "ClientError";
|
|
75
76
|
}
|
|
76
77
|
}
|
|
@@ -78,14 +79,22 @@ export class RemoteSandbox {
|
|
|
78
79
|
client;
|
|
79
80
|
id;
|
|
80
81
|
provider;
|
|
82
|
+
details;
|
|
81
83
|
estimatedHourlyUsd;
|
|
84
|
+
listHourlyUsd;
|
|
85
|
+
size;
|
|
82
86
|
attempts;
|
|
83
|
-
constructor(client, id, provider,
|
|
87
|
+
constructor(client, id, provider,
|
|
88
|
+
/** The full create response, including any fields the server added. Empty for `client.sandbox(id)`. */
|
|
89
|
+
details = {}) {
|
|
84
90
|
this.client = client;
|
|
85
91
|
this.id = id;
|
|
86
92
|
this.provider = provider;
|
|
87
|
-
this.
|
|
88
|
-
this.
|
|
93
|
+
this.details = details;
|
|
94
|
+
this.estimatedHourlyUsd = details.estimatedHourlyUsd;
|
|
95
|
+
this.listHourlyUsd = details.listHourlyUsd;
|
|
96
|
+
this.size = details.size;
|
|
97
|
+
this.attempts = details.attempts ?? [];
|
|
89
98
|
}
|
|
90
99
|
path(suffix = "") {
|
|
91
100
|
return `/v1/sandboxes/${encodeURIComponent(this.id)}${suffix}`;
|
|
@@ -118,7 +127,7 @@ export class RemoteSandbox {
|
|
|
118
127
|
else if (event === "result")
|
|
119
128
|
return payload;
|
|
120
129
|
else if (event === "error")
|
|
121
|
-
throw new ClientError(502, payload.error ?? data);
|
|
130
|
+
throw new ClientError(payload.status ?? 502, payload.error ?? data, [], payload.code);
|
|
122
131
|
}
|
|
123
132
|
}
|
|
124
133
|
throw new ClientError(502, "stream ended before the command finished");
|
|
@@ -131,10 +140,17 @@ export class RemoteSandbox {
|
|
|
131
140
|
const res = await this.client.request("GET", this.path(`/files?path=${encodeURIComponent(path)}`), undefined, "application/octet-stream");
|
|
132
141
|
return new Uint8Array(await res.arrayBuffer());
|
|
133
142
|
}
|
|
143
|
+
async readText(path) {
|
|
144
|
+
return new TextDecoder().decode(await this.readFile(path));
|
|
145
|
+
}
|
|
134
146
|
async pause() {
|
|
135
147
|
await this.client.request("POST", this.path("/pause"));
|
|
136
148
|
}
|
|
137
149
|
async kill() {
|
|
138
150
|
await this.client.request("DELETE", this.path());
|
|
139
151
|
}
|
|
152
|
+
/** `await using sbx = await client.create()` kills it when the block ends. */
|
|
153
|
+
async [Symbol.asyncDispose]() {
|
|
154
|
+
await this.kill();
|
|
155
|
+
}
|
|
140
156
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,10 +2,13 @@ export { checkProvider, CHECK_STEPS, type CheckReport, type CheckResult, type Ch
|
|
|
2
2
|
export { Ledger, defaultLedgerFile, type LedgerEntry } from "./ledger.js";
|
|
3
3
|
export { conformanceTable, loadConformance, type ConformanceRow, type SavedCheck } from "./conformance.js";
|
|
4
4
|
export { gc, listOpen, isGone, type GcResult, type OpenSandbox } from "./gc.js";
|
|
5
|
-
export { Router, RoutedSandbox, defaultProviders, defaultComputerProviders, parseId, type Candidate, type RouterOptions } from "./router.js";
|
|
5
|
+
export { Router, RoutedSandbox, defaultProviders, defaultComputerProviders, parseId, balanced, sizeOf, PRICE_TIE, DEFAULT_CREATE_TIMEOUT_S, DEFAULT_CREATE_DEADLINE_S, type Candidate, type RouterOptions, type SandboxPrice, type Size, } from "./router.js";
|
|
6
|
+
export { VERIFIED, FAILING } from "./verified.js";
|
|
7
|
+
export { BILLED_VS_LIST } from "./billing.js";
|
|
8
|
+
export { validate, splitRequest, SPEC_KEYS, ROUTE_KEYS, STRATEGIES } from "./validate.js";
|
|
6
9
|
export { CATALOG, hourlyCost, unfit, info, type ProviderInfo } from "./catalog.js";
|
|
7
|
-
export { createApp, HttpError, type ServerHooks } from "./server.js";
|
|
8
|
-
export { Client, ClientError, RemoteSandbox } from "./client.js";
|
|
10
|
+
export { createApp, HttpError, ERROR_STATUS, type ServerHooks } from "./server.js";
|
|
11
|
+
export { Client, ClientError, RemoteSandbox, type CreateResponse } from "./client.js";
|
|
9
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";
|
|
10
13
|
export { bench, benchMarkdown, type BenchRow } from "./bench.js";
|
|
11
14
|
export { aggregate, loadReports, makeReport, renderLeaderboard, type BenchReport, type LeaderRow } from "./leaderboard.js";
|
package/dist/index.js
CHANGED
|
@@ -2,9 +2,12 @@ export { checkProvider, CHECK_STEPS } from "./check.js";
|
|
|
2
2
|
export { Ledger, defaultLedgerFile } from "./ledger.js";
|
|
3
3
|
export { conformanceTable, loadConformance } from "./conformance.js";
|
|
4
4
|
export { gc, listOpen, isGone } from "./gc.js";
|
|
5
|
-
export { Router, RoutedSandbox, defaultProviders, defaultComputerProviders, parseId } from "./router.js";
|
|
5
|
+
export { Router, RoutedSandbox, defaultProviders, defaultComputerProviders, parseId, balanced, sizeOf, PRICE_TIE, DEFAULT_CREATE_TIMEOUT_S, DEFAULT_CREATE_DEADLINE_S, } from "./router.js";
|
|
6
|
+
export { VERIFIED, FAILING } from "./verified.js";
|
|
7
|
+
export { BILLED_VS_LIST } from "./billing.js";
|
|
8
|
+
export { validate, splitRequest, SPEC_KEYS, ROUTE_KEYS, STRATEGIES } from "./validate.js";
|
|
6
9
|
export { CATALOG, hourlyCost, unfit, info } from "./catalog.js";
|
|
7
|
-
export { createApp, HttpError } from "./server.js";
|
|
10
|
+
export { createApp, HttpError, ERROR_STATUS } from "./server.js";
|
|
8
11
|
export { Client, ClientError, RemoteSandbox } from "./client.js";
|
|
9
12
|
export { RoutedComputer, createComputer, planComputer, computerCatalog, computerHourly, COMPUTER_ENTRIES, } from "./computers.js";
|
|
10
13
|
export { bench, benchMarkdown } from "./bench.js";
|
package/dist/ledger.d.ts
CHANGED
|
@@ -8,8 +8,15 @@ export interface LedgerEntry {
|
|
|
8
8
|
createdAt: string;
|
|
9
9
|
/** ISO time its requested lifetime (spec.timeoutMinutes) runs out. */
|
|
10
10
|
expiresAt: string;
|
|
11
|
-
/**
|
|
11
|
+
/** Expected cost per hour while it runs (list price x measured billed ratio). */
|
|
12
12
|
usdHour: number;
|
|
13
|
+
/** List price per hour. Missing in entries written before 0.5.0. */
|
|
14
|
+
listUsdHour?: number;
|
|
15
|
+
/** Size it was created at. Missing in entries written before 0.5.0. */
|
|
16
|
+
size?: {
|
|
17
|
+
vcpu: number;
|
|
18
|
+
memoryGb: number;
|
|
19
|
+
};
|
|
13
20
|
}
|
|
14
21
|
/**
|
|
15
22
|
* Where the default ledger lives: $SANDBOXROUTER_HOME/sandboxes.jsonl, else ~/.sandboxrouter/.
|
package/dist/router.d.ts
CHANGED
|
@@ -1,15 +1,28 @@
|
|
|
1
1
|
import { 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
|
+
/** vCPUs and memory a sandbox gets. */
|
|
5
|
+
export interface Size {
|
|
6
|
+
vcpu: number;
|
|
7
|
+
memoryGb: number;
|
|
8
|
+
}
|
|
4
9
|
export interface Candidate {
|
|
5
10
|
provider: ProviderId;
|
|
6
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* What the sandbox is expected to cost, USD/hour: the provider's list price for this size, times what
|
|
13
|
+
* it really billed against list price where we measured that (`billedVsList`). The default strategy
|
|
14
|
+
* ranks by this, and a service reselling sandboxes should charge from it.
|
|
15
|
+
*/
|
|
7
16
|
estimatedHourlyUsd: number;
|
|
17
|
+
/** The provider's published list price for this size, USD/hour. */
|
|
18
|
+
listHourlyUsd: number;
|
|
19
|
+
/** Billed / list-price ratio behind `estimatedHourlyUsd`, when measured (`BILLED_VS_LIST`). */
|
|
20
|
+
billedVsList?: number;
|
|
8
21
|
/**
|
|
9
|
-
*
|
|
10
|
-
* (
|
|
22
|
+
* The size the sandbox gets: the provider's fixed default image size where it has one, else what was
|
|
23
|
+
* asked for (2 vCPU / 4 GB when nothing was). Some providers round up to their next size.
|
|
11
24
|
*/
|
|
12
|
-
|
|
25
|
+
size: Size;
|
|
13
26
|
/** Median create time: this router's own recent creates when it has a few, else your `bench` file. */
|
|
14
27
|
medianCreateMs?: number;
|
|
15
28
|
/** Passed the real-account check (as of this release, or `RouterOptions.verified`). */
|
|
@@ -48,7 +61,7 @@ export interface RouterOptions {
|
|
|
48
61
|
* costs money. Default false.
|
|
49
62
|
*/
|
|
50
63
|
requireVerified?: boolean;
|
|
51
|
-
/** Billed / list-price ratio per provider,
|
|
64
|
+
/** Billed / list-price ratio per provider, behind `estimatedHourlyUsd`. Default: the ratios in `BILLED_VS_LIST`, from saved cost runs. */
|
|
52
65
|
billedVsList?: Partial<Record<ProviderId, number>>;
|
|
53
66
|
}
|
|
54
67
|
/** Desktop-only providers. Filled in as adapters are added. */
|
|
@@ -99,18 +112,31 @@ export declare class Router {
|
|
|
99
112
|
/** Reattach to a sandbox by its routed id (`provider:nativeId`). */
|
|
100
113
|
get(id: string): Promise<RoutedSandbox>;
|
|
101
114
|
}
|
|
115
|
+
/** What a sandbox costs and its size, as the router priced it when it was created. */
|
|
116
|
+
export interface SandboxPrice {
|
|
117
|
+
/** Expected cost, USD/hour: list price x what the provider really billed against it, where measured. */
|
|
118
|
+
estimatedHourlyUsd: number;
|
|
119
|
+
/** The provider's published list price for this size, USD/hour. */
|
|
120
|
+
listHourlyUsd: number;
|
|
121
|
+
size: Size;
|
|
122
|
+
}
|
|
102
123
|
export declare class RoutedSandbox {
|
|
103
124
|
readonly provider: ProviderId;
|
|
104
125
|
private box;
|
|
105
126
|
/** Every create attempt, including failed fallbacks. */
|
|
106
127
|
readonly attempts: Attempt[];
|
|
107
|
-
readonly estimatedHourlyUsd: number;
|
|
108
128
|
private ledger?;
|
|
109
129
|
/** Stable id across processes: `provider:nativeId`. */
|
|
110
130
|
readonly id: string;
|
|
131
|
+
/** Expected cost, USD/hour (see `Candidate.estimatedHourlyUsd`). Undefined for a handle from `get()` this machine didn't create. */
|
|
132
|
+
readonly estimatedHourlyUsd?: number;
|
|
133
|
+
/** The provider's list price for this size, USD/hour. */
|
|
134
|
+
readonly listHourlyUsd?: number;
|
|
135
|
+
/** The size it was created at (see `Candidate.size`). */
|
|
136
|
+
readonly size?: Size;
|
|
111
137
|
constructor(provider: ProviderId, box: ProviderSandbox,
|
|
112
138
|
/** Every create attempt, including failed fallbacks. */
|
|
113
|
-
attempts: Attempt[],
|
|
139
|
+
attempts: Attempt[], price?: SandboxPrice, ledger?: Ledger | undefined);
|
|
114
140
|
get nativeId(): string;
|
|
115
141
|
/**
|
|
116
142
|
* Run a command. If it outlives `opts.timeoutSeconds` the adapter stops it and this rejects with
|
|
@@ -141,5 +167,9 @@ export declare function parseId(id: string): {
|
|
|
141
167
|
export declare function balanced(list: Candidate[]): Candidate[];
|
|
142
168
|
/** Seconds to wait for one provider's create before trying the next (RouteOptions.createTimeoutSeconds). */
|
|
143
169
|
export declare const DEFAULT_CREATE_TIMEOUT_S = 120;
|
|
170
|
+
/** Seconds a whole create may take over every provider it tries (RouteOptions.deadlineSeconds). */
|
|
171
|
+
export declare const DEFAULT_CREATE_DEADLINE_S = 240;
|
|
144
172
|
/** Seconds past a command's timeout before the router stops waiting for the adapter. */
|
|
145
173
|
export declare const EXEC_GRACE_S = 30;
|
|
174
|
+
/** The size a provider gives for a spec: its fixed default image size, else what was asked for. */
|
|
175
|
+
export declare function sizeOf(id: ProviderId, spec?: SandboxSpec): Size;
|
package/dist/router.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { CATALOG, accessLabel, hourlyCost, unfit } from "./catalog.js";
|
|
1
|
+
import { CATALOG, DEFAULT_MEMORY_GB, DEFAULT_VCPU, accessLabel, fixedSize, hourlyCost, unfit } from "./catalog.js";
|
|
2
2
|
import { RoutedComputer, createComputer, planComputer, } from "./computers.js";
|
|
3
3
|
import { loadBench, median } from "./bench-store.js";
|
|
4
4
|
import { Ledger, defaultLedgerFile } from "./ledger.js";
|
|
5
5
|
import { DEFAULT_EXEC_TIMEOUT_S, errorStatus, timeoutError } from "./util.js";
|
|
6
6
|
import { BILLED_VS_LIST } from "./billing.js";
|
|
7
7
|
import { FAILING, VERIFIED } from "./verified.js";
|
|
8
|
+
import { validate } from "./validate.js";
|
|
8
9
|
import { DaytonaProvider } from "./providers/daytona.js";
|
|
9
10
|
import { E2BProvider } from "./providers/e2b.js";
|
|
10
11
|
import { MaritimeProvider } from "./providers/maritime.js";
|
|
@@ -128,11 +129,12 @@ export class Router {
|
|
|
128
129
|
provider(id) {
|
|
129
130
|
const p = this.providers.get(id);
|
|
130
131
|
if (!p)
|
|
131
|
-
throw new RouterError(`unknown provider: ${id}
|
|
132
|
+
throw new RouterError(`unknown provider: ${id}`, [], "bad_request");
|
|
132
133
|
return p;
|
|
133
134
|
}
|
|
134
135
|
/** The ordered list of providers a create would try, with the reason any are skipped. */
|
|
135
136
|
plan(spec = {}, route = {}) {
|
|
137
|
+
validate(spec, route);
|
|
136
138
|
const explicit = route.provider ? [].concat(route.provider) : null;
|
|
137
139
|
// Local providers (Docker) are never picked automatically: they always "cost" $0 and isolate
|
|
138
140
|
// less than a cloud micro-VM. Name them explicitly to use them.
|
|
@@ -142,15 +144,19 @@ export class Router {
|
|
|
142
144
|
const bench = speed ? loadBench(this.benchFile) : {};
|
|
143
145
|
const list = ids.map((id) => {
|
|
144
146
|
// An id the catalog has never heard of (a typo in an explicit list) has no price to look up.
|
|
145
|
-
if (!CATALOG.some((c) => c.id === id))
|
|
146
|
-
return { provider: id, estimatedHourlyUsd: 0,
|
|
147
|
+
if (!CATALOG.some((c) => c.id === id)) {
|
|
148
|
+
return { provider: id, estimatedHourlyUsd: 0, listHourlyUsd: 0, size: { vcpu: 0, memoryGb: 0 }, verified: false, failing: false, skip: "unknown provider" };
|
|
149
|
+
}
|
|
147
150
|
const p = this.providers.get(id);
|
|
148
151
|
const list = round(hourlyCost(id, spec));
|
|
152
|
+
const ratio = this.billed[id];
|
|
149
153
|
const live = this.live.get(id);
|
|
150
154
|
const c = {
|
|
151
155
|
provider: id,
|
|
152
|
-
estimatedHourlyUsd: list,
|
|
153
|
-
|
|
156
|
+
estimatedHourlyUsd: round(list * (ratio ?? 1)),
|
|
157
|
+
listHourlyUsd: list,
|
|
158
|
+
...(ratio !== undefined ? { billedVsList: ratio } : {}),
|
|
159
|
+
size: sizeOf(id, spec),
|
|
154
160
|
medianCreateMs: speed ? (live && live.length >= LIVE_MIN_SAMPLES ? median(live) : median(bench[id]?.createMs)) : undefined,
|
|
155
161
|
verified: this.verified.has(id),
|
|
156
162
|
failing: this.failing.has(id),
|
|
@@ -170,15 +176,16 @@ export class Router {
|
|
|
170
176
|
// depends on who the provider is, only on its price, what it really billed, how fast it started for
|
|
171
177
|
// this router and whether its last real-account check failed. An explicit list keeps the caller's order.
|
|
172
178
|
if (!explicit) {
|
|
173
|
-
const
|
|
179
|
+
const byCost = (a, b) => a.estimatedHourlyUsd - b.estimatedHourlyUsd || a.provider.localeCompare(b.provider);
|
|
180
|
+
const byList = (a, b) => a.listHourlyUsd - b.listHourlyUsd || a.provider.localeCompare(b.provider);
|
|
174
181
|
if (strategy === "fastest") {
|
|
175
|
-
list.sort((a, b) => (a.medianCreateMs ?? Infinity) - (b.medianCreateMs ?? Infinity) ||
|
|
182
|
+
list.sort((a, b) => (a.medianCreateMs ?? Infinity) - (b.medianCreateMs ?? Infinity) || byCost(a, b));
|
|
176
183
|
}
|
|
177
184
|
else if (strategy === "cheapest") {
|
|
178
|
-
list.sort(
|
|
185
|
+
list.sort(byList);
|
|
179
186
|
}
|
|
180
187
|
else if (strategy === "verified") {
|
|
181
|
-
list.sort((a, b) => Number(b.verified) - Number(a.verified) ||
|
|
188
|
+
list.sort((a, b) => Number(b.verified) - Number(a.verified) || byCost(a, b));
|
|
182
189
|
}
|
|
183
190
|
else {
|
|
184
191
|
return balanced(list);
|
|
@@ -219,41 +226,52 @@ export class Router {
|
|
|
219
226
|
const plan = this.plan(spec, route);
|
|
220
227
|
const usable = plan.filter((c) => !c.skip);
|
|
221
228
|
if (usable.length === 0)
|
|
222
|
-
throw new RouterError(this.whyNone(plan, !!route.provider));
|
|
229
|
+
throw new RouterError(this.whyNone(plan, !!route.provider), [], "no_provider");
|
|
230
|
+
// Pinned with no fallback: the first provider named, or nothing. Never a different one.
|
|
231
|
+
if (route.provider && route.fallback === false && plan[0].skip) {
|
|
232
|
+
throw new RouterError(`${plan[0].provider} can't take this sandbox: ${plan[0].skip}`, [], "no_provider");
|
|
233
|
+
}
|
|
223
234
|
const attempts = [];
|
|
224
235
|
const tries = route.fallback === false ? usable.slice(0, 1) : usable;
|
|
225
236
|
const limitS = route.createTimeoutSeconds ?? DEFAULT_CREATE_TIMEOUT_S;
|
|
237
|
+
const deadline = Date.now() + (route.deadlineSeconds ?? DEFAULT_CREATE_DEADLINE_S) * 1000;
|
|
226
238
|
for (const c of tries) {
|
|
239
|
+
const left = deadline - Date.now();
|
|
240
|
+
if (left <= 0) {
|
|
241
|
+
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
|
+
}
|
|
227
243
|
const t0 = Date.now();
|
|
228
244
|
const pending = this.provider(c.provider).create(spec);
|
|
245
|
+
const waitS = Math.min(limitS, left / 1000);
|
|
246
|
+
const price = { estimatedHourlyUsd: c.estimatedHourlyUsd, listHourlyUsd: c.listHourlyUsd, size: c.size };
|
|
229
247
|
let timedOut = false;
|
|
230
248
|
try {
|
|
231
|
-
const box = await withDeadline(pending,
|
|
249
|
+
const box = await withDeadline(pending, waitS * 1000, () => {
|
|
232
250
|
timedOut = true;
|
|
233
|
-
return new ProviderError(c.provider, `create timed out after ${
|
|
251
|
+
return new ProviderError(c.provider, `create timed out after ${Number(waitS.toFixed(1))}s`);
|
|
234
252
|
});
|
|
235
253
|
attempts.push({ provider: c.provider, ok: true, ms: Date.now() - t0 });
|
|
236
254
|
this.observe(c.provider, Date.now() - t0);
|
|
237
|
-
const sbx = new RoutedSandbox(c.provider, box, attempts,
|
|
238
|
-
this.record(sbx,
|
|
255
|
+
const sbx = new RoutedSandbox(c.provider, box, attempts, price, this.ledger);
|
|
256
|
+
this.record(sbx, price, spec.timeoutMinutes);
|
|
239
257
|
return sbx;
|
|
240
258
|
}
|
|
241
259
|
catch (err) {
|
|
242
260
|
// A create that answers after we gave up still made a sandbox: record it, then kill it,
|
|
243
261
|
// so it can't bill unseen. If the kill fails, the ledger entry is left for `gc`.
|
|
244
262
|
if (timedOut)
|
|
245
|
-
pending.then((late) => this.discard(c.provider, late,
|
|
263
|
+
pending.then((late) => this.discard(c.provider, late, price, spec.timeoutMinutes), () => { });
|
|
246
264
|
const status = errorStatus(err);
|
|
247
265
|
attempts.push({ provider: c.provider, ok: false, ms: Date.now() - t0, error: err.message, ...(status ? { status } : {}) });
|
|
248
266
|
const setup = err instanceof SetupError || (status !== undefined && SETUP_ERRORS.has(status));
|
|
249
267
|
if (route.fallback !== "any" && setup) {
|
|
250
268
|
const msg = err.message.replace(/[.\s]+$/, "");
|
|
251
269
|
throw new RouterError(`${msg.startsWith(c.provider) ? msg : `${c.provider}: ${msg}`}. This looks like a setup problem with ${c.provider} (key, image, request or a missing package), ` +
|
|
252
|
-
`so the router stopped instead of moving the work to another provider; pass fallback: "any" to fall back on every error`, attempts);
|
|
270
|
+
`so the router stopped instead of moving the work to another provider; pass fallback: "any" to fall back on every error`, attempts, "setup");
|
|
253
271
|
}
|
|
254
272
|
}
|
|
255
273
|
}
|
|
256
|
-
throw new RouterError(`all providers failed: ${attempts.map((a) => `${a.provider}: ${a.error}`).join(" | ")}`, attempts);
|
|
274
|
+
throw new RouterError(`all providers failed: ${attempts.map((a) => `${a.provider}: ${a.error}`).join(" | ")}`, attempts, "all_failed");
|
|
257
275
|
}
|
|
258
276
|
/** Remember a successful create's time, for the speed tie-break and strategy "fastest". */
|
|
259
277
|
observe(id, ms) {
|
|
@@ -263,19 +281,21 @@ export class Router {
|
|
|
263
281
|
xs.shift();
|
|
264
282
|
this.live.set(id, xs);
|
|
265
283
|
}
|
|
266
|
-
record(sbx,
|
|
284
|
+
record(sbx, price, timeoutMinutes) {
|
|
267
285
|
const now = Date.now();
|
|
268
286
|
this.ledger?.open({
|
|
269
287
|
id: sbx.id,
|
|
270
288
|
provider: sbx.provider,
|
|
271
289
|
createdAt: new Date(now).toISOString(),
|
|
272
290
|
expiresAt: new Date(now + (timeoutMinutes ?? 30) * 60_000).toISOString(),
|
|
273
|
-
usdHour,
|
|
291
|
+
usdHour: price.estimatedHourlyUsd,
|
|
292
|
+
listUsdHour: price.listHourlyUsd,
|
|
293
|
+
size: price.size,
|
|
274
294
|
});
|
|
275
295
|
}
|
|
276
|
-
discard(provider, box,
|
|
277
|
-
const sbx = new RoutedSandbox(provider, box, [],
|
|
278
|
-
this.record(sbx,
|
|
296
|
+
discard(provider, box, price, timeoutMinutes) {
|
|
297
|
+
const sbx = new RoutedSandbox(provider, box, [], price, this.ledger);
|
|
298
|
+
this.record(sbx, price, timeoutMinutes);
|
|
279
299
|
return sbx.kill().catch(() => { });
|
|
280
300
|
}
|
|
281
301
|
/** Every provider that sells computers (desktops/phones): sandbox providers that do, plus desktop-only ones. */
|
|
@@ -307,26 +327,37 @@ export class Router {
|
|
|
307
327
|
async get(id) {
|
|
308
328
|
const { provider, nativeId } = parseId(id);
|
|
309
329
|
const box = await this.provider(provider).connect(nativeId);
|
|
310
|
-
|
|
330
|
+
// The price and size come from this machine's ledger when it started the sandbox; otherwise
|
|
331
|
+
// they're unknown (a guess at the default size would be wrong for anything bigger).
|
|
332
|
+
const e = this.ledger?.list().find((x) => x.id === id);
|
|
333
|
+
const price = e && e.listUsdHour !== undefined && e.size ? { estimatedHourlyUsd: e.usdHour, listHourlyUsd: e.listUsdHour, size: e.size } : undefined;
|
|
334
|
+
return new RoutedSandbox(provider, box, [], price, this.ledger);
|
|
311
335
|
}
|
|
312
336
|
}
|
|
313
337
|
export class RoutedSandbox {
|
|
314
338
|
provider;
|
|
315
339
|
box;
|
|
316
340
|
attempts;
|
|
317
|
-
estimatedHourlyUsd;
|
|
318
341
|
ledger;
|
|
319
342
|
/** Stable id across processes: `provider:nativeId`. */
|
|
320
343
|
id;
|
|
344
|
+
/** Expected cost, USD/hour (see `Candidate.estimatedHourlyUsd`). Undefined for a handle from `get()` this machine didn't create. */
|
|
345
|
+
estimatedHourlyUsd;
|
|
346
|
+
/** The provider's list price for this size, USD/hour. */
|
|
347
|
+
listHourlyUsd;
|
|
348
|
+
/** The size it was created at (see `Candidate.size`). */
|
|
349
|
+
size;
|
|
321
350
|
constructor(provider, box,
|
|
322
351
|
/** Every create attempt, including failed fallbacks. */
|
|
323
|
-
attempts,
|
|
352
|
+
attempts, price, ledger) {
|
|
324
353
|
this.provider = provider;
|
|
325
354
|
this.box = box;
|
|
326
355
|
this.attempts = attempts;
|
|
327
|
-
this.estimatedHourlyUsd = estimatedHourlyUsd;
|
|
328
356
|
this.ledger = ledger;
|
|
329
357
|
this.id = `${provider}:${box.nativeId}`;
|
|
358
|
+
this.estimatedHourlyUsd = price?.estimatedHourlyUsd;
|
|
359
|
+
this.listHourlyUsd = price?.listHourlyUsd;
|
|
360
|
+
this.size = price?.size;
|
|
330
361
|
}
|
|
331
362
|
get nativeId() {
|
|
332
363
|
return this.box.nativeId;
|
|
@@ -357,7 +388,7 @@ export class RoutedSandbox {
|
|
|
357
388
|
/** Pause, keeping the disk. Resume with router.get(id) or, on Maritime, any call. */
|
|
358
389
|
async pause() {
|
|
359
390
|
if (!this.box.pause)
|
|
360
|
-
throw new RouterError(`${this.provider} does not support pause; create with require: ["pause"]
|
|
391
|
+
throw new RouterError(`${this.provider} does not support pause; create with require: ["pause"]`, [], "unsupported");
|
|
361
392
|
await this.box.pause();
|
|
362
393
|
}
|
|
363
394
|
get canPause() {
|
|
@@ -370,10 +401,10 @@ export class RoutedSandbox {
|
|
|
370
401
|
export function parseId(id) {
|
|
371
402
|
const i = id.indexOf(":");
|
|
372
403
|
if (i <= 0)
|
|
373
|
-
throw new RouterError(`bad sandbox id "${id}": expected provider:id
|
|
404
|
+
throw new RouterError(`bad sandbox id "${id}": expected provider:id`, [], "bad_request");
|
|
374
405
|
const provider = id.slice(0, i);
|
|
375
406
|
if (!CATALOG.some((c) => c.id === provider))
|
|
376
|
-
throw new RouterError(`unknown provider in id: ${provider}
|
|
407
|
+
throw new RouterError(`unknown provider in id: ${provider}`, [], "bad_request");
|
|
377
408
|
return { provider, nativeId: id.slice(i + 1) };
|
|
378
409
|
}
|
|
379
410
|
function round(n) {
|
|
@@ -386,14 +417,15 @@ function round(n) {
|
|
|
386
417
|
* median create time; unknown speed after known), then cheapest, then alphabetical.
|
|
387
418
|
*/
|
|
388
419
|
export function balanced(list) {
|
|
389
|
-
const byCost = (a, b) => a.
|
|
420
|
+
const byCost = (a, b) => a.estimatedHourlyUsd - b.estimatedHourlyUsd || a.provider.localeCompare(b.provider);
|
|
390
421
|
const bySpeed = (a, b) => (a.medianCreateMs ?? Infinity) - (b.medianCreateMs ?? Infinity) || byCost(a, b);
|
|
391
422
|
const order = (group) => {
|
|
392
423
|
const rest = [...group].sort(byCost);
|
|
393
424
|
const out = [];
|
|
394
425
|
while (rest.length) {
|
|
395
|
-
const limit = rest[0].
|
|
396
|
-
|
|
426
|
+
const limit = rest[0].estimatedHourlyUsd * (1 + PRICE_TIE);
|
|
427
|
+
// Always take at least the cheapest, so the loop ends whatever the prices are.
|
|
428
|
+
const tie = rest.filter((c, i) => i === 0 || c.estimatedHourlyUsd <= limit);
|
|
397
429
|
out.push(...tie.sort(bySpeed));
|
|
398
430
|
rest.splice(0, tie.length);
|
|
399
431
|
}
|
|
@@ -403,6 +435,8 @@ export function balanced(list) {
|
|
|
403
435
|
}
|
|
404
436
|
/** Seconds to wait for one provider's create before trying the next (RouteOptions.createTimeoutSeconds). */
|
|
405
437
|
export const DEFAULT_CREATE_TIMEOUT_S = 120;
|
|
438
|
+
/** Seconds a whole create may take over every provider it tries (RouteOptions.deadlineSeconds). */
|
|
439
|
+
export const DEFAULT_CREATE_DEADLINE_S = 240;
|
|
406
440
|
/**
|
|
407
441
|
* HTTP statuses that mean the request or the key is wrong, not that the provider is down. 402 (out of
|
|
408
442
|
* balance, plan needed) isn't one: an unfunded account is exactly when the next provider should take the
|
|
@@ -426,3 +460,7 @@ async function withDeadline(work, ms, onExpire) {
|
|
|
426
460
|
work.catch(() => { });
|
|
427
461
|
}
|
|
428
462
|
}
|
|
463
|
+
/** The size a provider gives for a spec: its fixed default image size, else what was asked for. */
|
|
464
|
+
export function sizeOf(id, spec = {}) {
|
|
465
|
+
return fixedSize(id, spec) ?? { vcpu: spec.vcpu ?? DEFAULT_VCPU, memoryGb: spec.memoryGb ?? DEFAULT_MEMORY_GB };
|
|
466
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
2
|
import { Router, type RoutedSandbox } from "./router.js";
|
|
3
|
-
import { type RouteOptions, type SandboxSpec } from "./types.js";
|
|
3
|
+
import { type RouteOptions, type RouterErrorCode, type SandboxSpec } from "./types.js";
|
|
4
4
|
/**
|
|
5
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.
|
|
@@ -10,15 +10,27 @@ export interface ServerHooks {
|
|
|
10
10
|
authorize?(req: IncomingMessage): Promise<unknown | null>;
|
|
11
11
|
/** Routes outside /v1 (sign-in, billing webhooks, pages), checked before auth. Return true if handled. */
|
|
12
12
|
extra?(req: IncomingMessage, res: ServerResponse, url: URL): Promise<boolean>;
|
|
13
|
-
/** Before a sandbox starts. Throw HttpError to refuse (402 out of credit, 429 too many); may edit spec. */
|
|
13
|
+
/** Before a sandbox starts. Throw HttpError to refuse (402 out of credit, 429 too many); may edit spec and route. */
|
|
14
14
|
beforeCreate?(caller: unknown, spec: SandboxSpec, route: RouteOptions): Promise<void>;
|
|
15
|
-
/**
|
|
16
|
-
|
|
15
|
+
/**
|
|
16
|
+
* After it started. Return fields to add to the create response (e.g. what the caller is charged). If
|
|
17
|
+
* this throws, the sandbox is killed and the error returned. Not called when the caller hung up before
|
|
18
|
+
* the sandbox was ready: that sandbox is killed instead.
|
|
19
|
+
*/
|
|
20
|
+
afterCreate?(caller: unknown, sbx: RoutedSandbox, spec: SandboxSpec): Promise<Record<string, unknown> | void>;
|
|
21
|
+
/** A create that passed beforeCreate ended without a sandbox for the caller. Release whatever beforeCreate held. */
|
|
22
|
+
createFailed?(caller: unknown): Promise<void> | void;
|
|
17
23
|
/** May this caller use this sandbox? No means 404. */
|
|
18
24
|
owns?(caller: unknown, id: string): Promise<boolean>;
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
25
|
+
/**
|
|
26
|
+
* After DELETE. `error` is set when the provider's kill failed (the sandbox may still be running): the
|
|
27
|
+
* server answers with the error, but the caller asked for it gone, so stop charging them for it. A kill
|
|
28
|
+
* of a sandbox the provider no longer has counts as done.
|
|
29
|
+
*/
|
|
30
|
+
afterKill?(caller: unknown, id: string, outcome?: {
|
|
31
|
+
error?: Error;
|
|
32
|
+
}): Promise<void>;
|
|
33
|
+
/** /v1/computers. Default: on for a single-user server, off when `authorize` is set (computers don't go through these hooks). */
|
|
22
34
|
computers?: boolean;
|
|
23
35
|
/** false turns off pause. */
|
|
24
36
|
pause?: boolean;
|
|
@@ -56,3 +68,5 @@ export declare class HttpError extends Error {
|
|
|
56
68
|
readonly status: number;
|
|
57
69
|
constructor(status: number, message: string);
|
|
58
70
|
}
|
|
71
|
+
/** The HTTP status for each RouterError code (see RouterErrorCode). */
|
|
72
|
+
export declare const ERROR_STATUS: Record<RouterErrorCode, number>;
|
package/dist/server.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
|
-
import { CATALOG
|
|
3
|
+
import { CATALOG } from "./catalog.js";
|
|
4
4
|
import { Router } from "./router.js";
|
|
5
5
|
import { ProviderError, RouterError } from "./types.js";
|
|
6
|
+
import { errorStatus } from "./util.js";
|
|
7
|
+
import { splitRequest } from "./validate.js";
|
|
6
8
|
/**
|
|
7
9
|
* OpenRouter-style HTTP API in front of every provider. One token for your clients; provider keys
|
|
8
10
|
* stay on this host.
|
|
@@ -46,6 +48,7 @@ corsOrigins = (process.env.SANDBOXROUTER_CORS ?? "").split(",").map((s) => s.tri
|
|
|
46
48
|
return got.length === expected.length && timingSafeEqual(got, expected);
|
|
47
49
|
};
|
|
48
50
|
const live = new Map();
|
|
51
|
+
const computersOn = hooks.computers ?? !hooks.authorize;
|
|
49
52
|
const cors = (req, res) => {
|
|
50
53
|
const origin = req.headers.origin;
|
|
51
54
|
if (!origin || !(corsOrigins.includes("*") || corsOrigins.includes(origin)))
|
|
@@ -91,20 +94,19 @@ corsOrigins = (process.env.SANDBOXROUTER_CORS ?? "").split(",").map((s) => s.tri
|
|
|
91
94
|
// GET /v1/providers
|
|
92
95
|
if (req.method === "GET" && parts[1] === "providers" && parts.length === 2) {
|
|
93
96
|
return json(res, 200, {
|
|
94
|
-
providers: CATALOG.filter((c) => router.registered().includes(c.id)).map((c) =>
|
|
95
|
-
|
|
96
|
-
configured: router.isConfigured(c.id),
|
|
97
|
-
|
|
98
|
-
})),
|
|
97
|
+
providers: CATALOG.filter((c) => router.registered().includes(c.id)).map((c) => {
|
|
98
|
+
const p = router.plan({}, { provider: c.id })[0];
|
|
99
|
+
return { ...c, configured: router.isConfigured(c.id), estimatedHourlyUsd: p.estimatedHourlyUsd, listHourlyUsd: p.listHourlyUsd, size: p.size };
|
|
100
|
+
}),
|
|
99
101
|
});
|
|
100
102
|
}
|
|
101
103
|
// POST /v1/route
|
|
102
104
|
if (req.method === "POST" && parts[1] === "route" && parts.length === 2) {
|
|
103
|
-
const { spec, route } =
|
|
105
|
+
const { spec, route } = splitRequest(await readJson(req));
|
|
104
106
|
return json(res, 200, { candidates: router.plan(spec, route) });
|
|
105
107
|
}
|
|
106
108
|
if (parts[1] === "computers") {
|
|
107
|
-
if (
|
|
109
|
+
if (!computersOn)
|
|
108
110
|
return json(res, 404, { error: "computers aren't available on this server" });
|
|
109
111
|
return computers(req, res, url, parts);
|
|
110
112
|
}
|
|
@@ -112,22 +114,40 @@ corsOrigins = (process.env.SANDBOXROUTER_CORS ?? "").split(",").map((s) => s.tri
|
|
|
112
114
|
return json(res, 404, { error: "not found" });
|
|
113
115
|
// POST /v1/sandboxes
|
|
114
116
|
if (req.method === "POST" && parts.length === 2) {
|
|
115
|
-
const { spec, route } =
|
|
117
|
+
const { spec, route } = splitRequest(await readJson(req));
|
|
116
118
|
await hooks.beforeCreate?.(caller, spec, route);
|
|
117
|
-
|
|
119
|
+
// 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.
|
|
121
|
+
let hungUp = false;
|
|
122
|
+
res.on("close", () => (hungUp = !res.writableFinished));
|
|
123
|
+
let sbx;
|
|
124
|
+
let extra;
|
|
118
125
|
try {
|
|
119
|
-
await
|
|
126
|
+
sbx = await router.create(spec, route);
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
await hooks.createFailed?.(caller);
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
if (hungUp)
|
|
134
|
+
throw new HttpError(499, "the client closed the connection before the sandbox was ready; it was killed");
|
|
135
|
+
extra = await hooks.afterCreate?.(caller, sbx, spec);
|
|
120
136
|
}
|
|
121
137
|
catch (err) {
|
|
122
138
|
await sbx.kill().catch(() => { });
|
|
139
|
+
await hooks.createFailed?.(caller);
|
|
123
140
|
throw err;
|
|
124
141
|
}
|
|
125
142
|
live.set(sbx.id, sbx);
|
|
126
143
|
return json(res, 201, {
|
|
127
144
|
id: sbx.id,
|
|
128
145
|
provider: sbx.provider,
|
|
146
|
+
size: sbx.size,
|
|
129
147
|
estimatedHourlyUsd: sbx.estimatedHourlyUsd,
|
|
148
|
+
listHourlyUsd: sbx.listHourlyUsd,
|
|
130
149
|
attempts: sbx.attempts,
|
|
150
|
+
...(extra ?? {}),
|
|
131
151
|
});
|
|
132
152
|
}
|
|
133
153
|
const id = parts[2] ? decodeURIComponent(parts[2]) : "";
|
|
@@ -149,7 +169,8 @@ corsOrigins = (process.env.SANDBOXROUTER_CORS ?? "").split(",").map((s) => s.tri
|
|
|
149
169
|
emit("result", r);
|
|
150
170
|
}
|
|
151
171
|
catch (err) {
|
|
152
|
-
|
|
172
|
+
const e = describeError(err);
|
|
173
|
+
emit("error", { ...e.body, status: e.status });
|
|
153
174
|
}
|
|
154
175
|
return res.end();
|
|
155
176
|
}
|
|
@@ -179,7 +200,20 @@ corsOrigins = (process.env.SANDBOXROUTER_CORS ?? "").split(",").map((s) => s.tri
|
|
|
179
200
|
}
|
|
180
201
|
// DELETE /v1/sandboxes/:id
|
|
181
202
|
if (req.method === "DELETE" && parts.length === 3) {
|
|
182
|
-
|
|
203
|
+
try {
|
|
204
|
+
await (await box(id)).kill();
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
// Already gone at the provider: that's what the caller wanted.
|
|
208
|
+
if (!(err instanceof HttpError) && errorStatus(err) === 404) {
|
|
209
|
+
live.delete(id);
|
|
210
|
+
await hooks.afterKill?.(caller, id);
|
|
211
|
+
return json(res, 200, { id, killed: true });
|
|
212
|
+
}
|
|
213
|
+
if (!(err instanceof HttpError))
|
|
214
|
+
await hooks.afterKill?.(caller, id, { error: err });
|
|
215
|
+
throw err;
|
|
216
|
+
}
|
|
183
217
|
live.delete(id);
|
|
184
218
|
await hooks.afterKill?.(caller, id);
|
|
185
219
|
return json(res, 200, { id, killed: true });
|
|
@@ -245,11 +279,11 @@ corsOrigins = (process.env.SANDBOXROUTER_CORS ?? "").split(",").map((s) => s.tri
|
|
|
245
279
|
};
|
|
246
280
|
return createServer((req, res) => {
|
|
247
281
|
handle(req, res).catch((err) => {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
json(res, status,
|
|
282
|
+
// A closed connection can't be answered.
|
|
283
|
+
if (res.writableEnded || res.destroyed)
|
|
284
|
+
return;
|
|
285
|
+
const e = describeError(err);
|
|
286
|
+
json(res, e.status, e.body);
|
|
253
287
|
});
|
|
254
288
|
});
|
|
255
289
|
}
|
|
@@ -260,19 +294,27 @@ export class HttpError extends Error {
|
|
|
260
294
|
this.status = status;
|
|
261
295
|
}
|
|
262
296
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
return { spec: spec, route: route };
|
|
297
|
+
/**
|
|
298
|
+
* The HTTP status and JSON body for an error: `{ error, code?, attempts? }`. RouterErrors carry a code
|
|
299
|
+
* (see RouterErrorCode) that maps to one status; a ProviderError with an HTTP status passes it on.
|
|
300
|
+
*/
|
|
301
|
+
function describeError(err) {
|
|
302
|
+
const status = err instanceof HttpError ? err.status
|
|
303
|
+
: err instanceof RouterError ? ERROR_STATUS[err.code]
|
|
304
|
+
: err instanceof ProviderError && err.status ? err.status
|
|
305
|
+
: 500;
|
|
306
|
+
const code = err instanceof RouterError ? err.code : undefined;
|
|
307
|
+
return { status, body: { error: err?.message ?? String(err), ...(code ? { code } : {}), ...(err?.attempts?.length ? { attempts: err.attempts } : {}) } };
|
|
275
308
|
}
|
|
309
|
+
/** The HTTP status for each RouterError code (see RouterErrorCode). */
|
|
310
|
+
export const ERROR_STATUS = {
|
|
311
|
+
bad_request: 400,
|
|
312
|
+
unsupported: 400,
|
|
313
|
+
no_provider: 422,
|
|
314
|
+
setup: 502,
|
|
315
|
+
all_failed: 503,
|
|
316
|
+
timeout: 504,
|
|
317
|
+
};
|
|
276
318
|
const MAX_BODY = 100 * 1024 * 1024;
|
|
277
319
|
async function readRaw(req) {
|
|
278
320
|
const chunks = [];
|
package/dist/types.d.ts
CHANGED
|
@@ -3,9 +3,14 @@ export type ProviderId = "maritime" | "e2b" | "daytona" | "modal" | "vercel" | "
|
|
|
3
3
|
export type Capability = "gpu" | "pause" | "custom-image";
|
|
4
4
|
/** What the caller wants. Every field is optional; providers map it to their own knobs. */
|
|
5
5
|
export interface SandboxSpec {
|
|
6
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* vCPUs. Leave vcpu and memoryGb out to take any provider at the size it starts by default: most give
|
|
8
|
+
* 2 vCPU / 4 GB, but some only run a fixed, smaller image (E2B 2 vCPU / 0.5 GB, Daytona 1 / 1). Set
|
|
9
|
+
* either one and only providers that can give that size are tried. `Candidate.size` and
|
|
10
|
+
* `RoutedSandbox.size` say what you get.
|
|
11
|
+
*/
|
|
7
12
|
vcpu?: number;
|
|
8
|
-
/** Memory in GB
|
|
13
|
+
/** Memory in GB. See `vcpu`. */
|
|
9
14
|
memoryGb?: number;
|
|
10
15
|
/** GPU type, e.g. "T4", "A100". Only providers with the "gpu" capability accept it. */
|
|
11
16
|
gpu?: string;
|
|
@@ -37,6 +42,12 @@ export interface RouteOptions {
|
|
|
37
42
|
provider?: ProviderId | ProviderId[];
|
|
38
43
|
/** How to order candidates when no explicit list is given. Default: "balanced" (cheapest real cost, speed breaks near-ties). */
|
|
39
44
|
strategy?: Strategy;
|
|
45
|
+
/**
|
|
46
|
+
* Seconds the whole create may take, over every provider it tries (default 240, under the 300 s an
|
|
47
|
+
* HTTP client usually waits). When it runs out, create stops trying and throws a RouterError with
|
|
48
|
+
* code "timeout"; a provider still starting is killed when it answers.
|
|
49
|
+
*/
|
|
50
|
+
deadlineSeconds?: number;
|
|
40
51
|
/**
|
|
41
52
|
* What to do when a create fails. `true` (default): try the next candidate when the provider
|
|
42
53
|
* looks unavailable (timeout, network error, 429, 5xx, or an error without a status) or can't bill
|
|
@@ -92,9 +103,21 @@ export interface Attempt {
|
|
|
92
103
|
/** HTTP status of the failure, when the provider gave one (402: the account can't pay). */
|
|
93
104
|
status?: number;
|
|
94
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* Why the router couldn't do what was asked. Each maps to one HTTP status in the server.
|
|
108
|
+
* - bad_request (400): an invalid spec, route option or sandbox id
|
|
109
|
+
* - unsupported (400): the sandbox's provider can't do this (e.g. pause)
|
|
110
|
+
* - no_provider (422): no provider you have keys for can take this sandbox (`plan()` says why for each)
|
|
111
|
+
* - setup (502): a provider refused the request or the key (400/401/403/404/422 from it); fix the key,
|
|
112
|
+
* image or request rather than retrying
|
|
113
|
+
* - all_failed (503): every provider tried was down, busy or out of credit; retrying later can work
|
|
114
|
+
* - timeout (504): the create ran out of `deadlineSeconds`
|
|
115
|
+
*/
|
|
116
|
+
export type RouterErrorCode = "bad_request" | "unsupported" | "no_provider" | "setup" | "all_failed" | "timeout";
|
|
95
117
|
export declare class RouterError extends Error {
|
|
96
118
|
readonly attempts: Attempt[];
|
|
97
|
-
|
|
119
|
+
readonly code: RouterErrorCode;
|
|
120
|
+
constructor(message: string, attempts?: Attempt[], code?: RouterErrorCode);
|
|
98
121
|
}
|
|
99
122
|
/** Thrown by adapters for HTTP failures so the router can report status codes. */
|
|
100
123
|
export declare class ProviderError extends Error {
|
package/dist/types.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
export class RouterError extends Error {
|
|
2
2
|
attempts;
|
|
3
|
-
|
|
3
|
+
code;
|
|
4
|
+
constructor(message, attempts = [], code = "all_failed") {
|
|
4
5
|
super(message);
|
|
5
6
|
this.attempts = attempts;
|
|
7
|
+
this.code = code;
|
|
6
8
|
this.name = "RouterError";
|
|
7
9
|
}
|
|
8
10
|
}
|
package/dist/util.js
CHANGED
|
@@ -28,7 +28,8 @@ export function wrapCommand(cmd, opts = {}) {
|
|
|
28
28
|
}
|
|
29
29
|
/** The one error every adapter throws when a command outlives ExecOptions.timeoutSeconds. */
|
|
30
30
|
export function timeoutError(provider, seconds) {
|
|
31
|
-
|
|
31
|
+
// 504 for the HTTP API: the command ran out of time, not the server.
|
|
32
|
+
return new ProviderError(provider, `command timed out after ${seconds}s`, 504);
|
|
32
33
|
}
|
|
33
34
|
/**
|
|
34
35
|
* True when an exit code is the one left behind by a timeout kill and the command really ran
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type RouteOptions, type SandboxSpec, type Strategy } from "./types.js";
|
|
2
|
+
/** Every key a SandboxSpec can have. Anything else in a request is a mistake (e.g. "memory" for "memoryGb"). */
|
|
3
|
+
export declare const SPEC_KEYS: readonly ["vcpu", "memoryGb", "gpu", "env", "timeoutMinutes", "image", "metadata"];
|
|
4
|
+
/** Every key RouteOptions can have. */
|
|
5
|
+
export declare const ROUTE_KEYS: readonly ["provider", "strategy", "fallback", "require", "createTimeoutSeconds", "deadlineSeconds"];
|
|
6
|
+
export declare const STRATEGIES: readonly Strategy[];
|
|
7
|
+
/**
|
|
8
|
+
* Throws RouterError("bad_request") for a spec or route a provider couldn't make sense of: sizes and
|
|
9
|
+
* times that aren't positive numbers, an unknown strategy or fallback, maps that aren't string maps.
|
|
10
|
+
* Called by `plan()`, so every create, route and CLI command goes through it.
|
|
11
|
+
*/
|
|
12
|
+
export declare function validate(spec?: SandboxSpec, route?: RouteOptions): void;
|
|
13
|
+
/** Split a flat request body into spec and route options, refusing keys that are neither. */
|
|
14
|
+
export declare function splitRequest(body: Record<string, unknown>): {
|
|
15
|
+
spec: SandboxSpec;
|
|
16
|
+
route: RouteOptions;
|
|
17
|
+
};
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { CATALOG } from "./catalog.js";
|
|
2
|
+
import { RouterError } from "./types.js";
|
|
3
|
+
/** Every key a SandboxSpec can have. Anything else in a request is a mistake (e.g. "memory" for "memoryGb"). */
|
|
4
|
+
export const SPEC_KEYS = ["vcpu", "memoryGb", "gpu", "env", "timeoutMinutes", "image", "metadata"];
|
|
5
|
+
/** Every key RouteOptions can have. */
|
|
6
|
+
export const ROUTE_KEYS = ["provider", "strategy", "fallback", "require", "createTimeoutSeconds", "deadlineSeconds"];
|
|
7
|
+
export const STRATEGIES = ["default", "balanced", "verified", "cheapest", "fastest"];
|
|
8
|
+
const bad = (msg) => new RouterError(msg, [], "bad_request");
|
|
9
|
+
const positive = (v) => typeof v === "number" && Number.isFinite(v) && v > 0;
|
|
10
|
+
/** How a bad value is shown: numbers as-is (NaN, Infinity), anything else as JSON. */
|
|
11
|
+
const show = (v) => (typeof v === "number" ? String(v) : JSON.stringify(v));
|
|
12
|
+
const stringMap = (v) => typeof v === "object" && v !== null && !Array.isArray(v) && Object.values(v).every((x) => typeof x === "string");
|
|
13
|
+
/**
|
|
14
|
+
* Throws RouterError("bad_request") for a spec or route a provider couldn't make sense of: sizes and
|
|
15
|
+
* times that aren't positive numbers, an unknown strategy or fallback, maps that aren't string maps.
|
|
16
|
+
* Called by `plan()`, so every create, route and CLI command goes through it.
|
|
17
|
+
*/
|
|
18
|
+
export function validate(spec = {}, route = {}) {
|
|
19
|
+
for (const k of ["vcpu", "memoryGb", "timeoutMinutes"]) {
|
|
20
|
+
if (spec[k] !== undefined && !positive(spec[k]))
|
|
21
|
+
throw bad(`${k} must be a positive number, got ${show(spec[k])}`);
|
|
22
|
+
}
|
|
23
|
+
if (spec.gpu !== undefined && (typeof spec.gpu !== "string" || !spec.gpu))
|
|
24
|
+
throw bad(`gpu must be a GPU type such as "T4", got ${JSON.stringify(spec.gpu)}`);
|
|
25
|
+
for (const k of ["env", "image", "metadata"]) {
|
|
26
|
+
if (spec[k] !== undefined && !stringMap(spec[k]))
|
|
27
|
+
throw bad(`${k} must be an object of strings`);
|
|
28
|
+
}
|
|
29
|
+
if (route.strategy !== undefined && !STRATEGIES.includes(route.strategy)) {
|
|
30
|
+
throw bad(`unknown strategy ${JSON.stringify(route.strategy)}: use one of ${STRATEGIES.filter((s) => s !== "default").join(", ")}`);
|
|
31
|
+
}
|
|
32
|
+
if (route.fallback !== undefined && route.fallback !== true && route.fallback !== false && route.fallback !== "any") {
|
|
33
|
+
throw bad(`fallback must be true, false or "any", got ${JSON.stringify(route.fallback)}`);
|
|
34
|
+
}
|
|
35
|
+
for (const k of ["createTimeoutSeconds", "deadlineSeconds"]) {
|
|
36
|
+
if (route[k] !== undefined && !positive(route[k]))
|
|
37
|
+
throw bad(`${k} must be a positive number, got ${show(route[k])}`);
|
|
38
|
+
}
|
|
39
|
+
if (route.require !== undefined && (!Array.isArray(route.require) || !route.require.every((r) => typeof r === "string"))) {
|
|
40
|
+
throw bad("require must be a list of capabilities, e.g. [\"pause\"]");
|
|
41
|
+
}
|
|
42
|
+
if (route.provider !== undefined) {
|
|
43
|
+
const ids = [].concat(route.provider);
|
|
44
|
+
const unknown = ids.filter((id) => typeof id !== "string" || !CATALOG.some((c) => c.id === id));
|
|
45
|
+
if (unknown.length || ids.length === 0)
|
|
46
|
+
throw bad(`unknown provider: ${unknown.map((u) => JSON.stringify(u)).join(", ") || "(empty list)"}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Split a flat request body into spec and route options, refusing keys that are neither. */
|
|
50
|
+
export function splitRequest(body) {
|
|
51
|
+
const spec = {};
|
|
52
|
+
const route = {};
|
|
53
|
+
const unknown = [];
|
|
54
|
+
for (const [k, v] of Object.entries(body ?? {})) {
|
|
55
|
+
if (ROUTE_KEYS.includes(k))
|
|
56
|
+
route[k] = v;
|
|
57
|
+
else if (SPEC_KEYS.includes(k))
|
|
58
|
+
spec[k] = v;
|
|
59
|
+
else
|
|
60
|
+
unknown.push(k);
|
|
61
|
+
}
|
|
62
|
+
if (unknown.length)
|
|
63
|
+
throw bad(`unknown field${unknown.length > 1 ? "s" : ""} ${unknown.join(", ")}: a sandbox takes ${SPEC_KEYS.join(", ")}; routing takes ${ROUTE_KEYS.join(", ")}`);
|
|
64
|
+
validate(spec, route);
|
|
65
|
+
return { spec: spec, route: route };
|
|
66
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sandboxrouter/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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",
|