@the-open-engine/zeroshot 6.25.1 → 6.27.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.
Files changed (35) hide show
  1. package/lib/cluster/client.cjs +30 -5
  2. package/lib/cluster/client.d.ts +11 -1
  3. package/lib/cluster/client.mjs +29 -5
  4. package/lib/cluster/connection.cjs +38 -2
  5. package/lib/cluster/connection.d.ts +3 -0
  6. package/lib/cluster/connection.mjs +37 -1
  7. package/lib/cluster/index.cjs +3 -1
  8. package/lib/cluster/index.d.ts +3 -3
  9. package/lib/cluster/index.mjs +2 -2
  10. package/lib/hosted-session/coordinator.cjs +101 -0
  11. package/lib/hosted-session/coordinator.d.ts +9 -0
  12. package/lib/hosted-session/coordinator.mjs +97 -0
  13. package/lib/hosted-session/index.cjs +5 -0
  14. package/lib/hosted-session/index.d.ts +2 -0
  15. package/lib/hosted-session/index.mjs +1 -0
  16. package/lib/hosted-session/types.cjs +2 -0
  17. package/lib/hosted-session/types.d.ts +20 -0
  18. package/lib/hosted-session/types.mjs +1 -0
  19. package/package.json +14 -5
  20. package/scripts/build-cluster.js +21 -7
  21. package/src/cluster/client.ts +45 -5
  22. package/src/cluster/connection.ts +32 -1
  23. package/src/cluster/index.ts +4 -2
  24. package/src/cluster/ws.d.ts +1 -0
  25. package/src/hosted-session/coordinator.ts +110 -0
  26. package/src/hosted-session/index.ts +2 -0
  27. package/src/hosted-session/types.ts +21 -0
  28. package/src/hosted-target/bounds.ts +6 -0
  29. package/src/hosted-target/errors.ts +90 -0
  30. package/src/hosted-target/index.ts +44 -0
  31. package/src/hosted-target/response-validation.ts +86 -0
  32. package/src/hosted-target/retry.ts +46 -0
  33. package/src/hosted-target/target-adapter.ts +10 -0
  34. package/src/hosted-target/types.ts +58 -0
  35. package/src/hosted-target/zero-cloud-v1-adapter.ts +386 -0
