@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 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.
@@ -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 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
- }[]>;
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
- constructor(status: number, message: string, attempts?: Attempt[]);
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
- readonly estimatedHourlyUsd?: number | undefined;
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, estimatedHourlyUsd?: number | undefined, attempts?: Attempt[]);
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 list prices. */
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
- ...spec,
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
- constructor(status, message, attempts = []) {
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, estimatedHourlyUsd, attempts = []) {
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.estimatedHourlyUsd = estimatedHourlyUsd;
88
- this.attempts = attempts;
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
- /** Estimated list price per hour while it runs. */
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
- /** List price for the spec, USD/hour. What the sandbox is expected to cost at the provider's published rates. */
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
- * List price x what the provider really billed against list price, where that was measured
10
- * (`BILLED_VS_LIST`, or `RouterOptions.billedVsList`); the list price otherwise. The default strategy ranks by this.
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
- effectiveHourlyUsd: number;
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, for the default ranking. Default: `BILLED_VS_LIST` from saved cost runs. */
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[], estimatedHourlyUsd: number, ledger?: Ledger | undefined);
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, effectiveHourlyUsd: 0, verified: false, failing: false, skip: "unknown provider" };
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
- effectiveHourlyUsd: round(list * (this.billed[id] ?? 1)),
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 byPrice = (a, b) => a.estimatedHourlyUsd - b.estimatedHourlyUsd || a.provider.localeCompare(b.provider);
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) || byPrice(a, b));
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(byPrice);
185
+ list.sort(byList);
179
186
  }
180
187
  else if (strategy === "verified") {
181
- list.sort((a, b) => Number(b.verified) - Number(a.verified) || byPrice(a, b));
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, limitS * 1000, () => {
249
+ const box = await withDeadline(pending, waitS * 1000, () => {
232
250
  timedOut = true;
233
- return new ProviderError(c.provider, `create timed out after ${limitS}s`);
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, c.estimatedHourlyUsd, this.ledger);
238
- this.record(sbx, c.estimatedHourlyUsd, spec.timeoutMinutes);
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, c.estimatedHourlyUsd, spec.timeoutMinutes), () => { });
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, usdHour, timeoutMinutes) {
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, usdHour, timeoutMinutes) {
277
- const sbx = new RoutedSandbox(provider, box, [], usdHour, this.ledger);
278
- this.record(sbx, usdHour, timeoutMinutes);
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
- return new RoutedSandbox(provider, box, [], round(hourlyCost(provider)), this.ledger);
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, estimatedHourlyUsd, ledger) {
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.effectiveHourlyUsd - b.effectiveHourlyUsd || a.provider.localeCompare(b.provider);
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].effectiveHourlyUsd * (1 + PRICE_TIE);
396
- const tie = rest.filter((c) => c.effectiveHourlyUsd <= limit);
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
- /** After it started. If this throws, the sandbox is killed and the error returned. */
16
- afterCreate?(caller: unknown, sbx: RoutedSandbox, spec: SandboxSpec): Promise<void>;
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
- /** After DELETE killed it. */
20
- afterKill?(caller: unknown, id: string): Promise<void>;
21
- /** false turns off /v1/computers. */
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, hourlyCost } from "./catalog.js";
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
- ...c,
96
- configured: router.isConfigured(c.id),
97
- estimatedHourlyUsd: hourlyCost(c.id),
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 } = splitBody(await readJson(req));
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 (hooks.computers === false)
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 } = splitBody(await readJson(req));
117
+ const { spec, route } = splitRequest(await readJson(req));
116
118
  await hooks.beforeCreate?.(caller, spec, route);
117
- const sbx = await router.create(spec, route);
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 hooks.afterCreate?.(caller, sbx, spec);
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
- emit("error", { error: err.message });
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
- await (await box(id)).kill();
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
- const status = err instanceof HttpError ? err.status
249
- : err instanceof RouterError ? 502
250
- : err instanceof ProviderError && err.status ? err.status
251
- : 500;
252
- json(res, status, { error: err.message, attempts: err.attempts });
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
- const ROUTE_KEYS = ["provider", "strategy", "fallback", "require", "createTimeoutSeconds"];
264
- function splitBody(body) {
265
- const route = {};
266
- const spec = {};
267
- for (const [k, v] of Object.entries(body))
268
- (ROUTE_KEYS.includes(k) ? route : spec)[k] = v;
269
- // A provider id the catalog does not know is the client's mistake, not a routing failure.
270
- const ids = route.provider === undefined ? [] : [].concat(route.provider);
271
- const unknown = ids.filter((id) => !CATALOG.some((c) => c.id === id));
272
- if (unknown.length)
273
- throw new HttpError(400, `unknown provider: ${unknown.join(", ")}`);
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
- /** vCPUs (default 2). */
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 (default 4). */
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
- constructor(message: string, attempts?: Attempt[]);
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
- constructor(message, attempts = []) {
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
- return new ProviderError(provider, `command timed out after ${seconds}s`);
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
+ };
@@ -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.4.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",