xapi-to 0.1.19 → 0.1.21
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 +249 -1
- package/dist/chunk-UEQCIJ7T.js +922 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1802 -726
- package/dist/openai-sandbox-client.d.ts +85 -0
- package/dist/openai-sandbox-client.js +285 -0
- package/examples/openai-agents-sandbox-local.ts +131 -0
- package/examples/sandbox-api-cli-openai.mjs +450 -0
- package/package.json +25 -3
- package/scripts/openai-sandbox-agent-e2e.ts +219 -0
- package/scripts/sandbox-playground-e2e.mjs +463 -0
- package/skills/xapi/SKILL.md +19 -7
- package/skills/xapi/guides/linkedin.md +55 -0
- package/skills/xapi/guides/provider.md +198 -0
- package/skills/xapi/guides/sandbox.md +520 -0
- package/skills/xapi/guides/serper.md +124 -0
- package/src/client.ts +715 -0
- package/src/config.ts +160 -0
- package/src/openai-sandbox-client.ts +349 -0
- package/src/sandbox-client.ts +309 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP client - thin wrapper around fetch with timeout/retry
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { scheme, assertAllowedHost } from './config.ts';
|
|
6
|
+
import { open, rm } from 'node:fs/promises';
|
|
7
|
+
import { once } from 'node:events';
|
|
8
|
+
import { resolve } from 'node:path';
|
|
9
|
+
import { Readable, Transform } from 'node:stream';
|
|
10
|
+
import { pipeline } from 'node:stream/promises';
|
|
11
|
+
|
|
12
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
13
|
+
const EXECUTE_TIMEOUT_MS = 60_000;
|
|
14
|
+
const TRANSFER_IDLE_TIMEOUT_MS = 60_000;
|
|
15
|
+
|
|
16
|
+
// Retry policy for transient failures (exponential backoff with jitter).
|
|
17
|
+
// request() defaults to 0 retries (fail-safe): retrying a non-idempotent write
|
|
18
|
+
// after a lost response can duplicate a tweet/payment or turn a successful DELETE
|
|
19
|
+
// into a spurious 404. Only call sites known to be idempotent (GET reads) opt in.
|
|
20
|
+
const IDEMPOTENT_RETRIES = 2; // up to 3 attempts total
|
|
21
|
+
const RETRY_BASE_DELAY_MS = 500;
|
|
22
|
+
const RETRY_MAX_DELAY_MS = 8_000;
|
|
23
|
+
|
|
24
|
+
export interface ClientOptions {
|
|
25
|
+
actionHost: string;
|
|
26
|
+
apiKey?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ApiKeyApiRequestOptions {
|
|
30
|
+
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
31
|
+
body?: unknown;
|
|
32
|
+
timeoutMs?: number;
|
|
33
|
+
retries?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class HttpError extends Error {
|
|
37
|
+
constructor(
|
|
38
|
+
public readonly status: number,
|
|
39
|
+
detail: string,
|
|
40
|
+
public readonly retryAfterMs?: number,
|
|
41
|
+
) {
|
|
42
|
+
super(`HTTP ${status}: ${detail}`);
|
|
43
|
+
this.name = 'HttpError';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class RequestTimeoutError extends Error {
|
|
48
|
+
constructor(public readonly timeoutMs: number) {
|
|
49
|
+
super(`request timed out after ${timeoutMs}ms`);
|
|
50
|
+
this.name = 'RequestTimeoutError';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Retryable HTTP statuses. Deliberately conservative: 429 (rate limited — the
|
|
56
|
+
* request was rejected, not processed) and 502/503/504 (gateway/availability
|
|
57
|
+
* errors — the request almost certainly never reached the upstream). 500/501 are
|
|
58
|
+
* excluded because a non-idempotent action (e.g. posting a tweet) may already
|
|
59
|
+
* have taken effect, so a blind retry could duplicate it.
|
|
60
|
+
*/
|
|
61
|
+
function isRetryableStatus(status: number): boolean {
|
|
62
|
+
return status === 408 || status === 429 || status === 502 || status === 503 || status === 504;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Connection-level failures worth retrying (DNS/reset/refused). Excludes our own timeouts. */
|
|
66
|
+
function isRetryableNetworkError(e: unknown): boolean {
|
|
67
|
+
if (!(e instanceof Error)) return false;
|
|
68
|
+
if (e instanceof HttpError || e instanceof RequestTimeoutError) return false;
|
|
69
|
+
if (e.name === 'AbortError') return false;
|
|
70
|
+
return e instanceof TypeError || /network|fetch failed|econn|etimedout|eai_again|socket|dns/i.test(e.message);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Whether an idempotent caller may safely try the request again. */
|
|
74
|
+
export function isRetryableRequestError(e: unknown): boolean {
|
|
75
|
+
if (e instanceof HttpError) return isRetryableStatus(e.status);
|
|
76
|
+
if (e instanceof RequestTimeoutError) return true;
|
|
77
|
+
return isRetryableNetworkError(e);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function retryBaseDelayMs(): number {
|
|
81
|
+
const override = Number(process.env.XAPI_RETRY_BASE_MS);
|
|
82
|
+
return Number.isFinite(override) && override > 0 ? override : RETRY_BASE_DELAY_MS;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function transferIdleTimeoutMs(): number {
|
|
86
|
+
const override = Number(process.env.XAPI_TRANSFER_IDLE_TIMEOUT_MS);
|
|
87
|
+
return Number.isFinite(override) && override > 0
|
|
88
|
+
? override
|
|
89
|
+
: TRANSFER_IDLE_TIMEOUT_MS;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Exponential backoff with half-jitter, honoring a server Retry-After when present. */
|
|
93
|
+
function backoffDelayMs(attempt: number, retryAfterMs?: number): number {
|
|
94
|
+
if (retryAfterMs !== undefined && Number.isFinite(retryAfterMs) && retryAfterMs >= 0) {
|
|
95
|
+
return Math.min(retryAfterMs, RETRY_MAX_DELAY_MS);
|
|
96
|
+
}
|
|
97
|
+
const capped = Math.min(retryBaseDelayMs() * 2 ** attempt, RETRY_MAX_DELAY_MS);
|
|
98
|
+
return capped / 2 + Math.random() * (capped / 2);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function parseRetryAfterMs(res: Response): number | undefined {
|
|
102
|
+
const header = res.headers.get('retry-after');
|
|
103
|
+
if (!header) return undefined;
|
|
104
|
+
const seconds = Number(header);
|
|
105
|
+
if (Number.isFinite(seconds)) return seconds * 1000;
|
|
106
|
+
const at = Date.parse(header);
|
|
107
|
+
return Number.isFinite(at) ? Math.max(0, at - Date.now()) : undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function abortError(signal?: AbortSignal | null): Error {
|
|
111
|
+
const reason = signal?.reason;
|
|
112
|
+
return reason instanceof Error
|
|
113
|
+
? reason
|
|
114
|
+
: new DOMException('The operation was aborted', 'AbortError');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function sleep(ms: number, signal?: AbortSignal | null): Promise<void> {
|
|
118
|
+
if (signal?.aborted) return Promise.reject(abortError(signal));
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
121
|
+
const onAbort = () => {
|
|
122
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
123
|
+
signal?.removeEventListener('abort', onAbort);
|
|
124
|
+
reject(abortError(signal));
|
|
125
|
+
};
|
|
126
|
+
timer = setTimeout(() => {
|
|
127
|
+
signal?.removeEventListener('abort', onAbort);
|
|
128
|
+
resolve();
|
|
129
|
+
}, ms);
|
|
130
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function request<T>(
|
|
135
|
+
url: string,
|
|
136
|
+
options: RequestInit,
|
|
137
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
138
|
+
retries = 0,
|
|
139
|
+
): Promise<T> {
|
|
140
|
+
// Enforce the host allowlist before the API key ever leaves the machine.
|
|
141
|
+
assertAllowedHost(url);
|
|
142
|
+
|
|
143
|
+
let attempt = 0;
|
|
144
|
+
while (true) {
|
|
145
|
+
const controller = new AbortController();
|
|
146
|
+
const callerSignal = options.signal;
|
|
147
|
+
const abortFromCaller = () => controller.abort();
|
|
148
|
+
if (callerSignal?.aborted) controller.abort();
|
|
149
|
+
else callerSignal?.addEventListener('abort', abortFromCaller, { once: true });
|
|
150
|
+
let timedOut = false;
|
|
151
|
+
const timer = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs);
|
|
152
|
+
try {
|
|
153
|
+
// redirect: 'manual' — never auto-follow. fetch forwards custom headers
|
|
154
|
+
// (including XAPI-Key) across redirects, which would carry the API key to a
|
|
155
|
+
// host outside the allowlist. The xapi API never legitimately redirects.
|
|
156
|
+
const res = await fetch(url, { ...options, redirect: 'manual', signal: controller.signal });
|
|
157
|
+
if (res.status >= 300 && res.status < 400) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`refusing to follow redirect to "${res.headers.get('location') ?? '?'}" `
|
|
160
|
+
+ '(would forward the API key past the host allowlist)',
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
if (!res.ok) {
|
|
164
|
+
const retryAfterMs = isRetryableStatus(res.status) ? parseRetryAfterMs(res) : undefined;
|
|
165
|
+
if (isRetryableStatus(res.status) && attempt < retries) {
|
|
166
|
+
await res.text().catch(() => ''); // drain body so the socket can be reused
|
|
167
|
+
clearTimeout(timer);
|
|
168
|
+
await sleep(backoffDelayMs(attempt, retryAfterMs), callerSignal);
|
|
169
|
+
attempt++;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const text = await res.text();
|
|
173
|
+
throw new HttpError(res.status, text.slice(0, 300), retryAfterMs);
|
|
174
|
+
}
|
|
175
|
+
if (res.status === 204) {
|
|
176
|
+
return undefined as T;
|
|
177
|
+
}
|
|
178
|
+
const text = await res.text();
|
|
179
|
+
if (!text.trim()) {
|
|
180
|
+
return undefined as T;
|
|
181
|
+
}
|
|
182
|
+
const body = JSON.parse(text) as T;
|
|
183
|
+
// Detect business-level auth errors (HTTP 200 but unauthorized)
|
|
184
|
+
if (body && typeof body === 'object' && 'success' in body && (body as any).success === false) {
|
|
185
|
+
const data = (body as any).data;
|
|
186
|
+
if (data?.statusCode === 401 || data?.error === 'Unauthorized') {
|
|
187
|
+
throw new Error(
|
|
188
|
+
'Authentication failed: ' + (data.message || 'Invalid or missing API key')
|
|
189
|
+
+ '. Run "npx xapi-to config set apiKey=<key>" to update your key.',
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
if (data?.error === 'OAuth Required' || (data?.statusCode === 403 && data?.message?.includes('OAuth'))) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
(data.message || 'OAuth authorization required')
|
|
195
|
+
+ '. Run "xapi-to oauth bind" to connect your account.',
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return body;
|
|
200
|
+
} catch (e) {
|
|
201
|
+
if (timedOut) {
|
|
202
|
+
const timeoutError = new RequestTimeoutError(timeoutMs);
|
|
203
|
+
if (attempt < retries) {
|
|
204
|
+
await sleep(backoffDelayMs(attempt), callerSignal);
|
|
205
|
+
attempt++;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
throw timeoutError;
|
|
209
|
+
}
|
|
210
|
+
if (isRetryableNetworkError(e) && attempt < retries) {
|
|
211
|
+
clearTimeout(timer);
|
|
212
|
+
await sleep(backoffDelayMs(attempt), callerSignal);
|
|
213
|
+
attempt++;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
throw e;
|
|
217
|
+
} finally {
|
|
218
|
+
clearTimeout(timer);
|
|
219
|
+
callerSignal?.removeEventListener('abort', abortFromCaller);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function headers(apiKey?: string): Record<string, string> {
|
|
225
|
+
const h: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
226
|
+
if (apiKey) h['XAPI-Key'] = apiKey;
|
|
227
|
+
return h;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Call a scoped API-Key control-plane endpoint without exchanging for a JWT. */
|
|
231
|
+
export function apiKeyApiRequest<T>(
|
|
232
|
+
apiHost: string,
|
|
233
|
+
apiKey: string,
|
|
234
|
+
path: string,
|
|
235
|
+
options: ApiKeyApiRequestOptions = {},
|
|
236
|
+
): Promise<T> {
|
|
237
|
+
const method = options.method ?? 'GET';
|
|
238
|
+
const requestHeaders: Record<string, string> = { 'XAPI-KEY': apiKey };
|
|
239
|
+
if (options.body !== undefined) requestHeaders['Content-Type'] = 'application/json';
|
|
240
|
+
return request<T>(
|
|
241
|
+
`${scheme(apiHost)}://${apiHost}${path}`,
|
|
242
|
+
{
|
|
243
|
+
method,
|
|
244
|
+
headers: requestHeaders,
|
|
245
|
+
...(options.body !== undefined
|
|
246
|
+
? { body: JSON.stringify(options.body) }
|
|
247
|
+
: {}),
|
|
248
|
+
},
|
|
249
|
+
options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
250
|
+
options.retries ?? 0,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function baseUrl(opts: ClientOptions): string {
|
|
255
|
+
return `${scheme(opts.actionHost)}://${opts.actionHost}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Actions (unified: capabilities + APIs) ───────────────────────────────────
|
|
259
|
+
|
|
260
|
+
export async function actionList(
|
|
261
|
+
opts: ClientOptions,
|
|
262
|
+
params: { page?: number; page_size?: number; category?: string; source?: string; service_id?: string } = {},
|
|
263
|
+
) {
|
|
264
|
+
const url = new URL(`${baseUrl(opts)}/v1/actions`);
|
|
265
|
+
if (params.page) url.searchParams.set('page', String(params.page));
|
|
266
|
+
if (params.page_size) url.searchParams.set('page_size', String(params.page_size));
|
|
267
|
+
if (params.category) url.searchParams.set('category', params.category);
|
|
268
|
+
if (params.source) url.searchParams.set('source', params.source);
|
|
269
|
+
if (params.service_id) url.searchParams.set('service_id', params.service_id);
|
|
270
|
+
return request<{ actions: unknown[]; pagination: unknown }>(
|
|
271
|
+
url.toString(),
|
|
272
|
+
{ method: 'GET', headers: headers(opts.apiKey) },
|
|
273
|
+
DEFAULT_TIMEOUT_MS,
|
|
274
|
+
IDEMPOTENT_RETRIES,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export async function actionSearch(
|
|
279
|
+
query: string,
|
|
280
|
+
opts: ClientOptions,
|
|
281
|
+
params: {
|
|
282
|
+
category?: string;
|
|
283
|
+
source?: string;
|
|
284
|
+
page?: number;
|
|
285
|
+
page_size?: number;
|
|
286
|
+
include_all_versions?: boolean;
|
|
287
|
+
sort?: 'default' | 'relevance' | 'price';
|
|
288
|
+
} = {},
|
|
289
|
+
) {
|
|
290
|
+
const url = new URL(`${baseUrl(opts)}/v1/actions/search`);
|
|
291
|
+
url.searchParams.set('q', query);
|
|
292
|
+
if (params.category) url.searchParams.set('category', params.category);
|
|
293
|
+
if (params.source) url.searchParams.set('source', params.source);
|
|
294
|
+
if (params.page) url.searchParams.set('page', String(params.page));
|
|
295
|
+
if (params.page_size) url.searchParams.set('page_size', String(params.page_size));
|
|
296
|
+
if (params.include_all_versions) url.searchParams.set('include_all_versions', 'true');
|
|
297
|
+
if (params.sort) url.searchParams.set('sort', params.sort);
|
|
298
|
+
return request<{
|
|
299
|
+
results: unknown[];
|
|
300
|
+
query: string;
|
|
301
|
+
sort?: 'default' | 'relevance' | 'price';
|
|
302
|
+
ranking_version?: number;
|
|
303
|
+
pagination: unknown;
|
|
304
|
+
}>(
|
|
305
|
+
url.toString(),
|
|
306
|
+
{ method: 'GET', headers: headers(opts.apiKey) },
|
|
307
|
+
DEFAULT_TIMEOUT_MS,
|
|
308
|
+
IDEMPOTENT_RETRIES,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export async function actionCategories(opts: ClientOptions, params: { source?: string } = {}) {
|
|
313
|
+
const url = new URL(`${baseUrl(opts)}/v1/actions/categories`);
|
|
314
|
+
if (params.source) url.searchParams.set('source', params.source);
|
|
315
|
+
return request<{ categories: string[]; total: number }>(
|
|
316
|
+
url.toString(),
|
|
317
|
+
{ method: 'GET', headers: headers(opts.apiKey) },
|
|
318
|
+
DEFAULT_TIMEOUT_MS,
|
|
319
|
+
IDEMPOTENT_RETRIES,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export async function actionGet(id: string, opts: ClientOptions) {
|
|
324
|
+
return request<unknown[]>(
|
|
325
|
+
`${baseUrl(opts)}/v1/actions/${encodeURIComponent(id)}`,
|
|
326
|
+
{ method: 'GET', headers: headers(opts.apiKey) },
|
|
327
|
+
DEFAULT_TIMEOUT_MS,
|
|
328
|
+
IDEMPOTENT_RETRIES,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export async function actionBatch(ids: string[], opts: ClientOptions) {
|
|
333
|
+
return request<{ actions: unknown[]; missing_ids: string[] }>(
|
|
334
|
+
`${baseUrl(opts)}/v1/actions/batch`,
|
|
335
|
+
{
|
|
336
|
+
method: 'POST',
|
|
337
|
+
headers: headers(opts.apiKey),
|
|
338
|
+
body: JSON.stringify({ ids }),
|
|
339
|
+
},
|
|
340
|
+
DEFAULT_TIMEOUT_MS,
|
|
341
|
+
IDEMPOTENT_RETRIES, // read-only metadata fetch — safe to retry
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export async function actionCall(
|
|
346
|
+
actionId: string,
|
|
347
|
+
input: Record<string, unknown>,
|
|
348
|
+
opts: ClientOptions,
|
|
349
|
+
httpMethod?: string,
|
|
350
|
+
// Default 0: the execute endpoint dispatches arbitrary actions, some of which
|
|
351
|
+
// are non-idempotent writes (posting a tweet, a payment). A gateway 5xx or a
|
|
352
|
+
// dropped connection does NOT prove the upstream didn't run, so a blind retry
|
|
353
|
+
// could duplicate the effect. Callers invoking a known-idempotent action
|
|
354
|
+
// (e.g. task.poll) may opt into retries explicitly.
|
|
355
|
+
retries = 0,
|
|
356
|
+
// Per-request timeout. Capped at EXECUTE_TIMEOUT_MS so a caller (e.g. `task wait`)
|
|
357
|
+
// can shrink it to a remaining deadline but never extend it past the hard ceiling.
|
|
358
|
+
timeoutMs = EXECUTE_TIMEOUT_MS,
|
|
359
|
+
) {
|
|
360
|
+
return request<unknown>(
|
|
361
|
+
`${baseUrl(opts)}/v1/actions/execute`,
|
|
362
|
+
{
|
|
363
|
+
method: 'POST',
|
|
364
|
+
headers: headers(opts.apiKey),
|
|
365
|
+
body: JSON.stringify({ action_id: actionId, ...(httpMethod ? { method: httpMethod } : {}), input }),
|
|
366
|
+
},
|
|
367
|
+
Math.min(timeoutMs, EXECUTE_TIMEOUT_MS),
|
|
368
|
+
retries,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Execute an action through the SSE interface and forward frames unchanged. */
|
|
373
|
+
export async function actionStream(
|
|
374
|
+
actionId: string,
|
|
375
|
+
input: Record<string, unknown>,
|
|
376
|
+
opts: ClientOptions,
|
|
377
|
+
httpMethod?: string,
|
|
378
|
+
): Promise<void> {
|
|
379
|
+
const controller = new AbortController();
|
|
380
|
+
let timedOut = false;
|
|
381
|
+
let activeTimeoutMs = EXECUTE_TIMEOUT_MS;
|
|
382
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
383
|
+
const resetTimeout = (timeoutMs: number) => {
|
|
384
|
+
if (timer) clearTimeout(timer);
|
|
385
|
+
activeTimeoutMs = timeoutMs;
|
|
386
|
+
timer = setTimeout(() => {
|
|
387
|
+
timedOut = true;
|
|
388
|
+
controller.abort();
|
|
389
|
+
}, timeoutMs);
|
|
390
|
+
};
|
|
391
|
+
resetTimeout(EXECUTE_TIMEOUT_MS);
|
|
392
|
+
const url = `${baseUrl(opts)}/v1/actions/execute`;
|
|
393
|
+
assertAllowedHost(url);
|
|
394
|
+
|
|
395
|
+
try {
|
|
396
|
+
const res = await fetch(url, {
|
|
397
|
+
method: 'POST',
|
|
398
|
+
headers: {
|
|
399
|
+
...headers(opts.apiKey),
|
|
400
|
+
Accept: 'text/event-stream',
|
|
401
|
+
},
|
|
402
|
+
body: JSON.stringify({
|
|
403
|
+
action_id: actionId,
|
|
404
|
+
...(httpMethod ? { method: httpMethod } : {}),
|
|
405
|
+
input,
|
|
406
|
+
stream: true,
|
|
407
|
+
}),
|
|
408
|
+
redirect: 'manual',
|
|
409
|
+
signal: controller.signal,
|
|
410
|
+
});
|
|
411
|
+
if (res.status >= 300 && res.status < 400) {
|
|
412
|
+
throw new Error(
|
|
413
|
+
`refusing to follow redirect to "${res.headers.get('location') ?? '?'}" `
|
|
414
|
+
+ '(would forward the API key past the host allowlist)',
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
if (!res.ok) {
|
|
418
|
+
const text = await res.text();
|
|
419
|
+
throw new HttpError(
|
|
420
|
+
res.status,
|
|
421
|
+
text.slice(0, 300),
|
|
422
|
+
isRetryableStatus(res.status) ? parseRetryAfterMs(res) : undefined,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const contentType = res.headers.get('content-type') || '';
|
|
427
|
+
if (!contentType.toLowerCase().includes('text/event-stream')) {
|
|
428
|
+
const text = await res.text();
|
|
429
|
+
throw new Error(
|
|
430
|
+
`expected an SSE response but received "${contentType || 'unknown'}": ${text.slice(0, 300)}`,
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (!res.body) return;
|
|
435
|
+
const idleTimeoutMs = transferIdleTimeoutMs();
|
|
436
|
+
resetTimeout(idleTimeoutMs);
|
|
437
|
+
const source = Readable.fromWeb(res.body as any);
|
|
438
|
+
for await (const chunk of source) {
|
|
439
|
+
resetTimeout(idleTimeoutMs);
|
|
440
|
+
if (!process.stdout.write(chunk)) await once(process.stdout, 'drain');
|
|
441
|
+
}
|
|
442
|
+
} catch (error) {
|
|
443
|
+
if (timedOut) throw new RequestTimeoutError(activeTimeoutMs);
|
|
444
|
+
throw error;
|
|
445
|
+
} finally {
|
|
446
|
+
if (timer) clearTimeout(timer);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export interface ActionDownloadResult {
|
|
451
|
+
output: string;
|
|
452
|
+
bytes: number;
|
|
453
|
+
contentType?: string;
|
|
454
|
+
contentDisposition?: string;
|
|
455
|
+
status: number;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Execute an action in raw mode and write the response without decoding it. */
|
|
459
|
+
export async function actionDownload(
|
|
460
|
+
actionId: string,
|
|
461
|
+
input: Record<string, unknown>,
|
|
462
|
+
opts: ClientOptions,
|
|
463
|
+
outputPath: string,
|
|
464
|
+
httpMethod?: string,
|
|
465
|
+
): Promise<ActionDownloadResult> {
|
|
466
|
+
const controller = new AbortController();
|
|
467
|
+
let timedOut = false;
|
|
468
|
+
let activeTimeoutMs = EXECUTE_TIMEOUT_MS;
|
|
469
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
470
|
+
const resetTimeout = (timeoutMs: number) => {
|
|
471
|
+
if (timer) clearTimeout(timer);
|
|
472
|
+
activeTimeoutMs = timeoutMs;
|
|
473
|
+
timer = setTimeout(() => {
|
|
474
|
+
timedOut = true;
|
|
475
|
+
controller.abort();
|
|
476
|
+
}, timeoutMs);
|
|
477
|
+
};
|
|
478
|
+
resetTimeout(EXECUTE_TIMEOUT_MS);
|
|
479
|
+
const target = resolve(outputPath);
|
|
480
|
+
let file: Awaited<ReturnType<typeof open>> | undefined;
|
|
481
|
+
let complete = false;
|
|
482
|
+
|
|
483
|
+
try {
|
|
484
|
+
try {
|
|
485
|
+
// Fail before executing a potentially billable/non-idempotent action.
|
|
486
|
+
// This also lets cleanup safely remove only files created by this call.
|
|
487
|
+
file = await open(target, 'wx');
|
|
488
|
+
} catch (error: any) {
|
|
489
|
+
if (error?.code === 'EEXIST') {
|
|
490
|
+
throw new Error(`Output file already exists: ${target}`);
|
|
491
|
+
}
|
|
492
|
+
throw error;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const url = `${baseUrl(opts)}/v1/actions/execute`;
|
|
496
|
+
assertAllowedHost(url);
|
|
497
|
+
const res = await fetch(url, {
|
|
498
|
+
method: 'POST',
|
|
499
|
+
headers: headers(opts.apiKey),
|
|
500
|
+
body: JSON.stringify({
|
|
501
|
+
action_id: actionId,
|
|
502
|
+
...(httpMethod ? { method: httpMethod } : {}),
|
|
503
|
+
input,
|
|
504
|
+
response_mode: 'raw',
|
|
505
|
+
}),
|
|
506
|
+
redirect: 'manual',
|
|
507
|
+
signal: controller.signal,
|
|
508
|
+
});
|
|
509
|
+
if (res.status >= 300 && res.status < 400) {
|
|
510
|
+
throw new Error(
|
|
511
|
+
`refusing to follow redirect to "${res.headers.get('location') ?? '?'}" `
|
|
512
|
+
+ '(would forward the API key past the host allowlist)',
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
if (!res.ok) {
|
|
516
|
+
const text = await res.text();
|
|
517
|
+
throw new HttpError(
|
|
518
|
+
res.status,
|
|
519
|
+
text.slice(0, 300),
|
|
520
|
+
isRetryableStatus(res.status) ? parseRetryAfterMs(res) : undefined,
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
let bytes = 0;
|
|
525
|
+
if (res.body) {
|
|
526
|
+
const idleTimeoutMs = transferIdleTimeoutMs();
|
|
527
|
+
resetTimeout(idleTimeoutMs);
|
|
528
|
+
const source = Readable.fromWeb(res.body as any);
|
|
529
|
+
const counter = new Transform({
|
|
530
|
+
transform(chunk, _encoding, callback) {
|
|
531
|
+
resetTimeout(idleTimeoutMs);
|
|
532
|
+
bytes += Buffer.isBuffer(chunk)
|
|
533
|
+
? chunk.length
|
|
534
|
+
: Buffer.byteLength(chunk);
|
|
535
|
+
callback(null, chunk);
|
|
536
|
+
},
|
|
537
|
+
});
|
|
538
|
+
await pipeline(source, counter, file.createWriteStream());
|
|
539
|
+
} else {
|
|
540
|
+
await file.close();
|
|
541
|
+
}
|
|
542
|
+
complete = true;
|
|
543
|
+
|
|
544
|
+
return {
|
|
545
|
+
output: target,
|
|
546
|
+
bytes,
|
|
547
|
+
contentType: res.headers.get('content-type') || undefined,
|
|
548
|
+
contentDisposition:
|
|
549
|
+
res.headers.get('content-disposition') || undefined,
|
|
550
|
+
status: res.status,
|
|
551
|
+
};
|
|
552
|
+
} catch (error) {
|
|
553
|
+
if (timedOut) throw new RequestTimeoutError(activeTimeoutMs);
|
|
554
|
+
throw error;
|
|
555
|
+
} finally {
|
|
556
|
+
if (timer) clearTimeout(timer);
|
|
557
|
+
if (!complete && file) {
|
|
558
|
+
await file.close().catch(() => undefined);
|
|
559
|
+
await rm(target, { force: true }).catch(() => undefined);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export async function actionServices(
|
|
565
|
+
opts: ClientOptions,
|
|
566
|
+
params: { page?: number; page_size?: number; category?: string } = {},
|
|
567
|
+
) {
|
|
568
|
+
const url = new URL(`${baseUrl(opts)}/v1/actions/services`);
|
|
569
|
+
if (params.page) url.searchParams.set('page', String(params.page));
|
|
570
|
+
if (params.page_size) url.searchParams.set('page_size', String(params.page_size));
|
|
571
|
+
if (params.category) url.searchParams.set('category', params.category);
|
|
572
|
+
return request<{ services: unknown[]; pagination: unknown }>(
|
|
573
|
+
url.toString(),
|
|
574
|
+
{ method: 'GET', headers: headers(opts.apiKey) },
|
|
575
|
+
DEFAULT_TIMEOUT_MS,
|
|
576
|
+
IDEMPOTENT_RETRIES,
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
export async function healthCheck(opts: ClientOptions) {
|
|
581
|
+
return request<unknown>(
|
|
582
|
+
`${baseUrl(opts)}/health`,
|
|
583
|
+
{ method: 'GET', headers: headers(opts.apiKey) },
|
|
584
|
+
5_000,
|
|
585
|
+
0, // health is a quick connectivity probe — fail fast, don't retry
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// ── Auth ──────────────────────────────────────────────────────────────────────
|
|
590
|
+
|
|
591
|
+
export async function loginWithApiKey(apiKey: string, apiHost: string) {
|
|
592
|
+
return request<{ accessToken: string; user: unknown }>(
|
|
593
|
+
`${scheme(apiHost)}://${apiHost}/api/auth/login/apikey`,
|
|
594
|
+
{
|
|
595
|
+
method: 'POST',
|
|
596
|
+
headers: { 'Content-Type': 'application/json' },
|
|
597
|
+
body: JSON.stringify({ apiKey }),
|
|
598
|
+
},
|
|
599
|
+
DEFAULT_TIMEOUT_MS,
|
|
600
|
+
IDEMPOTENT_RETRIES, // auth exchange has no side effect — safe to retry
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// ── OAuth ──────────────────────────────────────────────────────────────────────
|
|
605
|
+
|
|
606
|
+
function jwtHeaders(jwtToken: string): Record<string, string> {
|
|
607
|
+
return { 'Content-Type': 'application/json', Authorization: `Bearer ${jwtToken}` };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
export async function listKeys(jwtToken: string, apiHost: string) {
|
|
611
|
+
return request<Array<{
|
|
612
|
+
id: string;
|
|
613
|
+
name: string;
|
|
614
|
+
keyPreview: string;
|
|
615
|
+
oauthEnabled: boolean;
|
|
616
|
+
createdAt: string;
|
|
617
|
+
}>>(
|
|
618
|
+
`${scheme(apiHost)}://${apiHost}/api/keys`,
|
|
619
|
+
{ method: 'GET', headers: jwtHeaders(jwtToken) },
|
|
620
|
+
DEFAULT_TIMEOUT_MS,
|
|
621
|
+
IDEMPOTENT_RETRIES,
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
export async function enableOAuthForKey(
|
|
626
|
+
keyId: string,
|
|
627
|
+
plaintextKey: string,
|
|
628
|
+
jwtToken: string,
|
|
629
|
+
apiHost: string,
|
|
630
|
+
) {
|
|
631
|
+
return request<{ success: boolean; message: string }>(
|
|
632
|
+
`${scheme(apiHost)}://${apiHost}/api/keys/${keyId}/enable-oauth`,
|
|
633
|
+
{
|
|
634
|
+
method: 'POST',
|
|
635
|
+
headers: jwtHeaders(jwtToken),
|
|
636
|
+
body: JSON.stringify({ plaintextKey }),
|
|
637
|
+
},
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
export interface ScopeDefinition {
|
|
642
|
+
scope: string;
|
|
643
|
+
label: string;
|
|
644
|
+
description: string;
|
|
645
|
+
required: boolean;
|
|
646
|
+
category: string;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
export interface OAuthProvider {
|
|
650
|
+
id: string;
|
|
651
|
+
name: string;
|
|
652
|
+
type: string;
|
|
653
|
+
grantType: string;
|
|
654
|
+
defaultScopes: string;
|
|
655
|
+
scopeDefinitions: ScopeDefinition[] | null;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
export async function listOAuthProviders(apiHost: string) {
|
|
659
|
+
return request<OAuthProvider[]>(
|
|
660
|
+
`${scheme(apiHost)}://${apiHost}/api/oauth/providers`,
|
|
661
|
+
{ method: 'GET', headers: { 'Content-Type': 'application/json' } },
|
|
662
|
+
DEFAULT_TIMEOUT_MS,
|
|
663
|
+
IDEMPOTENT_RETRIES,
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
export async function initiateOAuth(
|
|
668
|
+
apiKeyId: string,
|
|
669
|
+
providerId: string,
|
|
670
|
+
jwtToken: string,
|
|
671
|
+
apiHost: string,
|
|
672
|
+
scopes?: string,
|
|
673
|
+
) {
|
|
674
|
+
const body: Record<string, string> = { apiKeyId, providerId };
|
|
675
|
+
if (scopes) body.scopes = scopes;
|
|
676
|
+
return request<{ authorizationUrl: string; state: string }>(
|
|
677
|
+
`${scheme(apiHost)}://${apiHost}/api/oauth/authorize`,
|
|
678
|
+
{
|
|
679
|
+
method: 'POST',
|
|
680
|
+
headers: jwtHeaders(jwtToken),
|
|
681
|
+
body: JSON.stringify(body),
|
|
682
|
+
},
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
export async function listOAuthBindings(jwtToken: string, apiHost: string) {
|
|
687
|
+
return request<Array<{
|
|
688
|
+
id: string;
|
|
689
|
+
apiKeyId: string;
|
|
690
|
+
providerId: string;
|
|
691
|
+
providerAccountId: string;
|
|
692
|
+
providerAccountName: string | null;
|
|
693
|
+
scopes: string;
|
|
694
|
+
createdAt: string;
|
|
695
|
+
updatedAt: string;
|
|
696
|
+
provider: { id: string; name: string; type: string };
|
|
697
|
+
}>>(
|
|
698
|
+
`${scheme(apiHost)}://${apiHost}/api/oauth/bindings`,
|
|
699
|
+
{ method: 'GET', headers: jwtHeaders(jwtToken) },
|
|
700
|
+
DEFAULT_TIMEOUT_MS,
|
|
701
|
+
IDEMPOTENT_RETRIES,
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
export async function deleteOAuthBinding(
|
|
706
|
+
bindingId: string,
|
|
707
|
+
jwtToken: string,
|
|
708
|
+
apiHost: string,
|
|
709
|
+
) {
|
|
710
|
+
const result = await request<{ success: boolean } | undefined>(
|
|
711
|
+
`${scheme(apiHost)}://${apiHost}/api/oauth/bindings/${bindingId}`,
|
|
712
|
+
{ method: 'DELETE', headers: jwtHeaders(jwtToken) },
|
|
713
|
+
);
|
|
714
|
+
return result ?? { success: true };
|
|
715
|
+
}
|