@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/README.md +4 -3
- package/dist/bench-store.d.ts +2 -1
- package/dist/bench-store.js +6 -3
- package/dist/billing.d.ts +5 -0
- package/dist/billing.js +3 -0
- package/dist/check.d.ts +5 -0
- package/dist/check.js +12 -1
- package/dist/cli.js +19 -6
- package/dist/client.d.ts +33 -11
- package/dist/client.js +29 -13
- package/dist/cost.d.ts +11 -0
- package/dist/cost.js +43 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.js +5 -2
- package/dist/ledger.d.ts +8 -1
- package/dist/router.d.ts +71 -5
- package/dist/router.js +136 -39
- package/dist/server.d.ts +21 -7
- package/dist/server.js +72 -30
- package/dist/types.d.ts +37 -9
- package/dist/types.js +3 -1
- package/dist/util.js +2 -1
- package/dist/validate.d.ts +17 -0
- package/dist/validate.js +66 -0
- package/dist/verified.d.ts +2 -0
- package/dist/verified.js +2 -0
- package/package.json +1 -1
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 {
|
|
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
|
|
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(
|
|
133
|
-
|
|
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 =
|
|
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.
|
|
145
|
-
//
|
|
146
|
-
// An explicit
|
|
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
|
|
149
|
-
|
|
150
|
-
|
|
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 (
|
|
153
|
-
list.sort(
|
|
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
|
-
|
|
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,
|
|
249
|
+
const box = await withDeadline(pending, waitS * 1000, () => {
|
|
204
250
|
timedOut = true;
|
|
205
|
-
return new ProviderError(c.provider, `create timed out after ${
|
|
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
|
-
|
|
209
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
240
|
-
const sbx = new RoutedSandbox(provider, box, [],
|
|
241
|
-
this.record(sbx,
|
|
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
|
-
|
|
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,
|
|
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
|
-
/**
|
|
16
|
-
|
|
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
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
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
|
-
|
|
96
|
-
configured: router.isConfigured(c.id),
|
|
97
|
-
|
|
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 } =
|
|
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 (
|
|
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 } =
|
|
117
|
+
const { spec, route } = splitRequest(await readJson(req));
|
|
116
118
|
await hooks.beforeCreate?.(caller, spec, route);
|
|
117
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
json(res, status,
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
/**
|
|
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;
|
|
@@ -22,18 +27,27 @@ export interface SandboxSpec {
|
|
|
22
27
|
metadata?: Record<string, string>;
|
|
23
28
|
}
|
|
24
29
|
/**
|
|
25
|
-
* "
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|