@sandboxrouter/sdk 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -1,8 +1,11 @@
1
- import { timingSafeEqual } from "node:crypto";
1
+ import { createHash, 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
+ import { splitComputerRequest } from "./computers.js";
5
6
  import { ProviderError, RouterError } from "./types.js";
7
+ import { errorStatus } from "./util.js";
8
+ import { splitRequest } from "./validate.js";
6
9
  /**
7
10
  * OpenRouter-style HTTP API in front of every provider. One token for your clients; provider keys
8
11
  * stay on this host.
@@ -46,6 +49,64 @@ corsOrigins = (process.env.SANDBOXROUTER_CORS ?? "").split(",").map((s) => s.tri
46
49
  return got.length === expected.length && timingSafeEqual(got, expected);
47
50
  };
48
51
  const live = new Map();
52
+ const computersOn = hooks.computers ?? !hooks.authorize;
53
+ /**
54
+ * Idempotency-Key: a create retried with the same key (by the same caller, to the same path) gets the
55
+ * first create's answer instead of starting another sandbox, for 24 hours. A retry that arrives while
56
+ * the first is still starting waits for it. The same key with a different body is refused (422).
57
+ * Failed creates aren't remembered, so retrying one runs it again.
58
+ */
59
+ const replays = new Map();
60
+ const once = async (req, res, body, run) => {
61
+ const raw = req.headers["idempotency-key"];
62
+ if (raw === undefined) {
63
+ const r = await run(false);
64
+ return json(res, r.status, r.body);
65
+ }
66
+ const key = Array.isArray(raw) ? raw[0] : raw;
67
+ if (!key || key.length > 255)
68
+ throw new HttpError(400, "Idempotency-Key must be 1 to 255 characters");
69
+ const now = Date.now();
70
+ for (const [k, v] of replays)
71
+ if (now - v.at > IDEMPOTENCY_TTL_MS)
72
+ replays.delete(k);
73
+ while (replays.size >= IDEMPOTENCY_MAX)
74
+ replays.delete(replays.keys().next().value);
75
+ const scope = sha256(`${req.headers.authorization ?? ""}\n${req.url}\n${key}`);
76
+ const bodyHash = sha256(JSON.stringify(body ?? {}));
77
+ const hit = replays.get(scope);
78
+ if (hit) {
79
+ if (hit.bodyHash !== bodyHash)
80
+ throw new HttpError(422, "this Idempotency-Key was already used with a different request");
81
+ const r = await hit.result;
82
+ hit.claimed = true;
83
+ res.setHeader("Idempotent-Replayed", "true");
84
+ return json(res, r.status, r.body);
85
+ }
86
+ const entry = { bodyHash, result: run(true), at: now, claimed: false };
87
+ replays.set(scope, entry);
88
+ let r;
89
+ try {
90
+ r = await entry.result;
91
+ }
92
+ catch (err) {
93
+ replays.delete(scope);
94
+ throw err;
95
+ }
96
+ // The caller hung up but sent a key, so it means to retry: keep the sandbox for a retry to
97
+ // collect, and only then give up on it.
98
+ if (!res.writableEnded && (res.destroyed || !res.writable) && r.discard) {
99
+ setTimeout(() => {
100
+ if (!entry.claimed) {
101
+ replays.delete(scope);
102
+ r.discard().catch(() => { });
103
+ }
104
+ }, hooks.orphanGraceMs ?? ORPHAN_GRACE_MS).unref();
105
+ }
106
+ else
107
+ entry.claimed = true;
108
+ return json(res, r.status, r.body);
109
+ };
49
110
  const cors = (req, res) => {
50
111
  const origin = req.headers.origin;
51
112
  if (!origin || !(corsOrigins.includes("*") || corsOrigins.includes(origin)))
@@ -91,43 +152,73 @@ corsOrigins = (process.env.SANDBOXROUTER_CORS ?? "").split(",").map((s) => s.tri
91
152
  // GET /v1/providers
92
153
  if (req.method === "GET" && parts[1] === "providers" && parts.length === 2) {
93
154
  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
- })),
155
+ providers: CATALOG.filter((c) => router.registered().includes(c.id)).map((c) => {
156
+ const p = router.plan({}, { provider: c.id })[0];
157
+ return { ...c, configured: router.isConfigured(c.id), estimatedHourlyUsd: p.estimatedHourlyUsd, listHourlyUsd: p.listHourlyUsd, size: p.size };
158
+ }),
99
159
  });
100
160
  }
101
161
  // POST /v1/route
