@sandboxrouter/sdk 0.3.5 → 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/dist/router.js CHANGED
@@ -1,9 +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
- import { VERIFIED } from "./verified.js";
6
+ import { BILLED_VS_LIST } from "./billing.js";
7
+ import { FAILING, VERIFIED } from "./verified.js";
8
+ import { validate } from "./validate.js";
7
9
  import { DaytonaProvider } from "./providers/daytona.js";
8
10
  import { E2BProvider } from "./providers/e2b.js";
9
11
  import { MaritimeProvider } from "./providers/maritime.js";
@@ -43,6 +45,12 @@ import { MobilerunProvider } from "./providers/mobilerun.js";
43
45
  import { LimrunProvider } from "./providers/limrun.js";
44
46
  import { WorkspacesAgentProvider } from "./providers/workspaces.js";
45
47
  import { ProviderError, SetupError, RouterError, } from "./types.js";
48
+ /** Providers within this share of the cheapest remaining price count as a tie, broken by speed. */
49
+ export const PRICE_TIE = 0.1;
50
+ /** Creates this router must have seen for a provider before its own timings replace the bench file's. */
51
+ const LIVE_MIN_SAMPLES = 3;
52
+ /** Recent creates kept per provider for the live median. */
53
+ const LIVE_SAMPLES = 20;
46
54
  /** Desktop-only providers. Filled in as adapters are added. */
