@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 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 tries the ones that passed the real-account check first. "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
+ 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 " : c.verified ? "tested " : " ";
160
- // The billed column only shows where a measured bill moved it off list price.
161
- const real = c.effectiveHourlyUsd !== c.estimatedHourlyUsd ? ` (billed ~$${c.effectiveHourlyUsd.toFixed(4)})` : "";
162
- const speed = c.medianCreateMs !== undefined ? `${c.medianCreateMs} ms ` : "";
163
- console.log(`${mark} ${c.provider.padEnd(11)} $${c.estimatedHourlyUsd.toFixed(4)}/hr${real} ${speed}${tested}${c.skip ? `(skipped: ${c.skip})` : ""}`.trimEnd());
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 { Attempt, ExecOptions, ExecResult, RouteOptions, SandboxSpec } from "./types.js";
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 list prices. */
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
- }[]>;
35
- /** Start a sandbox on the first provider that works, as the server routes it. */
36
- create(spec?: SandboxSpec, route?: RouteOptions): Promise<RemoteSandbox>;
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
- constructor(status: number, message: string, attempts?: Attempt[]);
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
- readonly estimatedHourlyUsd?: number | undefined;
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, estimatedHourlyUsd?: number | undefined, attempts?: Attempt[]);
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 list prices. */
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
- /** Start a sandbox on the first provider that works, as the server routes it. */
54
- async create(spec = {}, route = {}) {
55
- const r = await this.json("POST", "/v1/sandboxes", {
56
- ...spec,
57
- ...route,
58
- });
59
- return new RemoteSandbox(this, r.id, r.provider, r.estimatedHourlyUsd, r.attempts);
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
- constructor(status, message, attempts = []) {
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, estimatedHourlyUsd, attempts = []) {
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.estimatedHourlyUsd = estimatedHourlyUsd;
88
- this.attempts = attempts;
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
  }
@@ -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
- exec(command: string, timeoutSeconds?: number): Promise<{
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. An explicit `prefer` list keeps its order.
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[]): Promise<RoutedComputer>;
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
- exec(command, timeoutSeconds) {
29
- return this.c.exec(command, timeoutSeconds);
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
- delete() {
52
- return this.c.delete();
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. An explicit `prefer` list keeps its order.
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 usable = planComputer(providers, os, prefer)
176
- .filter((c) => !c.skip)
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 errors = [];
183
- for (const p of usable) {
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
- return new RoutedComputer(p.id, await p.createComputer({ ...spec, os }));
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
- errors.push(`${p.id}: ${err.message}`);
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: ${errors.join(" | ")}`);
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
- const box = await router.get(s.id);
38
- await box.kill(); // closes the ledger entry
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
- /** Estimated list price per hour while it runs. */
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/.