@sandboxrouter/sdk 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/dist/cli.js +7 -5
- package/dist/client.d.ts +42 -14
- package/dist/client.js +39 -16
- package/dist/computers.d.ts +47 -4
- package/dist/computers.js +130 -17
- package/dist/gc.js +5 -2
- package/dist/index.d.ts +7 -4
- package/dist/index.js +6 -3
- package/dist/ledger.d.ts +11 -2
- package/dist/router.d.ts +40 -11
- package/dist/router.js +79 -60
- package/dist/server.d.ts +37 -7
- package/dist/server.js +195 -50
- package/dist/types.d.ts +28 -4
- package/dist/types.js +3 -1
- package/dist/util.d.ts +8 -0
- package/dist/util.js +23 -1
- package/dist/validate.d.ts +17 -0
- package/dist/validate.js +66 -0
- package/package.json +1 -1
package/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
|
|
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
|
-
|
|
96
|
-
configured: router.isConfigured(c.id),
|
|
97
|
-
|
|
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 } =
|
|
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 (
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
192
|
-
const
|
|
193
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
json(res, status,
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
};
|
package/dist/validate.js
ADDED
|
@@ -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.
|
|
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",
|