@@ -0,0 +1,46 @@
1
+ import { MAX_RETRY_ATTEMPTS, MAX_RETRY_ELAPSED_MS } from './bounds.ts';
2
+ import type { TargetAdapterError } from './errors.ts';
3
+ import { TargetAuthError, TargetRateLimitError } from './errors.ts';
4
+ import type { Clock, RetryPolicy } from './types.ts';
5
+
6
+ export class DefaultRetryPolicy implements RetryPolicy {
7
+ shouldRetry(
8
+ attempt: number,
9
+ elapsed: number,
10
+ error: TargetAdapterError,
11
+ ): { retry: boolean; delayMs: number } {
12
+ if (error instanceof TargetAuthError) return { retry: false, delayMs: 0 };
13
+ if (!error.retryable) return { retry: false, delayMs: 0 };
14
+ if (attempt >= MAX_RETRY_ATTEMPTS) return { retry: false, delayMs: 0 };
15
+ if (elapsed >= MAX_RETRY_ELAPSED_MS) return { retry: false, delayMs: 0 };
16
+
17
+ const requestedDelay =
18
+ error instanceof TargetRateLimitError && error.retryAfterMs !== undefined
19
+ ? Math.max(0, error.retryAfterMs)
20
+ : Math.min(1000 * Math.pow(2, attempt), 10_000);
21
+ const remaining = MAX_RETRY_ELAPSED_MS - elapsed;
22
+ if (!Number.isFinite(requestedDelay) || requestedDelay >= remaining) {
23
+ return { retry: false, delayMs: 0 };
24
+ }
25
+
26
+ return { retry: true, delayMs: requestedDelay };
27
+ }
28
+ }
29
+
30
+ export function parseRetryAfter(header: string | null, clock: Clock): number | null {
31
+ if (header === null) return null;
32
+
33
+ const trimmed = header.trim();
34
+ if (trimmed.length === 0) return null;
35
+
36
+ const numericSeconds = Number(trimmed);
37
+ if (Number.isFinite(numericSeconds) && numericSeconds >= 0) {
38
+ return Math.ceil(numericSeconds * 1000);
39
+ }
40
+
41
+ const date = Date.parse(trimmed);
42
+ if (Number.isNaN(date)) return null;
43
+
44
+ const delayMs = date - clock.now();
45
+ return delayMs > 0 ? Math.ceil(delayMs) : 0;
46
+ }
@@ -0,0 +1,10 @@
1
+ import type { AllocateRequest, Capsule, CapsuleAccess, CapsuleLimits, CapsuleListPage } from './types.ts';
2
+
3
+ export interface TargetAdapter {
4
+ allocate(req: AllocateRequest, signal?: AbortSignal): Promise<Capsule>;
5
+ list(cursor?: string, signal?: AbortSignal): Promise<CapsuleListPage>;
6
+ inspect(capsuleId: string, signal?: AbortSignal): Promise<Capsule>;
7
+ terminate(capsuleId: string, signal?: AbortSignal): Promise<void>;
8
+ limits(signal?: AbortSignal): Promise<CapsuleLimits>;
9
+ access(capsuleId: string, signal?: AbortSignal): Promise<CapsuleAccess>;
10
+ }
@@ -0,0 +1,58 @@
1
+ import type { TargetAdapterError } from './errors.ts';
2
+
3
+ export interface TargetAccessTokenProvider {
4
+ getAccessToken(signal?: AbortSignal): Promise<string>;
5
+ }
6
+
7
+ export type CapsuleState = 'provisioning' | 'running' | 'stopping' | 'terminated' | 'failed' | (string & {});
8
+
9
+ export const KNOWN_CAPSULE_STATES = ['provisioning', 'running', 'stopping', 'terminated', 'failed'] as const;
10
+
11
+ export interface Capsule {
12
+ readonly id: string;
13
+ readonly state: CapsuleState;
14
+ readonly createdAt: string;
15
+ readonly [key: string]: unknown;
16
+ }
17
+
18
+ export interface CapsuleAccess {
19
+ readonly endpoint: string;
20
+ readonly token: string;
21
+ readonly expiresAt: string;
22
+ }
23
+
24
+ export interface CapsuleListPage {
25
+ readonly items: readonly Capsule[];
26
+ readonly cursor?: string;
27
+ }
28
+
29
+ export interface CapsuleLimits {
30
+ readonly maxConcurrent: number;
31
+ readonly maxPerHour: number;
32
+ readonly [key: string]: unknown;
33
+ }
34
+
35
+ export interface AllocateRequest {
36
+ readonly idempotencyKey: string;
37
+ readonly profile: string;
38
+ }
39
+
40
+ export interface HttpTransport {
41
+ fetch(url: string, init: RequestInit & { redirect: 'error' }): Promise<Response>;
42
+ }
43
+
44
+ export interface Clock {
45
+ now(): number;
46
+ }
47
+
48
+ export interface RetryPolicy {
49
+ shouldRetry(
50
+ attempt: number,
51
+ elapsed: number,
52
+ error: TargetAdapterError,
53
+ ): { retry: boolean; delayMs: number };
54
+ }
55
+
56
+ export interface TargetDiscovery {
57
+ readonly capsuleV1: string;
58
+ }
@@ -0,0 +1,386 @@
1
+ import { MAX_ERROR_BODY_BYTES, MAX_RESPONSE_BYTES, MAX_RETRY_ELAPSED_MS, IDEMPOTENCY_KEY_PATTERN } from './bounds.ts';
2
+ import {
3
+ TargetAuthError,
4
+ TargetConflictError,
5
+ TargetNotFoundError,
6
+ TargetProtocolError,
7
+ TargetRateLimitError,
8
+ TargetTransportError,
9
+ } from './errors.ts';
10
+ import { TargetAdapterError } from './errors.ts';
11
+ import { DefaultRetryPolicy, parseRetryAfter } from './retry.ts';
12
+ import {
13
+ assertCapsule,
14
+ assertCapsuleAccess,
15
+ assertCapsuleLimits,
16
+ assertCapsuleListPage,
17
+ } from './response-validation.ts';
18
+ import type { TargetAdapter } from './target-adapter.ts';
19
+ import type {
20
+ AllocateRequest,
21
+ Capsule,
22
+ CapsuleAccess,
23
+ CapsuleLimits,
24
+ CapsuleListPage,
25
+ Clock,
26
+ HttpTransport,
27
+ RetryPolicy,
28
+ TargetAccessTokenProvider,
29
+ TargetDiscovery,
30
+ } from './types.ts';
31
+
32
+ interface ZeroCloudV1Options {
33
+ readonly discovery: TargetDiscovery;
34
+ readonly organization: string;
35
+ readonly tokenProvider: TargetAccessTokenProvider;
36
+ readonly transport?: HttpTransport;
37
+ readonly clock?: Clock;
38
+ readonly retryPolicy?: RetryPolicy;
39
+ }
40
+
41
+ const DEFAULT_TRANSPORT: HttpTransport = {
42
+ fetch(url: string, init: RequestInit & { redirect: 'error' }): Promise<Response> {
43
+ return globalThis.fetch(url, init);
44
+ },
45
+ };
46
+
47
+ const DEFAULT_CLOCK: Clock = { now: () => Date.now() };
48
+
49
+ function throwIfAborted(signal?: AbortSignal): void {
50
+ if (signal?.aborted) {
51
+ throw signal.reason === undefined
52
+ ? new DOMException('The operation was aborted', 'AbortError')
53
+ : signal.reason;
54
+ }
55
+ }
56
+
57
+ async function waitForRetryDelay(delayMs: number, signal?: AbortSignal): Promise<void> {
58
+ throwIfAborted(signal);
59
+ if (delayMs <= 0) return;
60
+
61
+ await new Promise<void>((resolve, reject) => {
62
+ const onAbort = (): void => {
63
+ clearTimeout(timer);
64
+ reject(
65
+ signal!.reason === undefined
66
+ ? new DOMException('The operation was aborted', 'AbortError')
67
+ : signal!.reason,
68
+ );
69
+ };
70
+ const timer = setTimeout(() => {
71
+ signal?.removeEventListener('abort', onAbort);
72
+ resolve();
73
+ }, delayMs);
74
+ signal?.addEventListener('abort', onAbort, { once: true });
75
+ });
76
+ }
77
+
78
+ function originOf(url: string): string {
79
+ try {
80
+ const u = new URL(url);
81
+ return u.origin;
82
+ } catch {
83
+ throw new TargetProtocolError(`Invalid URL: ${url}`);
84
+ }
85
+ }
86
+
87
+ export class ZeroCloudV1TargetAdapter implements TargetAdapter {
88
+ private readonly discovery: TargetDiscovery;
89
+ private readonly organization: string;
90
+ private readonly tokenProvider: TargetAccessTokenProvider;
91
+ private readonly transport: HttpTransport;
92
+ private readonly clock: Clock;
93
+ private readonly retryPolicy: RetryPolicy;
94
+ private readonly expectedOrigin: string;
95
+
96
+ constructor(opts: ZeroCloudV1Options) {
97
+ this.discovery = opts.discovery;
98
+ this.organization = opts.organization;
99
+ this.tokenProvider = opts.tokenProvider;
100
+ this.transport = opts.transport ?? DEFAULT_TRANSPORT;
101
+ this.clock = opts.clock ?? DEFAULT_CLOCK;
102
+ this.retryPolicy = opts.retryPolicy ?? new DefaultRetryPolicy();
103
+ this.expectedOrigin = originOf(opts.discovery.capsuleV1);
104
+ }
105
+
106
+ async allocate(req: AllocateRequest, signal?: AbortSignal): Promise<Capsule> {
107
+ if (!IDEMPOTENCY_KEY_PATTERN.test(req.idempotencyKey)) {
108
+ throw new TargetProtocolError(
109
+ `Invalid idempotency key: must match ${IDEMPOTENCY_KEY_PATTERN}`,
110
+ );
111
+ }
112
+
113
+ const body = JSON.stringify({ profile: req.profile, organization: this.organization });
114
+
115
+ return this._withRetry(async () => {
116
+ const resp = await this._request('POST', '/capsules', {
117
+ signal,
118
+ body,
119
+ headers: { 'Idempotency-Key': req.idempotencyKey },
120
+ });
121
+ const json = await this._readJson(resp);
122
+ return assertCapsule(json);
123
+ }, signal);
124
+ }
125
+
126
+ async list(cursor?: string, signal?: AbortSignal): Promise<CapsuleListPage> {
127
+ return this._withRetry(async () => {
128
+ const params = new URLSearchParams({ organization: this.organization });
129
+ if (cursor) params.set('cursor', cursor);
130
+ const resp = await this._request('GET', `/capsules?${params.toString()}`, { signal });
131
+ const json = await this._readJson(resp);
132
+ const page = assertCapsuleListPage(json);
133
+ if (page.cursor !== undefined && page.cursor === cursor) {
134
+ throw new TargetProtocolError(
135
+ `Pagination loop detected: server returned the same cursor "${cursor}"`,
136
+ );
137
+ }
138
+ return page;
139
+ }, signal);
140
+ }
141
+
142
+ async inspect(capsuleId: string, signal?: AbortSignal): Promise<Capsule> {
143
+ return this._withRetry(async () => {
144
+ const resp = await this._request('GET', `/capsules/${encodeURIComponent(capsuleId)}`, {
145
+ signal,
146
+ });
147
+ const json = await this._readJson(resp);
148
+ return assertCapsule(json);
149
+ }, signal);
150
+ }
151
+
152
+ async terminate(capsuleId: string, signal?: AbortSignal): Promise<void> {
153
+ await this._withRetry(async () => {
154
+ const resp = await this._request(
155
+ 'DELETE',
156
+ `/capsules/${encodeURIComponent(capsuleId)}`,
157
+ { signal },
158
+ );
159
+ if (resp.status !== 204) {
160
+ const json = await this._readJson(resp);
161
+ throw new TargetProtocolError(`Unexpected terminate response: ${JSON.stringify(json)}`);
162
+ }
163
+ }, signal);
164
+ }
165
+
166
+ async limits(signal?: AbortSignal): Promise<CapsuleLimits> {
167
+ return this._withRetry(async () => {
168
+ const params = new URLSearchParams({ organization: this.organization });
169
+ const resp = await this._request('GET', `/limits?${params.toString()}`, { signal });
170
+ const json = await this._readJson(resp);
171
+ return assertCapsuleLimits(json);
172
+ }, signal);
173
+ }
174
+
175
+ async access(capsuleId: string, signal?: AbortSignal): Promise<CapsuleAccess> {
176
+ return this._withRetry(async () => {
177
+ const resp = await this._request(
178
+ 'POST',
179
+ `/capsules/${encodeURIComponent(capsuleId)}/access`,
180
+ { signal },
181
+ );
182
+ const json = await this._readJson(resp);
183
+ return assertCapsuleAccess(json);
184
+ }, signal);
185
+ }
186
+
187
+ private async _request(
188
+ method: string,
189
+ path: string,
190
+ opts: { signal?: AbortSignal | undefined; body?: string | undefined; headers?: Record<string, string> | undefined },
191
+ ): Promise<Response> {
192
+ const url = `${this.discovery.capsuleV1}${path}`;
193
+
194
+ const responseOrigin = originOf(url);
195
+ if (responseOrigin !== this.expectedOrigin) {
196
+ throw new TargetProtocolError(
197
+ `Origin mismatch: expected ${this.expectedOrigin}, got ${responseOrigin}`,
198
+ );
199
+ }
200
+
201
+ let token: string;
202
+ try {
203
+ token = await this.tokenProvider.getAccessToken(opts.signal);
204
+ } catch (err) {
205
+ throw new TargetTransportError('Failed to acquire access token', err);
206
+ }
207
+
208
+ const headers: Record<string, string> = {
209
+ 'Authorization': `Bearer ${token}`,
210
+ 'Content-Type': 'application/json',
211
+ 'Accept': 'application/json',
212
+ ...opts.headers,
213
+ };
214
+
215
+ let response: Response;
216
+ const fetchInit: RequestInit & { redirect: 'error' } = {
217
+ method,
218
+ headers,
219
+ redirect: 'error',
220
+ };
221
+ if (opts.body !== undefined) fetchInit.body = opts.body;
222
+ if (opts.signal !== undefined) fetchInit.signal = opts.signal;
223
+
224
+ try {
225
+ response = await this.transport.fetch(url, fetchInit);
226
+ } catch (err) {
227
+ if (err instanceof TargetAdapterError) throw err;
228
+ const msg = err instanceof Error ? err.message : String(err);
229
+ if (msg.includes('redirect')) {
230
+ throw new TargetProtocolError(`Redirect rejected for ${method} ${path}`, err);
231
+ }
232
+ throw new TargetTransportError(`Network error during ${method} ${path}`, err);
233
+ }
234
+
235
+ if (response.status < 200 || response.status >= 300) {
236
+ await this._mapStatusError(response, method, path, opts.headers);
237
+ }
238
+
239
+ return response;
240
+ }
241
+
242
+ private async _mapStatusError(
243
+ response: Response,
244
+ method: string,
245
+ path: string,
246
+ headers?: Record<string, string>,
247
+ ): Promise<never> {
248
+ const status = response.status;
249
+ const errorBody = await this._readErrorBody(response);
250
+ const context = `${status} ${method} ${path}: ${errorBody}`;
251
+
252
+ if (status === 401 || status === 403) throw new TargetAuthError(context);
253
+ if (status === 404) throw new TargetNotFoundError(context);
254
+ if (status === 409) {
255
+ const idempotencyKey = headers?.['Idempotency-Key'] ?? 'unknown';
256
+ throw new TargetConflictError(idempotencyKey, context);
257
+ }
258
+ if (status === 429) {
259
+ const retryAfterMs = parseRetryAfter(response.headers.get('Retry-After'), this.clock);
260
+ throw new TargetRateLimitError(context, retryAfterMs ?? undefined);
261
+ }
262
+ if (status >= 500) throw new TargetTransportError(context);
263
+ throw new TargetProtocolError(`Unexpected status ${context}`);
264
+ }
265
+
266
+ private async _readJson(response: Response): Promise<unknown> {
267
+ const contentLength = response.headers.get('Content-Length');
268
+ if (contentLength !== null) {
269
+ const len = parseInt(contentLength, 10);
270
+ if (Number.isFinite(len) && len > MAX_RESPONSE_BYTES) {
271
+ throw new TargetProtocolError(
272
+ `Response too large: ${len} bytes exceeds limit of ${MAX_RESPONSE_BYTES}`,
273
+ );
274
+ }
275
+ }
276
+
277
+ let text: string;
278
+ try {
279
+ const reader = response.body?.getReader();
280
+ if (!reader) {
281
+ text = await response.text();
282
+ } else {
283
+ const chunks: Uint8Array[] = [];
284
+ let totalBytes = 0;
285
+ while (true) {
286
+ const { done, value } = await reader.read();
287
+ if (done) break;
288
+ totalBytes += value.byteLength;
289
+ if (totalBytes > MAX_RESPONSE_BYTES) {
290
+ reader.cancel();
291
+ throw new TargetProtocolError(
292
+ `Response body exceeds limit of ${MAX_RESPONSE_BYTES} bytes`,
293
+ );
294
+ }
295
+ chunks.push(value);
296
+ }
297
+ const combined = new Uint8Array(totalBytes);
298
+ let offset = 0;
299
+ for (const chunk of chunks) {
300
+ combined.set(chunk, offset);
301
+ offset += chunk.byteLength;
302
+ }
303
+ text = new TextDecoder().decode(combined);
304
+ }
305
+ } catch (err) {
306
+ if (err instanceof TargetProtocolError) throw err;
307
+ throw new TargetProtocolError('Failed to read response body', err);
308
+ }
309
+
310
+ try {
311
+ return JSON.parse(text);
312
+ } catch (err) {
313
+ throw new TargetProtocolError('Invalid JSON in response body', err);
314
+ }
315
+ }
316
+
317
+ private async _readErrorBody(response: Response): Promise<string> {
318
+ const reader = response.body?.getReader();
319
+ if (!reader) return '';
320
+
321
+ const chunks: Uint8Array[] = [];
322
+ let totalBytes = 0;
323
+ try {
324
+ while (totalBytes < MAX_ERROR_BODY_BYTES) {
325
+ const { done, value } = await reader.read();
326
+ if (done) break;
327
+
328
+ const remaining = MAX_ERROR_BODY_BYTES - totalBytes;
329
+ const retained = value.byteLength > remaining ? value.slice(0, remaining) : value;
330
+ chunks.push(retained);
331
+ totalBytes += retained.byteLength;
332
+ if (retained.byteLength !== value.byteLength || totalBytes === MAX_ERROR_BODY_BYTES) {
333
+ void reader.cancel().catch(() => undefined);
334
+ break;
335
+ }
336
+ }
337
+ } catch {
338
+ void reader.cancel().catch(() => undefined);
339
+ return '<unreadable>';
340
+ }
341
+
342
+ const combined = new Uint8Array(totalBytes);
343
+ let offset = 0;
344
+ for (const chunk of chunks) {
345
+ combined.set(chunk, offset);
346
+ offset += chunk.byteLength;
347
+ }
348
+ return new TextDecoder().decode(combined);
349
+ }
350
+
351
+ private async _withRetry<T>(
352
+ fn: () => Promise<T>,
353
+ signal?: AbortSignal,
354
+ ): Promise<T> {
355
+ const startTime = this.clock.now();
356
+ let attempt = 0;
357
+
358
+ while (true) {
359
+ throwIfAborted(signal);
360
+ try {
361
+ return await fn();
362
+ } catch (err) {
363
+ throwIfAborted(signal);
364
+ if (!(err instanceof TargetAdapterError)) throw err;
365
+ if (err instanceof TargetAuthError) throw err;
366
+
367
+ attempt++;
368
+ const elapsed = this.clock.now() - startTime;
369
+ const decision = this.retryPolicy.shouldRetry(attempt, elapsed, err);
370
+ const remaining = MAX_RETRY_ELAPSED_MS - elapsed;
371
+
372
+ if (
373
+ !decision.retry ||
374
+ !Number.isFinite(decision.delayMs) ||
375
+ decision.delayMs < 0 ||
376
+ decision.delayMs >= remaining
377
+ ) {
378
+ throw err;
379
+ }
380
+
381
+ await waitForRetryDelay(decision.delayMs, signal);
382
+ if (this.clock.now() - startTime >= MAX_RETRY_ELAPSED_MS) throw err;
383
+ }
384
+ }
385
+ }
386
+ }