102
162
  if (req.method === "POST" && parts[1] === "route" && parts.length === 2) {
103
- const { spec, route } = splitBody(await readJson(req));
163
+ const { spec, route } = splitRequest(await readJson(req));
104
164
  return json(res, 200, { candidates: router.plan(spec, route) });
105
165
  }
106
166
  if (parts[1] === "computers") {
107
- if (hooks.computers === false)
167
+ if (!computersOn)
108
168
  return json(res, 404, { error: "computers aren't available on this server" });
109
- return computers(req, res, url, parts);
169
+ return computers(req, res, url, parts, caller);
110
170
  }
111
171
  if (parts[1] !== "sandboxes")
112
172
  return json(res, 404, { error: "not found" });
113
173
  // POST /v1/sandboxes
114
174
  if (req.method === "POST" && parts.length === 2) {
115
- const { spec, route } = splitBody(await readJson(req));
116
- await hooks.beforeCreate?.(caller, spec, route);
117
- const sbx = await router.create(spec, route);
118
- try {
119
- await hooks.afterCreate?.(caller, sbx, spec);
120
- }
121
- catch (err) {
122
- await sbx.kill().catch(() => { });
123
- throw err;
124
- }
125
- live.set(sbx.id, sbx);
126
- return json(res, 201, {
127
- id: sbx.id,
128
- provider: sbx.provider,
129
- estimatedHourlyUsd: sbx.estimatedHourlyUsd,
130
- attempts: sbx.attempts,
175
+ const body = await readJson(req);
176
+ const { spec, route } = splitRequest(body);
177
+ // A caller that gives up (client timeout, closed connection) never learns the id: kill what
178
+ // starts after that rather than leave it running, and billing, unseen. With an Idempotency-Key
179
+ // it's kept for a retry instead (see once).
180
+ let hungUp = false;
181
+ res.on("close", () => (hungUp = !res.writableFinished));
182
+ return once(req, res, body, async (keepIfHungUp) => {
183
+ await hooks.beforeCreate?.(caller, spec, route);
184
+ let sbx;
185
+ let extra;
186
+ try {
187
+ sbx = await router.create(spec, route);
188
+ }
189
+ catch (err) {
190
+ await hooks.createFailed?.(caller);
191
+ throw err;
192
+ }
193
+ try {
194
+ if (hungUp && !keepIfHungUp)
195
+ throw new HttpError(499, "the client closed the connection before the sandbox was ready; it was killed");
196
+ extra = await hooks.afterCreate?.(caller, sbx, spec);
197
+ }
198
+ catch (err) {
199
+ await sbx.kill().catch(() => { });
200
+ await hooks.createFailed?.(caller);
201
+ throw err;
202
+ }
203
+ live.set(sbx.id, sbx);
204
+ const made = sbx;
205
+ return {
206
+ status: 201,
207
+ body: {
208
+ id: sbx.id,
209
+ provider: sbx.provider,
210
+ size: sbx.size,
211
+ estimatedHourlyUsd: sbx.estimatedHourlyUsd,
212
+ listHourlyUsd: sbx.listHourlyUsd,
213
+ attempts: sbx.attempts,
214
+ ...(extra ?? {}),
215
+ },
216
+ discard: async () => {
217
+ live.delete(made.id);
218
+ await made.kill().catch(() => { });
219
+ await hooks.afterKill?.(caller, made.id);
220
+ },
221
+ };
131
222
  });
132
223
  }
133
224
  const id = parts[2] ? decodeURIComponent(parts[2]) : "";
@@ -149,7 +240,8 @@ corsOrigins = (process.env.SANDBOXROUTER_CORS ?? "").split(",").map((s) => s.tri
149
240
  emit("result", r);
150
241
  }
151
242
  catch (err) {
152
- emit("error", { error: err.message });
243
+ const e = describeError(err);
244
+ emit("error", { ...e.body, status: e.status });
153
245
  }
154
246
  return res.end();
155
247
  }
@@ -179,28 +271,67 @@ corsOrigins = (process.env.SANDBOXROUTER_CORS ?? "").split(",").map((s) => s.tri
179
271
  }
180
272
  // DELETE /v1/sandboxes/:id
181
273
  if (req.method === "DELETE" && parts.length === 3) {
182
- await (await box(id)).kill();
274
+ try {
275
+ await (await box(id)).kill();
276
+ }
277
+ catch (err) {
278
+ // Already gone at the provider: that's what the caller wanted.
279
+ if (!(err instanceof HttpError) && errorStatus(err) === 404) {
280
+ live.delete(id);
281
+ await hooks.afterKill?.(caller, id);
282
+ return json(res, 200, { id, killed: true });
283
+ }
284
+ if (!(err instanceof HttpError))
285
+ await hooks.afterKill?.(caller, id, { error: err });
286
+ throw err;
287
+ }
183
288
  live.delete(id);
184
289
  await hooks.afterKill?.(caller, id);
185
290
  return json(res, 200, { id, killed: true });
186
291
  }
187
292
  return json(res, 404, { error: "not found" });
188
293
  };
