@sandstormapp/sdk 0.2.0 → 1.1.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/index.js CHANGED
@@ -1,84 +1,194 @@
1
- import { toolMethods } from "./openapi.js";
2
- export { toolMethods };
3
1
  export class SandstormError extends Error {
4
2
  name = 'SandstormError';
5
3
  status;
4
+ type;
6
5
  code;
7
- constructor(status, code, message) {
8
- super(message);
6
+ param;
7
+ requestId;
8
+ constructor(status, refusal) {
9
+ super(refusal.message);
9
10
  this.status = status;
10
- this.code = code;
11
+ this.type = refusal.type;
12
+ this.code = refusal.code;
13
+ this.param = refusal.param;
14
+ this.requestId = refusal.request_id;
11
15
  }
12
16
  }
13
- const BASE_URL = 'https://agent.sandstormapp.com';
14
- const POLL_MS = 2_000;
15
- const JSON_BODY = { 'content-type': 'application/json' };
16
- function refusalOf(text) {
17
- try {
18
- const body = JSON.parse(text);
19
- return typeof body.error === 'string' && typeof body.code === 'string' ? { error: body.error, code: body.code } : undefined;
20
- }
21
- catch {
22
- return undefined;
17
+ export class GenerationFailed extends Error {
18
+ name = 'GenerationFailed';
19
+ generation;
20
+ constructor(generation) {
21
+ super(generation.error?.message ?? `${generation.id} was ${generation.status}`);
22
+ this.generation = generation;
23
23
  }
24
24
  }
25
- async function refused(response) {
26
- const text = await response.text();
27
- const refusal = refusalOf(text);
28
- return refusal === undefined
29
- ? new SandstormError(response.status, undefined, text === '' ? response.statusText : text)
30
- : new SandstormError(response.status, refusal.code, refusal.error);
25
+ const BASE_URL = 'https://ads.dev';
26
+ const LONGEST_WAIT_S = 60;
27
+ const RUN_TIMEOUT_MS = 60 * 60_000;
28
+ const RETRYABLE = new Set([408, 429, 500, 502, 503, 504]);
29
+ const FINISHED = new Set(['succeeded', 'failed', 'canceled']);
30
+ export const isFinished = (generation) => FINISHED.has(generation.status);
31
+ const environment = (name) => globalThis.process?.env?.[name];
32
+ export class Page {
33
+ #read;
34
+ constructor(read) {
35
+ this.#read = read;
36
+ }
37
+ then(fulfilled, rejected) {
38
+ return this.#read(undefined).then(fulfilled, rejected);
39
+ }
40
+ async *[Symbol.asyncIterator]() {
41
+ for (let cursor;;) {
42
+ const page = await this.#read(cursor);
43
+ yield* page.data;
44
+ if (!page.has_more || page.next_cursor === null)
45
+ return;
46
+ cursor = page.next_cursor;
47
+ }
48
+ }
31
49
  }
32
- const finished = (task) => task.status !== 'running';
33
50
  export class Sandstorm {
34
- tools;
35
- tasks;
36
51
  #baseUrl;
37
52
  #headers;
38
- constructor({ apiKey, brand, baseUrl = BASE_URL }) {
39
- this.#baseUrl = baseUrl.replace(/\/$/, '');
40
- this.#headers = { authorization: `Bearer ${apiKey}`, ...(brand === undefined ? {} : { 'x-sandstorm-brand': brand }) };
41
- this.tools = Object.fromEntries(Object.entries(toolMethods).map(([name, method]) => [
42
- name,
43
- method === 'get'
44
- ? (query) => this.#read(name, query)
45
- : (input, options) => this.#call(name, input, options),
46
- ]));
47
- this.tasks = {
48
- list: () => this.#send('GET', '/v1/tasks'),
49
- get: (taskId) => this.#send('GET', `/v1/tasks/${taskId}`),
50
- stop: (taskId) => this.#send('POST', `/v1/tasks/${taskId}/stop`),
51
- wait: async (taskId) => {
52
- for (;;) {
53
- const task = await this.tasks.get(taskId);
54
- if (finished(task))
55
- return task;
56
- await new Promise((resolve) => setTimeout(resolve, POLL_MS));
57
- }
58
- },
59
- };
53
+ #maxRetries;
54
+ #timeoutMs;
55
+ #fetch;
56
+ constructor({ apiKey = environment('SANDSTORM_API_KEY'), baseUrl = environment('SANDSTORM_API_URL') ?? BASE_URL, brand, maxRetries = 2, timeoutMs = 60_000, fetch: fetcher = (input, init) => fetch(input, init), } = {}) {
57
+ this.#baseUrl = baseUrl.replace(/\/+$/, '');
58
+ if (apiKey === undefined || apiKey === '') {
59
+ throw new Error(`Pass apiKey or set SANDSTORM_API_KEY. To make a key: curl -fsSL ${this.#baseUrl}/login.mjs -o sandstorm-login.mjs && node sandstorm-login.mjs`);
60
+ }
61
+ this.#headers = { authorization: `Bearer ${apiKey}`, ...(brand === undefined ? {} : { 'sandstorm-brand': brand }) };
62
+ this.#maxRetries = maxRetries;
63
+ this.#timeoutMs = timeoutMs;
64
+ this.#fetch = fetcher;
60
65
  }
61
- catalogue() {
62
- return this.#send('GET', '/v1/tools');
66
+ generations = {
67
+ create: (model, input, { webhookUrl, wait, idempotencyKey = crypto.randomUUID() } = {}) => this.#send({
68
+ method: 'POST',
69
+ path: '/v1/generations',
70
+ body: { model, input, ...(webhookUrl === undefined ? {} : { webhook_url: webhookUrl }) },
71
+ ...(wait === undefined ? {} : { wait }),
72
+ idempotencyKey,
73
+ }),
74
+ get: (id, { wait } = {}) => this.#send({ method: 'GET', path: `/v1/generations/${encodeURIComponent(id)}`, ...(wait === undefined ? {} : { wait }) }),
75
+ wait: async (id, { timeoutMs = RUN_TIMEOUT_MS } = {}) => {
76
+ const deadline = Date.now() + timeoutMs;
77
+ for (;;) {
78
+ const left = Math.ceil((deadline - Date.now()) / 1000);
79
+ const generation = await this.generations.get(id, { wait: Math.max(1, Math.min(LONGEST_WAIT_S, left)) });
80
+ if (isFinished(generation))
81
+ return generation;
82
+ if (left <= 0)
83
+ throw new Error(`${id} did not finish within ${timeoutMs} ms; it is still ${generation.status}`);
84
+ }
85
+ },
86
+ run: async (model, input, { timeoutMs, ...options } = {}) => {
87
+ const created = await this.generations.create(model, input, { ...options, wait: LONGEST_WAIT_S });
88
+ const generation = isFinished(created) ? created : await this.generations.wait(created.id, timeoutMs === undefined ? {} : { timeoutMs });
89
+ if (generation.status !== 'succeeded')
90
+ throw new GenerationFailed(generation);
91
+ return generation;
92
+ },
93
+ cancel: (id) => this.#send({ method: 'POST', path: `/v1/generations/${encodeURIComponent(id)}/cancel` }),
94
+ list: (params = {}) => this.#list('/v1/generations', params),
95
+ };
96
+ models = {
97
+ list: (params = {}) => this.#list('/v1/models', params),
98
+ get: (id) => this.#send({ method: 'GET', path: `/v1/models/${encodeURIComponent(id)}` }),
99
+ };
100
+ assets = {
101
+ list: (params = {}) => this.#list('/v1/assets', params),
102
+ get: (id) => this.#send({ method: 'GET', path: `/v1/assets/${encodeURIComponent(id)}` }),
103
+ import: (url, { name, idempotencyKey = crypto.randomUUID() } = {}) => this.#send({ method: 'POST', path: '/v1/assets', body: { url, ...(name === undefined ? {} : { name }) }, idempotencyKey }),
104
+ };
105
+ brand = {
106
+ get: () => this.#send({ method: 'GET', path: '/v1/brand' }),
107
+ };
108
+ offers = {
109
+ list: (params = {}) => this.#list('/v1/offers', params),
110
+ get: (id) => this.#send({ method: 'GET', path: `/v1/offers/${encodeURIComponent(id)}` }),
111
+ };
112
+ characters = {
113
+ list: (params = {}) => this.#list('/v1/characters', params),
114
+ };
115
+ voices = {
116
+ list: (params = {}) => this.#list('/v1/voices', params),
117
+ };
118
+ advertisers = {
119
+ list: (params) => this.#list('/v1/advertisers', params),
120
+ };
121
+ ads = {
122
+ list: (params = {}) => this.#list('/v1/ads', params),
123
+ get: (id) => this.#send({ method: 'GET', path: `/v1/ads/${encodeURIComponent(id)}` }),
124
+ };
125
+ webhooks = {
126
+ secret: async () => (await this.#send({ method: 'GET', path: '/v1/webhooks/secret' })).secret,
127
+ };
128
+ #list(path, params) {
129
+ return new Page((cursor) => this.#send({ method: 'GET', path, query: cursor === undefined ? params : { ...params, cursor } }));
63
130
  }
64
- #read(name, query = {}) {
65
- const search = new URLSearchParams();
66
- for (const [key, value] of Object.entries(query)) {
67
- for (const each of Array.isArray(value) ? value : [value])
68
- if (each !== undefined)
69
- search.append(key, String(each));
131
+ async #send(request) {
132
+ const url = new URL(this.#baseUrl + request.path);
133
+ for (const [name, value] of Object.entries(request.query ?? {}))
134
+ if (value !== undefined)
135
+ url.searchParams.set(name, String(value));
136
+ const headers = {
137
+ ...this.#headers,
138
+ ...(request.body === undefined ? {} : { 'content-type': 'application/json' }),
139
+ ...(request.wait === undefined ? {} : { prefer: `wait=${request.wait}` }),
140
+ ...(request.idempotencyKey === undefined ? {} : { 'idempotency-key': request.idempotencyKey }),
141
+ };
142
+ const body = request.body === undefined ? null : JSON.stringify(request.body);
143
+ const send = this.#fetch;
144
+ for (let attempt = 0;; attempt++) {
145
+ const timeout = AbortSignal.timeout(this.#timeoutMs + (request.wait ?? 0) * 1000);
146
+ const answered = await send(url, { method: request.method, headers, body, signal: timeout }).catch((error) => error instanceof Error ? error : new Error(String(error)));
147
+ if (answered instanceof Response && answered.ok && isJson(answered))
148
+ return (await answered.json());
149
+ if (answered instanceof Response && answered.ok)
150
+ throw await notTheApi(answered, url.origin);
151
+ const retryable = answered instanceof Error || RETRYABLE.has(answered.status);
152
+ if (!retryable || attempt >= this.#maxRetries)
153
+ throw answered instanceof Error ? answered : await refusalOf(answered, url.origin);
154
+ await new Promise((resolve) => setTimeout(resolve, delayOf(attempt, answered instanceof Response ? answered : undefined)));
70
155
  }
71
- const asked = search.toString();
72
- return this.#send('GET', `/v1/tools/${name}${asked === '' ? '' : `?${asked}`}`);
73
- }
74
- #call(name, input, options) {
75
- const headers = options === undefined ? JSON_BODY : { ...JSON_BODY, prefer: `wait=${options.wait}` };
76
- return this.#send('POST', `/v1/tools/${name}`, headers, JSON.stringify(input));
77
156
  }
78
- async #send(method, path, headers = {}, body = null) {
79
- const response = await fetch(this.#baseUrl + path, { method, headers: { ...this.#headers, ...headers }, body });
80
- if (!response.ok)
81
- throw await refused(response);
82
- return response.json();
157
+ }
158
+ function delayOf(attempt, response) {
159
+ const after = Number(response?.headers.get('retry-after'));
160
+ if (Number.isFinite(after) && after > 0)
161
+ return after * 1000;
162
+ return Math.min(8_000, 500 * 2 ** attempt) * (0.75 + Math.random() / 2);
163
+ }
164
+ const isJson = (response) => response.headers.get('content-type')?.includes('json') === true;
165
+ async function notTheApi(response, origin) {
166
+ await response.body?.cancel();
167
+ return new SandstormError(response.status, {
168
+ type: 'api_error',
169
+ code: 'not_the_api',
170
+ message: `${origin} answered ${response.status} with ${response.headers.get('content-type') ?? 'no content type'} instead of the API's JSON. If ${origin} is not the Sandstorm API, pass baseUrl or set SANDSTORM_API_URL.`,
171
+ });
172
+ }
173
+ async function refusalOf(response, origin) {
174
+ if (!isJson(response))
175
+ return notTheApi(response, origin);
176
+ return new SandstormError(response.status, (await response.json()).error);
177
+ }
178
+ const HEX = /^[0-9a-f]{64}$/;
179
+ export async function verifyWebhook({ secret, body, signature, toleranceSeconds = 300, now = Date.now() }) {
180
+ const parts = new Map((signature ?? '').split(',').map((part) => [part.slice(0, part.indexOf('=')), part.slice(part.indexOf('=') + 1)]));
181
+ const timestamp = Number(parts.get('t'));
182
+ const given = parts.get('v1') ?? '';
183
+ if (!Number.isInteger(timestamp) || !HEX.test(given))
184
+ throw new Error('The Sandstorm-Signature header is missing or malformed.');
185
+ if (Math.abs(now / 1000 - timestamp) > toleranceSeconds)
186
+ throw new Error('The webhook is older than the tolerance; refuse it as a possible replay.');
187
+ const encoder = new TextEncoder();
188
+ const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
189
+ const bytes = Uint8Array.from(given.match(/../g) ?? [], (pair) => Number.parseInt(pair, 16));
190
+ if (!(await crypto.subtle.verify('HMAC', key, bytes, encoder.encode(`${timestamp}.${body}`)))) {
191
+ throw new Error('The webhook signature does not match; it was not sent by Sandstorm with this secret.');
83
192
  }
193
+ return JSON.parse(body);
84
194
  }