47
55
  export function defaultComputerProviders() {
48
56
  return [new OrgoProvider(), new MobilerunProvider(), new LimrunProvider(), new WorkspacesAgentProvider()];
@@ -89,6 +97,11 @@ export class Router {
89
97
  providers;
90
98
  benchFile;
91
99
  verified;
100
+ failing;
101
+ requireVerified;
102
+ billed;
103
+ /** Create times this router measured, most recent last. */
104
+ live = new Map();
92
105
  computerOnly;
93
106
  /** The local record of sandboxes this router created, or undefined when turned off. */
94
107
  ledger;
@@ -99,6 +112,10 @@ export class Router {
99
112
  this.providers = new Map((opts.providers ?? defaultProviders()).map((p) => [p.id, p]));
100
113
  this.benchFile = opts.benchFile;
101
114
  this.verified = new Set(opts.verified ?? Object.keys(VERIFIED));
115
+ this.failing = new Set(opts.failing ?? Object.keys(FAILING));
116
+ this.requireVerified = opts.requireVerified ?? false;
117
+ this.billed =
118
+ opts.billedVsList ?? Object.fromEntries(Object.entries(BILLED_VS_LIST).map(([id, b]) => [id, b.ratio]));
102
119
  this.computerOnly = opts.computers ?? (opts.providers ? [] : defaultComputerProviders());
103
120
  }
104
121
  /** Registered and has credentials. False for providers this router doesn't know. */
@@ -112,48 +129,66 @@ export class Router {
112
129
  provider(id) {
113
130
  const p = this.providers.get(id);
114
131
  if (!p)
115
- throw new RouterError(`unknown provider: ${id}`);
132
+ throw new RouterError(`unknown provider: ${id}`, [], "bad_request");
116
133
  return p;
117
134
  }
118
135
  /** The ordered list of providers a create would try, with the reason any are skipped. */
119
136
  plan(spec = {}, route = {}) {
137
+ validate(spec, route);
120
138
  const explicit = route.provider ? [].concat(route.provider) : null;
121
139
  // Local providers (Docker) are never picked automatically: they always "cost" $0 and isolate
122
140
  // less than a cloud micro-VM. Name them explicitly to use them.
123
141
  const ids = explicit ?? CATALOG.filter((c) => !c.local && this.providers.has(c.id)).map((c) => c.id);
124
- const bench = route.strategy === "fastest" ? loadBench(this.benchFile) : {};
142
+ const strategy = route.strategy ?? "default";
143
+ const speed = strategy === "default" || strategy === "balanced" || strategy === "fastest";
144
+ const bench = speed ? loadBench(this.benchFile) : {};
125
145
  const list = ids.map((id) => {
126
146
  // An id the catalog has never heard of (a typo in an explicit list) has no price to look up.
127
- if (!CATALOG.some((c) => c.id === id))
128
- return { provider: id, estimatedHourlyUsd: 0, verified: 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
+ }
129
150
  const p = this.providers.get(id);
151
+ const list = round(hourlyCost(id, spec));
152
+ const ratio = this.billed[id];
153
+ const live = this.live.get(id);
130
154
  const c = {
131
155
  provider: id,
132
- estimatedHourlyUsd: round(hourlyCost(id, spec)),
133
- medianCreateMs: median(bench[id]?.createMs),
156
+ estimatedHourlyUsd: round(list * (ratio ?? 1)),
157
+ listHourlyUsd: list,
158
+ ...(ratio !== undefined ? { billedVsList: ratio } : {}),
159
+ size: sizeOf(id, spec),
160
+ medianCreateMs: speed ? (live && live.length >= LIVE_MIN_SAMPLES ? median(live) : median(bench[id]?.createMs)) : undefined,
134
161
  verified: this.verified.has(id),
162
+ failing: this.failing.has(id),
135
163
  };
136
164
  if (!p)
137
165
  c.skip = "no adapter registered";
138
166
  else {
139
167
  const gated = accessLabel(id);
140
- c.skip = unfit(id, spec, route.require) ?? (p.configured() ? undefined : gated ? `not configured; ${gated}` : "not configured");
168
+ c.skip =
169
+ unfit(id, spec, route.require) ??
170
+ (p.configured() ? undefined : gated ? `not configured; ${gated}` : "not configured") ??
171
+ (this.requireVerified && !c.verified ? "not tested (this router only uses providers that passed the real-account check)" : undefined);
141
172
  }
142
173
  return c;
143
174
  });
144
- // Neutral by construction: the same rule for every provider, ties alphabetical by id. The default
145
- // puts providers that passed the real-account check first; any provider gets there by passing it.
146
- // An explicit provider list keeps the caller's order.
175
+ // Neutral by construction: the same rule for every provider, ties alphabetical by id. Nothing here
176
+ // depends on who the provider is, only on its price, what it really billed, how fast it started for
177
+ // this router and whether its last real-account check failed. An explicit list keeps the caller's order.
147
178
  if (!explicit) {
148
- const byPrice = (a, b) => a.estimatedHourlyUsd - b.estimatedHourlyUsd || a.provider.localeCompare(b.provider);
149
- if (route.strategy === "fastest") {
150
- list.sort((a, b) => (a.medianCreateMs ?? Infinity) - (b.medianCreateMs ?? Infinity) || byPrice(a, b));
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);
181
+ if (strategy === "fastest") {
182
+ list.sort((a, b) => (a.medianCreateMs ?? Infinity) - (b.medianCreateMs ?? Infinity) || byCost(a, b));
183
+ }
184
+ else if (strategy === "cheapest") {
185
+ list.sort(byList);
151
186
  }
152
- else if (route.strategy === "cheapest") {
153
- list.sort(byPrice);
187
+ else if (strategy === "verified") {
188
+ list.sort((a, b) => Number(b.verified) - Number(a.verified) || byCost(a, b));
154
189
  }
155
190
  else {
156
- list.sort((a, b) => Number(b.verified) - Number(a.verified) || byPrice(a, b));
191
+ return balanced(list);
157
192
  }
158
193
  }
159
194
  return list;
@@ -191,54 +226,76 @@ export class Router {
191
226
  const plan = this.plan(spec, route);
192
227
  const usable = plan.filter((c) => !c.skip);
193
228
  if (usable.length === 0)
194
- 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
+ }
195
234
  const attempts = [];
196
235
  const tries = route.fallback === false ? usable.slice(0, 1) : usable;
197
236
  const limitS = route.createTimeoutSeconds ?? DEFAULT_CREATE_TIMEOUT_S;
237
+ const deadline = Date.now() + (route.deadlineSeconds ?? DEFAULT_CREATE_DEADLINE_S) * 1000;
198
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
+ }
199
243
  const t0 = Date.now();
200
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 };
201
247
  let timedOut = false;
202
248
  try {
203
- const box = await withDeadline(pending, limitS * 1000, () => {
249
+ const box = await withDeadline(pending, waitS * 1000, () => {
204
250
  timedOut = true;
205
- 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`);
206
252
  });
207
253
  attempts.push({ provider: c.provider, ok: true, ms: Date.now() - t0 });
208
- const sbx = new RoutedSandbox(c.provider, box, attempts, c.estimatedHourlyUsd, this.ledger);
209
- this.record(sbx, c.estimatedHourlyUsd, spec.timeoutMinutes);
254
+ this.observe(c.provider, Date.now() - t0);
255
+ const sbx = new RoutedSandbox(c.provider, box, attempts, price, this.ledger);
256
+ this.record(sbx, price, spec.timeoutMinutes);
210
257
  return sbx;
211
258
  }
212
259
  catch (err) {
213
260
  // A create that answers after we gave up still made a sandbox: record it, then kill it,
214
261
  // so it can't bill unseen. If the kill fails, the ledger entry is left for `gc`.
215
262
  if (timedOut)
216
- pending.then((late) => this.discard(c.provider, late, c.estimatedHourlyUsd, spec.timeoutMinutes), () => { });
217
- attempts.push({ provider: c.provider, ok: false, ms: Date.now() - t0, error: err.message });
263
+ pending.then((late) => this.discard(c.provider, late, price, spec.timeoutMinutes), () => { });
218
264
  const status = errorStatus(err);
265
+ attempts.push({ provider: c.provider, ok: false, ms: Date.now() - t0, error: err.message, ...(status ? { status } : {}) });
219
266
  const setup = err instanceof SetupError || (status !== undefined && SETUP_ERRORS.has(status));
220
267
  if (route.fallback !== "any" && setup) {
221
268
  const msg = err.message.replace(/[.\s]+$/, "");
222
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), ` +
223
- `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");
224
271
  }
225
272
  }
226
273
  }
227
- 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");
275
+ }
276
+ /** Remember a successful create's time, for the speed tie-break and strategy "fastest". */
277
+ observe(id, ms) {
278
+ const xs = this.live.get(id) ?? [];
279
+ xs.push(ms);
280
+ if (xs.length > LIVE_SAMPLES)
281
+ xs.shift();
282
+ this.live.set(id, xs);
228
283
  }
229
- record(sbx, usdHour, timeoutMinutes) {
284
+ record(sbx, price, timeoutMinutes) {
230
285
  const now = Date.now();
231
286
  this.ledger?.open({
232
287
  id: sbx.id,
233
288
  provider: sbx.provider,
234
289
  createdAt: new Date(now).toISOString(),
235
290
  expiresAt: new Date(now + (timeoutMinutes ?? 30) * 60_000).toISOString(),
236
- usdHour,
291
+ usdHour: price.estimatedHourlyUsd,
292
+ listUsdHour: price.listHourlyUsd,
293
+ size: price.size,
237
294
  });
238
295
  }
239
- discard(provider, box, usdHour, timeoutMinutes) {
240
- const sbx = new RoutedSandbox(provider, box, [], usdHour, this.ledger);
241
- 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);
242
299
  return sbx.kill().catch(() => { });
243
300
  }
244
301
  /** Every provider that sells computers (desktops/phones): sandbox providers that do, plus desktop-only ones. */
@@ -270,26 +327,37 @@ export class Router {
270
327
  async get(id) {
271
328
  const { provider, nativeId } = parseId(id);
272
329
  const box = await this.provider(provider).connect(nativeId);
273
- 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);
274
335
  }
275
336
  }
276
337
  export class RoutedSandbox {
277
338
  provider;
278
339
  box;
279
340
  attempts;
280
- estimatedHourlyUsd;
281
341
  ledger;
282
342
  /** Stable id across processes: `provider:nativeId`. */
283
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;
284
350
  constructor(provider, box,
285
351
  /** Every create attempt, including failed fallbacks. */
286
- attempts, estimatedHourlyUsd, ledger) {
352
+ attempts, price, ledger) {
287
353
  this.provider = provider;
288
354
  this.box = box;
289
355
  this.attempts = attempts;
290
- this.estimatedHourlyUsd = estimatedHourlyUsd;
291
356
  this.ledger = ledger;
292
357
  this.id = `${provider}:${box.nativeId}`;
358
+ this.estimatedHourlyUsd = price?.estimatedHourlyUsd;
359
+ this.listHourlyUsd = price?.listHourlyUsd;
360
+ this.size = price?.size;
293
361
  }
294
362
  get nativeId() {
295
363
  return this.box.nativeId;
@@ -320,7 +388,7 @@ export class RoutedSandbox {
320
388
  /** Pause, keeping the disk. Resume with router.get(id) or, on Maritime, any call. */
321
389
  async pause() {
322
390
  if (!this.box.pause)
323
- 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");
324
392
  await this.box.pause();
325
393
  }
326
394
  get canPause() {
@@ -333,17 +401,42 @@ export class RoutedSandbox {
333
401
  export function parseId(id) {
334
402
  const i = id.indexOf(":");
335
403
  if (i <= 0)
336
- throw new RouterError(`bad sandbox id "${id}": expected provider:id`);
404
+ throw new RouterError(`bad sandbox id "${id}": expected provider:id`, [], "bad_request");
337
405
  const provider = id.slice(0, i);
338
406
  if (!CATALOG.some((c) => c.id === provider))
339
- throw new RouterError(`unknown provider in id: ${provider}`);
407
+ throw new RouterError(`unknown provider in id: ${provider}`, [], "bad_request");
340
408
  return { provider, nativeId: id.slice(i + 1) };
341
409
  }
342
410
  function round(n) {
343
411
  return Math.round(n * 10000) / 10000;
344
412
  }
413
+ /**
414
+ * The default order. Providers whose last real-account check failed go last. The rest go cheapest
415
+ * first by effective price (list price x what the provider really billed, where measured), except that
416
+ * providers within PRICE_TIE of the cheapest remaining one count as a tie and go fastest first (by
417
+ * median create time; unknown speed after known), then cheapest, then alphabetical.
418
+ */
419
+ export function balanced(list) {
420
+ const byCost = (a, b) => a.estimatedHourlyUsd - b.estimatedHourlyUsd || a.provider.localeCompare(b.provider);
421
+ const bySpeed = (a, b) => (a.medianCreateMs ?? Infinity) - (b.medianCreateMs ?? Infinity) || byCost(a, b);
422
+ const order = (group) => {
423
+ const rest = [...group].sort(byCost);
424
+ const out = [];
425
+ while (rest.length) {
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);
429
+ out.push(...tie.sort(bySpeed));
430
+ rest.splice(0, tie.length);
431
+ }
432
+ return out;
433
+ };
434
+ return [...order(list.filter((c) => !c.failing)), ...order(list.filter((c) => c.failing))];
435
+ }
345
436
  /** Seconds to wait for one provider's create before trying the next (RouteOptions.createTimeoutSeconds). */
346
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;
347
440
  /**
348
441
  * HTTP statuses that mean the request or the key is wrong, not that the provider is down. 402 (out of
349
442
  * balance, plan needed) isn't one: an unfunded account is exactly when the next provider should take the
@@ -367,3 +460,7 @@ async function withDeadline(work, ms, onExpire) {
367
460
  work.catch(() => { });
368
461
  }
369
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;
@@ -22,18 +27,27 @@ export interface SandboxSpec {
22
27
  metadata?: Record<string, string>;
23
28
  }
24
29
  /**
25
- * "verified" (and "default", the same thing): providers that passed the real-account check first
26
- * (as of this release; `VERIFIED` in src/verified.ts), then the rest; cheapest first within each,
27
- * ties alphabetical.
30
+ * "balanced" (and "default", the same thing): cheapest first by effective price (list price x what the
31
+ * provider really billed, where measured; `BILLED_VS_LIST`), with providers within 10% of each other on
32
+ * price ordered by speed (this router's own recent create times, else your `bench` file). Providers
33
+ * whose last real-account check failed (`FAILING`) go last.
34
+ * "verified": providers that passed the real-account check first (`VERIFIED`), then the rest; cheapest
35
+ * list price first within each.
28
36
  * "cheapest": lowest list price for the spec, tested or not, ties alphabetical.
29
- * "fastest": lowest median cold start you measured with `bench`, then cheapest.
37
+ * "fastest": lowest median create time (this router's own, else your `bench` file), then cheapest.
30
38
  */
31
- export type Strategy = "default" | "verified" | "cheapest" | "fastest";
39
+ export type Strategy = "default" | "balanced" | "verified" | "cheapest" | "fastest";
32
40
  export interface RouteOptions {
33
41
  /** Pin to one provider, or give an ordered list to try. Omit to let the router choose. */
34
42
  provider?: ProviderId | ProviderId[];
35
- /** How to order candidates when no explicit list is given. Default: "verified" (tested providers first, then cheapest). */
43
+ /** How to order candidates when no explicit list is given. Default: "balanced" (cheapest real cost, speed breaks near-ties). */
36
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;
37
51
  /**
38
52
  * What to do when a create fails. `true` (default): try the next candidate when the provider
39
53
  * looks unavailable (timeout, network error, 429, 5xx, or an error without a status) or can't bill
@@ -86,10 +100,24 @@ export interface Attempt {
86
100
  ok: boolean;
87
101
  ms: number;
88
102
  error?: string;
103
+ /** HTTP status of the failure, when the provider gave one (402: the account can't pay). */
104
+ status?: number;
89
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";
90
117
  export declare class RouterError extends Error {
91
118
  readonly attempts: Attempt[];
92
- constructor(message: string, attempts?: Attempt[]);
119
+ readonly code: RouterErrorCode;
120
+ constructor(message: string, attempts?: Attempt[], code?: RouterErrorCode);
93
121
  }
94
122
  /** Thrown by adapters for HTTP failures so the router can report status codes. */
95
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
  }