189
- const computers = async (req, res, url, parts) => {
294
+ const computers = async (req, res, url, parts, caller) => {
190
295
  if (req.method === "POST" && parts.length === 2) {
191
- const { provider, ...spec } = await readJson(req);
192
- const c = await router.computer(spec, { provider });
193
- return json(res, 201, { ...(await c.info()), id: c.id, nativeId: c.nativeId, provider: c.provider });
296
+ const body = await readJson(req);
297
+ const { spec, route } = splitComputerRequest(body);
298
+ return once(req, res, body, async () => {
299
+ await hooks.beforeCreateComputer?.(caller, spec);
300
+ const c = await router.computer(spec, route);
301
+ let extra;
302
+ try {
303
+ extra = await hooks.afterCreateComputer?.(caller, c);
304
+ }
305
+ catch (err) {
306
+ await c.delete().catch(() => { });
307
+ throw err;
308
+ }
309
+ return {
310
+ status: 201,
311
+ body: { ...(await c.info()), id: c.id, nativeId: c.nativeId, provider: c.provider, estimatedHourlyUsd: c.estimatedHourlyUsd, attempts: c.attempts, ...(extra ?? {}) },
312
+ };
313
+ });
194
314
  }
195
315
  const id = parts[2] ? decodeURIComponent(parts[2]) : "";
196
316
  if (!id)
197
317
  return json(res, 404, { error: "not found" });
318
+ if (hooks.owns && !(await hooks.owns(caller, id)))
319
+ throw new HttpError(404, "no such computer");
198
320
  const c = await router.getComputer(id);
199
321
  const sub = parts[3];
200
322
  if (req.method === "GET" && !sub)
201
323
  return json(res, 200, { ...(await c.info()), id, nativeId: c.nativeId });
202
324
  if (req.method === "DELETE" && !sub) {
203
- await c.delete();
325
+ try {
326
+ await c.delete();
327
+ }
328
+ catch (err) {
329
+ if (errorStatus(err) !== 404) {
330
+ await hooks.afterDeleteComputer?.(caller, id, { error: err });
331
+ throw err;
332
+ }
333
+ }
334
+ await hooks.afterDeleteComputer?.(caller, id);
204
335
  return json(res, 200, { id, deleted: true });
205
336
  }
206
337
  if (req.method === "POST" && sub === "actions") {
@@ -245,11 +376,11 @@ corsOrigins = (process.env.SANDBOXROUTER_CORS ?? "").split(",").map((s) => s.tri
245
376
  };
246
377
  return createServer((req, res) => {
247
378
  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 });
379
+ // A closed connection can't be answered.
380
+ if (res.writableEnded || res.destroyed)
381
+ return;
382
+ const e = describeError(err);
383
+ json(res, e.status, e.body);
253
384
  });
254
385
  });
255
386
  }
@@ -260,19 +391,33 @@ export class HttpError extends Error {
260
391
  this.status = status;
261
392
  }
262
393
  }
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 };
394
+ /**
395
+ * The HTTP status and JSON body for an error: `{ error, code?, attempts? }`. RouterErrors carry a code
396
+ * (see RouterErrorCode) that maps to one status; a ProviderError with an HTTP status passes it on.
397
+ */
398
+ function describeError(err) {
399
+ const status = err instanceof HttpError ? err.status
400
+ : err instanceof RouterError ? ERROR_STATUS[err.code]
401
+ : err instanceof ProviderError && err.status ? err.status
402
+ : 500;
403
+ const code = err instanceof RouterError ? err.code : undefined;
404
+ return { status, body: { error: err?.message ?? String(err), ...(code ? { code } : {}), ...(err?.attempts?.length ? { attempts: err.attempts } : {}) } };
275
405
  }
