@sandboxrouter/sdk 0.4.0 → 0.6.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 +42 -14
- package/dist/client.js +39 -16
- package/dist/computers.d.ts +47 -4
- package/dist/computers.js +130 -17
- package/dist/gc.js +5 -2
- package/dist/index.d.ts +7 -4
- package/dist/index.js +6 -3
- package/dist/ledger.d.ts +11 -2
- package/dist/router.d.ts +40 -11
- package/dist/router.js +79 -60
- package/dist/server.d.ts +37 -7
- package/dist/server.js +195 -50
- package/dist/types.d.ts +28 -4
- package/dist/types.js +3 -1
- package/dist/util.d.ts +8 -0
- package/dist/util.js +23 -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.
|
|
@@ -18,41 +19,68 @@ export declare class Client {
|
|
|
18
19
|
fetch?: typeof fetch;
|
|
19
20
|
});
|
|
20
21
|
/** @internal */
|
|
21
|
-
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>;
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
create(spec?: SandboxSpec, route?: RouteOptions
|
|
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[]>;
|
|
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>;
|
|
37
42
|
/** A handle on a sandbox you created earlier. */
|
|
38
43
|
sandbox(id: string): RemoteSandbox;
|
|
39
44
|
}
|
|
40
45
|
export declare class ClientError extends Error {
|
|
41
46
|
readonly status: number;
|
|
42
47
|
readonly attempts: Attempt[];
|
|
43
|
-
|
|
48
|
+
/** The server's RouterErrorCode, when the router refused or failed (e.g. "no_provider", "all_failed"). */
|
|
49
|
+
readonly code?: RouterErrorCode | undefined;
|
|
50
|
+
constructor(status: number, message: string, attempts?: Attempt[],
|
|
51
|
+
/** The server's RouterErrorCode, when the router refused or failed (e.g. "no_provider", "all_failed"). */
|
|
52
|
+
code?: RouterErrorCode | undefined);
|
|
53
|
+
}
|
|
54
|
+
/** What POST /v1/sandboxes answers. A hosted service may add fields (e.g. what it charges). */
|
|
55
|
+
export interface CreateResponse {
|
|
56
|
+
id: string;
|
|
57
|
+
provider: string;
|
|
58
|
+
size?: Size;
|
|
59
|
+
estimatedHourlyUsd?: number;
|
|
60
|
+
listHourlyUsd?: number;
|
|
61
|
+
attempts: Attempt[];
|
|
62
|
+
[field: string]: unknown;
|
|
44
63
|
}
|
|
45
64
|
export declare class RemoteSandbox {
|
|
46
65
|
private readonly client;
|
|
47
66
|
readonly id: string;
|
|
48
67
|
readonly provider: string;
|
|
49
|
-
|
|
68
|
+
/** The full create response, including any fields the server added. Empty for `client.sandbox(id)`. */
|
|
69
|
+
readonly details: Partial<CreateResponse>;
|
|
70
|
+
readonly estimatedHourlyUsd?: number;
|
|
71
|
+
readonly listHourlyUsd?: number;
|
|
72
|
+
readonly size?: Size;
|
|
50
73
|
readonly attempts: Attempt[];
|
|
51
|
-
constructor(client: Client, id: string, provider: string,
|
|
74
|
+
constructor(client: Client, id: string, provider: string,
|
|
75
|
+
/** The full create response, including any fields the server added. Empty for `client.sandbox(id)`. */
|
|
76
|
+
details?: Partial<CreateResponse>);
|
|
52
77
|
private path;
|
|
53
78
|
exec(cmd: string, opts?: ExecOptions): Promise<ExecResult>;
|
|
54
79
|
writeFile(path: string, content: string | Uint8Array): Promise<void>;
|
|
55
80
|
readFile(path: string): Promise<Uint8Array>;
|
|
81
|
+
readText(path: string): Promise<string>;
|
|
56
82
|
pause(): Promise<void>;
|
|
57
83
|
kill(): Promise<void>;
|
|
84
|
+
/** `await using sbx = await client.create()` kills it when the block ends. */
|
|
85
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
58
86
|
}
|
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
|
});
|
|
@@ -35,28 +36,31 @@ export class Client {
|
|
|
35
36
|
parsed = JSON.parse(text);
|
|
36
37
|
}
|
|
37
38
|
catch { }
|
|
38
|
-
throw new ClientError(res.status, parsed.error ?? (text || res.statusText), parsed.attempts ?? []);
|
|
39
|
+
throw new ClientError(res.status, parsed.error ?? (text || res.statusText), parsed.attempts ?? [], parsed.code);
|
|
39
40
|
}
|
|
40
41
|
return res;
|
|
41
42
|
}
|
|
42
43
|
async json(method, path, body) {
|
|
43
44
|
return (await this.request(method, path, body)).json();
|
|
44
45
|
}
|
|
45
|
-
/** Providers this server can route to, with
|
|
46
|
+
/** Providers this server can route to, with prices at the default size. */
|
|
46
47
|
async providers() {
|
|
47
48
|
return (await this.json("GET", "/v1/providers")).providers;
|
|
48
49
|
}
|
|
49
|
-
/** Dry run: which providers would be tried, in order. */
|
|
50
|
+
/** Dry run: which providers would be tried, in order, each with its price, size and any reason it's skipped. */
|
|
50
51
|
async route(spec = {}, route = {}) {
|
|
51
52
|
return (await this.json("POST", "/v1/route", { ...spec, ...route })).candidates;
|
|
52
53
|
}
|
|
53
|
-
/**
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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" });
|
|
60
64
|
}
|
|
61
65
|
/** A handle on a sandbox you created earlier. */
|
|
62
66
|
sandbox(id) {
|
|
@@ -67,10 +71,14 @@ const DEFAULT_URL = "http://127.0.0.1:8787";
|
|
|
67
71
|
export class ClientError extends Error {
|
|
68
72
|
status;
|
|
69
73
|
attempts;
|
|
70
|
-
|
|
74
|
+
code;
|
|
75
|
+
constructor(status, message, attempts = [],
|
|
76
|
+
/** The server's RouterErrorCode, when the router refused or failed (e.g. "no_provider", "all_failed"). */
|
|
77
|
+
code) {
|
|
71
78
|
super(`${status}: ${message}`);
|
|
72
79
|
this.status = status;
|
|
73
80
|
this.attempts = attempts;
|
|
81
|
+
this.code = code;
|
|
74
82
|
this.name = "ClientError";
|
|
75
83
|
}
|
|
76
84
|
}
|
|
@@ -78,14 +86,22 @@ export class RemoteSandbox {
|
|
|
78
86
|
client;
|
|
79
87
|
id;
|
|
80
88
|
provider;
|
|
89
|
+
details;
|
|
81
90
|
estimatedHourlyUsd;
|
|
91
|
+
listHourlyUsd;
|
|
92
|
+
size;
|
|
82
93
|
attempts;
|
|
83
|
-
constructor(client, id, provider,
|
|
94
|
+
constructor(client, id, provider,
|
|
95
|
+
/** The full create response, including any fields the server added. Empty for `client.sandbox(id)`. */
|
|
96
|
+
details = {}) {
|
|
84
97
|
this.client = client;
|
|
85
98
|
this.id = id;
|
|
86
99
|
this.provider = provider;
|
|
87
|
-
this.
|
|
88
|
-
this.
|
|
100
|
+
this.details = details;
|
|
101
|
+
this.estimatedHourlyUsd = details.estimatedHourlyUsd;
|
|
102
|
+
this.listHourlyUsd = details.listHourlyUsd;
|
|
103
|
+
this.size = details.size;
|
|
104
|
+
this.attempts = details.attempts ?? [];
|
|
89
105
|
}
|
|
90
106
|
path(suffix = "") {
|
|
91
107
|
return `/v1/sandboxes/${encodeURIComponent(this.id)}${suffix}`;
|
|
@@ -118,7 +134,7 @@ export class RemoteSandbox {
|
|
|
118
134
|
else if (event === "result")
|
|
119
135
|
return payload;
|
|
120
136
|
else if (event === "error")
|
|
121
|
-
throw new ClientError(502, payload.error ?? data);
|
|
137
|
+
throw new ClientError(payload.status ?? 502, payload.error ?? data, [], payload.code);
|
|
122
138
|
}
|
|
123
139
|
}
|
|
124
140
|
throw new ClientError(502, "stream ended before the command finished");
|
|
@@ -131,10 +147,17 @@ export class RemoteSandbox {
|
|
|
131
147
|
const res = await this.client.request("GET", this.path(`/files?path=${encodeURIComponent(path)}`), undefined, "application/octet-stream");
|
|
132
148
|
return new Uint8Array(await res.arrayBuffer());
|
|
133
149
|
}
|
|
150
|
+
async readText(path) {
|
|
151
|
+
return new TextDecoder().decode(await this.readFile(path));
|
|
152
|
+
}
|
|
134
153
|
async pause() {
|
|
135
154
|
await this.client.request("POST", this.path("/pause"));
|
|
136
155
|
}
|
|
137
156
|
async kill() {
|
|
138
157
|
await this.client.request("DELETE", this.path());
|
|
139
158
|
}
|
|
159
|
+
/** `await using sbx = await client.create()` kills it when the block ends. */
|
|
160
|
+
async [Symbol.asyncDispose]() {
|
|
161
|
+
await this.kill();
|
|
162
|
+
}
|
|
140
163
|
}
|
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
|
@@ -2,11 +2,14 @@ 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";
|
|
9
|
-
export { RoutedComputer, createComputer, planComputer, computerCatalog, computerHourly, COMPUTER_ENTRIES, type ComputerCatalogEntry, type ComputerCandidate, type ComputerProviderId, type ActionResult, type ComputerAction, type ComputerActionName, type ComputerInfo, type ComputerOs, type ComputerProvider, type ComputerSpec, type ProviderComputer, } from "./computers.js";
|
|
10
|
+
export { createApp, HttpError, ERROR_STATUS, ORPHAN_GRACE_MS, type ServerHooks } from "./server.js";
|
|
11
|
+
export { Client, ClientError, RemoteSandbox, type CreateResponse } from "./client.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";
|
|
10
13
|
export { bench, benchMarkdown, type BenchRow } from "./bench.js";
|
|
11
14
|
export { aggregate, loadReports, makeReport, renderLeaderboard, type BenchReport, type LeaderRow } from "./leaderboard.js";
|
|
12
15
|
export { MaritimeProvider } from "./providers/maritime.js";
|
package/dist/index.js
CHANGED
|
@@ -2,11 +2,14 @@ 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, ORPHAN_GRACE_MS } from "./server.js";
|
|
8
11
|
export { Client, ClientError, RemoteSandbox } from "./client.js";
|
|
9
|
-
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";
|
|
10
13
|
export { bench, benchMarkdown } from "./bench.js";
|
|
11
14
|
export { aggregate, loadReports, makeReport, renderLeaderboard } from "./leaderboard.js";
|
|
12
15
|
export { MaritimeProvider } from "./providers/maritime.js";
|
package/dist/ledger.d.ts
CHANGED
|
@@ -3,13 +3,22 @@ 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. */
|
|
10
12
|
expiresAt: string;
|
|
11
|
-
/**
|
|
13
|
+
/** Expected cost per hour while it runs (list price x measured billed ratio). */
|
|
12
14
|
usdHour: number;
|
|
15
|
+
/** List price per hour. Missing in entries written before 0.5.0. */
|
|
16
|
+
listUsdHour?: number;
|
|
17
|
+
/** Size it was created at. Missing in entries written before 0.5.0. */
|
|
18
|
+
size?: {
|
|
19
|
+
vcpu: number;
|
|
20
|
+
memoryGb: number;
|
|
21
|
+
};
|
|
13
22
|
}
|
|
14
23
|
/**
|
|
15
24
|
* Where the default ledger lives: $SANDBOXROUTER_HOME/sandboxes.jsonl, else ~/.sandboxrouter/.
|