406
+ /** How long an Idempotency-Key is remembered, and how many at most. */
407
+ const IDEMPOTENCY_TTL_MS = 24 * 3600_000;
408
+ const IDEMPOTENCY_MAX = 10_000;
409
+ /** How long a sandbox whose caller hung up (with an Idempotency-Key) waits for a retry to collect it. */
410
+ export const ORPHAN_GRACE_MS = 120_000;
411
+ const sha256 = (s) => createHash("sha256").update(s).digest("hex");
412
+ /** The HTTP status for each RouterError code (see RouterErrorCode). */
413
+ export const ERROR_STATUS = {
414
+ bad_request: 400,
415
+ unsupported: 400,
416
+ no_provider: 422,
417
+ setup: 502,
418
+ all_failed: 503,
419
+ timeout: 504,
420
+ };
276
421
  const MAX_BODY = 100 * 1024 * 1024;
277
422
  async function readRaw(req) {
278
423
  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
@@ -85,16 +96,29 @@ export interface Provider {
85
96
  connect(nativeId: string): Promise<ProviderSandbox>;
86
97
  }
87
98
  export interface Attempt {
88
- provider: ProviderId;
99
+ /** A sandbox provider, or a computer provider (some sell only computers). */
100
+ provider: ProviderId | (string & {});
89
101
  ok: boolean;
90
102
  ms: number;
91
103
  error?: string;
92
104
  /** HTTP status of the failure, when the provider gave one (402: the account can't pay). */
93
105
  status?: number;
94
106
  }
107
+ /**
108
+ * Why the router couldn't do what was asked. Each maps to one HTTP status in the server.
109
+ * - bad_request (400): an invalid spec, route option or sandbox id
110
+ * - unsupported (400): the sandbox's provider can't do this (e.g. pause)
111
+ * - no_provider (422): no provider you have keys for can take this sandbox (`plan()` says why for each)
112
+ * - setup (502): a provider refused the request or the key (400/401/403/404/422 from it); fix the key,
113
+ * image or request rather than retrying
114
+ * - all_failed (503): every provider tried was down, busy or out of credit; retrying later can work
115
+ * - timeout (504): the create ran out of `deadlineSeconds`
116
+ */
117
+ export type RouterErrorCode = "bad_request" | "unsupported" | "no_provider" | "setup" | "all_failed" | "timeout";
95
118
  export declare class RouterError extends Error {
96
119
  readonly attempts: Attempt[];
97
- constructor(message: string, attempts?: Attempt[]);
120
+ readonly code: RouterErrorCode;
121
+ constructor(message: string, attempts?: Attempt[], code?: RouterErrorCode);
98
122
  }
99
123
  /** Thrown by adapters for HTTP failures so the router can report status codes. */
100
124
  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.d.ts CHANGED
@@ -43,3 +43,11 @@ export declare function optionalImport<T>(pkg: string, provider: string): Promis
43
43
  * (E2B's AuthenticationError, AWS's CredentialsProviderError). Undefined when there's none.
44
44
  */
45
45
  export declare function errorStatus(err: unknown, depth?: number): number | undefined;
46
+ /**
47
+ * HTTP statuses that mean the request or the key is wrong, not that the provider is down. 402 (out of
48
+ * balance, plan needed) isn't one: an unfunded account is exactly when the next provider should take the
49
+ * sandbox, and the failed attempt stays on `attempts` (the CLI prints it), so it isn't hidden.
50
+ */
51
+ export declare const SETUP_ERRORS: Set<number>;
52
+ /** Resolve with `work`, or reject with `onExpire()` after `ms`. A late `work` rejection is swallowed. */
53
+ export declare function withDeadline<T>(work: Promise<T>, ms: number, onExpire: () => Error): Promise<T>;
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
@@ -110,3 +111,24 @@ export function errorStatus(err, depth = 0) {
110
111
  return 429;
111
112
  return errorStatus(e.cause, depth + 1);
112
113
  }
114
+ /**
115
+ * HTTP statuses that mean the request or the key is wrong, not that the provider is down. 402 (out of
116
+ * balance, plan needed) isn't one: an unfunded account is exactly when the next provider should take the
117
+ * sandbox, and the failed attempt stays on `attempts` (the CLI prints it), so it isn't hidden.
118
+ */
119
+ export const SETUP_ERRORS = new Set([400, 401, 403, 404, 422]);
120
+ /** Resolve with `work`, or reject with `onExpire()` after `ms`. A late `work` rejection is swallowed. */
121
+ export async function withDeadline(work, ms, onExpire) {
122
+ let timer;
123
+ const expired = new Promise((_, reject) => {
124
+ timer = setTimeout(() => reject(onExpire()), ms);
125
+ timer.unref();
126
+ });
127
+ try {
128
+ return await Promise.race([work, expired]);
129
+ }
130
+ finally {
131
+ clearTimeout(timer);
132
+ work.catch(() => { });
133
+ }
134
+ }
@@ -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.6.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",