@pipelex/sdk 0.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/client.js ADDED
@@ -0,0 +1,884 @@
1
+ import { pollUntilResult, } from "./runs.js";
2
+ import { ApiResponseError, ApiUnreachableError, PipelineExecuteTimeoutError, PipelineRequestError, RunLifecycleUnavailableError, RunStillRunningError, } from "./errors.js";
3
+ /** Hosted default — the client composes every endpoint as `{base}/v1/{endpoint}`. */
4
+ export const DEFAULT_API_BASE_URL = "https://api.pipelex.com";
5
+ // The client composes every endpoint from one origin (PIPELEX_API_URL): `{base}/v1/{endpoint}`.
6
+ // The same paths are served by the Pipelex Hosted API (api.pipelex.com) and by a bare
7
+ // OSS pipelex-api runner (localhost:8081) — the protocol surface is identical; only
8
+ // the hosted extensions (e.g. run polling) differ, detectable via GET /v1/version.
9
+ const API_PREFIX = "v1";
10
+ const RUNS = "runs";
11
+ const DEFAULT_REQUEST_TIMEOUT_MS = 1_200_000; // 20 min — matches the runner's blocking execute ceiling.
12
+ const POLL_REQUEST_TIMEOUT_MS = 30_000; // single status/result GETs; the hosted gateway caps responses at ~30s.
13
+ const DEFAULT_DEGRADED_RETRY_SECONDS = 5; // matches the platform's `_DEGRADE_RETRY_AFTER_SECONDS`.
14
+ const VALIDATE_MARKDOWN_RENDER_FORMAT = "markdown";
15
+ /**
16
+ * `VersionInfo.implementation` of the bare open-source runner (no run store).
17
+ * Anything else — the hosted `pipelex-hosted` first — is assumed to serve the
18
+ * durable run-lifecycle extension; a wrong guess still fails with the clear
19
+ * `RunLifecycleUnavailableError` on the first poll.
20
+ */
21
+ const BARE_RUNNER_IMPLEMENTATION = "pipelex-api";
22
+ /**
23
+ * Client for the Pipelex hosted API — and any MTHDS-compliant runner.
24
+ *
25
+ * One base URL (`PIPELEX_API_URL`); every endpoint is `<base>/v1/<endpoint>`:
26
+ * - **protocol** (`execute` / `start` / `validate` / `models` / `version`) — works
27
+ * against any MTHDS-compliant runner, hosted or bare.
28
+ * - **build extensions** (`/v1/build/*`) — the Pipelex API's authoring helpers.
29
+ * - **run lifecycle** (`getRunStatus` / `getRunResult` / `waitForResult`) — the
30
+ * durable polling extension that survives long runs and lets a caller resume by
31
+ * id. Served only by a deployment that includes the platform block (the Pipelex
32
+ * Hosted API); a bare `pipelex-api` runner 404s those routes, which the lifecycle
33
+ * methods translate into a clear `RunLifecycleUnavailableError`.
34
+ *
35
+ * Implements `MTHDSProtocol<DictPipeOutput>` so the protocol-execution methods
36
+ * stay shaped like the standard's wire surface (`mthds/protocol`). The Pipelex
37
+ * extensions (build, run lifecycle) ride on top.
38
+ */
39
+ export class PipelexApiClient {
40
+ apiToken;
41
+ baseUrl;
42
+ /** Origin root derived from the base URL — `/health` lives here, not under `/v1`. */
43
+ originUrl;
44
+ /** Cached `/v1/version` handshake outcome — whether the durable lifecycle is served. */
45
+ lifecycleAvailable;
46
+ constructor(options = {}) {
47
+ this.apiToken = options.apiToken ?? process.env.PIPELEX_API_KEY;
48
+ const normalizedBaseUrl = (options.baseUrl ??
49
+ process.env.PIPELEX_API_URL ??
50
+ DEFAULT_API_BASE_URL).replace(/\/+$/, "");
51
+ // The base URL must be host-only: direct SDK usage and PIPELEX_API_URL reach
52
+ // this constructor and must be held to that rule, or a path-prefixed value
53
+ // (e.g. `.../v1`) composes as `/v1/v1/...` and fails with a misleading
54
+ // endpoint error instead of a clear base-URL one. Trailing slashes are
55
+ // stripped first; a remaining path/query/fragment/credentials is rejected.
56
+ if (!isValidBaseUrl(normalizedBaseUrl)) {
57
+ throw new PipelineRequestError(`Invalid API base URL "${normalizedBaseUrl}": must be host-only ` +
58
+ `(http/https, no path, query, fragment, or credentials). Endpoints ` +
59
+ `compose as {base}/v1/{endpoint}.`);
60
+ }
61
+ this.baseUrl = normalizedBaseUrl;
62
+ this.originUrl = new URL("/", this.baseUrl).origin;
63
+ }
64
+ // ── URL resolution ───────────────────────────────────────────────────
65
+ /** Build an API URL: `<base>/v1/<endpoint>`. */
66
+ url(endpoint) {
67
+ return `${this.baseUrl}/${API_PREFIX}/${endpoint.replace(/^\/+/, "")}`;
68
+ }
69
+ // ── Transport ──────────────────────────────────────────────────────
70
+ /**
71
+ * Issue one HTTP request and return the raw status/headers/body. Wraps
72
+ * DNS/connect/TLS/timeout failures as `ApiUnreachableError`; a caller-driven
73
+ * abort (Ctrl-C / agent walk-away) propagates as-is so the poll loop can stop
74
+ * cleanly. Non-2xx interpretation is left to the caller. `url` is a fully
75
+ * resolved absolute URL.
76
+ */
77
+ async requestRaw(method, url, options = {}) {
78
+ const headers = { Accept: "application/json" };
79
+ if (this.apiToken) {
80
+ headers["Authorization"] = `Bearer ${this.apiToken}`;
81
+ }
82
+ const hasBody = options.body !== undefined;
83
+ if (hasBody) {
84
+ headers["Content-Type"] = "application/json";
85
+ }
86
+ const timeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
87
+ const controller = new AbortController();
88
+ const timer = setTimeout(() => controller.abort(new DOMException("Request timed out.", "TimeoutError")), timeoutMs);
89
+ const userSignal = options.signal;
90
+ const onUserAbort = () => controller.abort(userSignal?.reason);
91
+ if (userSignal) {
92
+ if (userSignal.aborted)
93
+ controller.abort(userSignal.reason);
94
+ else
95
+ userSignal.addEventListener("abort", onUserAbort, { once: true });
96
+ }
97
+ let response;
98
+ try {
99
+ response = await fetch(url, {
100
+ method,
101
+ headers,
102
+ body: hasBody ? JSON.stringify(options.body) : undefined,
103
+ signal: controller.signal,
104
+ });
105
+ }
106
+ catch (err) {
107
+ // A caller-initiated abort (not our timeout) propagates untouched so
108
+ // `waitForResult` callers can distinguish "I stopped waiting" from a
109
+ // network failure.
110
+ if (userSignal?.aborted)
111
+ throw err;
112
+ // undici (Node fetch) wraps DNS/connect/TLS failures as
113
+ // `TypeError("fetch failed")` with the system error attached as `cause`.
114
+ // Our timeout aborts the controller with a "TimeoutError" DOMException.
115
+ const code = extractNetworkErrorCode(err);
116
+ throw new ApiUnreachableError(`Could not reach Pipelex API at ${this.baseUrl} (${code ?? "network error"})`, this.baseUrl, code, { cause: err });
117
+ }
118
+ finally {
119
+ clearTimeout(timer);
120
+ if (userSignal)
121
+ userSignal.removeEventListener("abort", onUserAbort);
122
+ }
123
+ const body = await response.text().catch(() => "");
124
+ return {
125
+ status: response.status,
126
+ statusText: response.statusText,
127
+ headers: response.headers,
128
+ body,
129
+ };
130
+ }
131
+ /**
132
+ * Issue a request and parse the JSON body, throwing a plain `Error` on a
133
+ * non-2xx response. Used by the build extensions and `health` — surfaces
134
+ * that don't need the protocol's structured error taxonomy.
135
+ */
136
+ async requestJson(method, url, body) {
137
+ const headers = { Accept: "application/json" };
138
+ if (this.apiToken) {
139
+ headers["Authorization"] = `Bearer ${this.apiToken}`;
140
+ }
141
+ if (body !== undefined) {
142
+ headers["Content-Type"] = "application/json";
143
+ }
144
+ const res = await fetch(url, {
145
+ method,
146
+ headers,
147
+ body: body !== undefined ? JSON.stringify(body) : undefined,
148
+ });
149
+ if (!res.ok) {
150
+ const text = await res.text().catch(() => "");
151
+ throw new Error(`API ${method} ${url} failed (${res.status}): ${text || res.statusText}`);
152
+ }
153
+ return res.json();
154
+ }
155
+ postApi(path, body) {
156
+ return this.requestJson("POST", this.url(path), body);
157
+ }
158
+ /**
159
+ * Issue a Pipelex-product request (`/v1/me`, `/v1/methods`, `/v1/billing/*`,
160
+ * …) and parse its JSON body, mapping a non-2xx response to the typed
161
+ * `ApiResponseError` so callers branch on the structured `code` discriminant,
162
+ * not the HTTP status. Empty-body tolerant — DELETE / onboarding / updateRun
163
+ * answer 2xx with no body, returned as `undefined`. Uses the management-call
164
+ * timeout, not the blocking-execute ceiling.
165
+ */
166
+ async requestProduct(method, endpoint, body, options = {}) {
167
+ const res = await this.requestRaw(method, this.url(endpoint), {
168
+ body,
169
+ timeoutMs: POLL_REQUEST_TIMEOUT_MS,
170
+ signal: options.signal,
171
+ });
172
+ if (res.status < 200 || res.status >= 300) {
173
+ this.throwApiResponseError(method, endpoint, res);
174
+ }
175
+ return (res.body ? JSON.parse(res.body) : undefined);
176
+ }
177
+ throwApiResponseError(method, endpoint, res) {
178
+ const { errorType, serverMessage, validationErrors, code } = parseErrorBody(res.body);
179
+ throw new ApiResponseError(`API ${method} /${API_PREFIX}/${endpoint} failed (${res.status}): ${serverMessage ?? (res.body || res.statusText)}`, this.baseUrl, res.status, res.statusText, res.body, errorType, serverMessage, validationErrors, code);
180
+ }
181
+ /**
182
+ * Translate a "route absent" 404 (a bare pipelex-api with no platform block)
183
+ * into a clear `RunLifecycleUnavailableError`. The platform's own 404s (run
184
+ * not found / cross-org) carry a structured error envelope (a `code` field)
185
+ * and are left for normal handling.
186
+ */
187
+ throwIfLifecycleUnavailable(res, url) {
188
+ if (res.status !== 404)
189
+ return;
190
+ if (!isMissingRoute404(res.body))
191
+ return;
192
+ throw new RunLifecycleUnavailableError(`The durable run lifecycle is not available: ${url} returned 404. Run polling is a ` +
193
+ `hosted-API extension (/${API_PREFIX}/${RUNS}/*), not part of the MTHDS Protocol; ` +
194
+ "PIPELEX_API_URL points at a bare runner that does not serve it.", this.baseUrl);
195
+ }
196
+ /**
197
+ * Map the protocol's optional 202 execute degrade to a typed
198
+ * error. Hosted does not emit 202 today, but the protocol permits it;
199
+ * raising a typed error (with the `pipeline_run_id` + `Location` + `Retry-After`
200
+ * hints) beats a generic parse failure on an unexpected body shape.
201
+ */
202
+ throwIfExecuteDegraded(res) {
203
+ if (res.status !== 202)
204
+ return;
205
+ let runId = "";
206
+ try {
207
+ const parsed = JSON.parse(res.body);
208
+ if (parsed && typeof parsed === "object") {
209
+ const candidate = parsed.pipeline_run_id;
210
+ if (typeof candidate === "string")
211
+ runId = candidate;
212
+ }
213
+ }
214
+ catch {
215
+ // Non-JSON 202 body — keep runId empty; the error message covers it.
216
+ }
217
+ throw new RunStillRunningError(`execute() was accepted asynchronously (202): run ${runId || "<unknown>"} is still ` +
218
+ "running server-side. Poll its results (hosted) or use start().", runId, parseRetryAfter(res.headers), res.headers.get("location"));
219
+ }
220
+ // ── Health ────────────────────────────────────────────────────────
221
+ async health() {
222
+ // `/health` is origin-level, NOT under the `/v1` prefix.
223
+ return this.requestJson("GET", `${this.originUrl}/health`);
224
+ }
225
+ // ── Protocol surface ─────────────────────────────────────────────────
226
+ /**
227
+ * Execute a method synchronously and wait for its completion —
228
+ * `POST /v1/execute`.
229
+ *
230
+ * Behind the hosted gateway, synchronous requests terminate at ~30s; a run
231
+ * that exceeds that surfaces as `PipelineExecuteTimeoutError` pointing at the
232
+ * durable start+poll path. Throws `RunStillRunningError` on the protocol's
233
+ * optional 202 degrade.
234
+ */
235
+ async execute(options) {
236
+ const extensions = buildExtensions(options.extra);
237
+ if (!options.pipe_code &&
238
+ (!options.mthds_contents || options.mthds_contents.length === 0) &&
239
+ Object.keys(extensions).length === 0) {
240
+ throw new PipelineRequestError("Either pipe_code, mthds_contents or a server-specific extension arg (extra) must be provided to execute().");
241
+ }
242
+ const request = {
243
+ pipe_code: options.pipe_code,
244
+ mthds_contents: options.mthds_contents,
245
+ inputs: options.inputs,
246
+ output_name: options.output_name,
247
+ output_multiplicity: options.output_multiplicity,
248
+ dynamic_output_concept_ref: options.dynamic_output_concept_ref,
249
+ ...extensions,
250
+ };
251
+ const startedAt = Date.now();
252
+ try {
253
+ const res = await this.requestRaw("POST", this.url("execute"), {
254
+ body: request,
255
+ });
256
+ this.throwIfExecuteDegraded(res);
257
+ if (res.status < 200 || res.status >= 300) {
258
+ this.throwApiResponseError("POST", "execute", res);
259
+ }
260
+ return JSON.parse(res.body);
261
+ }
262
+ catch (err) {
263
+ if (err instanceof RunStillRunningError)
264
+ throw err;
265
+ // The hosted gateway terminates synchronous requests at ~30s. A run that
266
+ // exceeds that comes back as a gateway 503/504 (or a client abort) —
267
+ // translate it into a clear, actionable error pointing at start+poll.
268
+ const elapsedMs = Date.now() - startedAt;
269
+ if (isGatewayTimeout(err, elapsedMs)) {
270
+ throw new PipelineExecuteTimeoutError(elapsedMs, { cause: err });
271
+ }
272
+ throw err;
273
+ }
274
+ }
275
+ /**
276
+ * Start a method asynchronously — `POST /v1/start` (202, no output yet).
277
+ *
278
+ * Server-specific extension args ride `options.extra` and merge into the
279
+ * request body — the server you call defines and handles them (including a
280
+ * client-supplied run id where a server supports one). The returned
281
+ * `pipeline_run_id` is always authoritative; on a hosted deployment it is
282
+ * durable — poll `getRunStatus` / `getRunResult`.
283
+ */
284
+ async start(options) {
285
+ const extensions = buildExtensions(options.extra);
286
+ if (!options.pipe_code &&
287
+ (!options.mthds_contents || options.mthds_contents.length === 0) &&
288
+ Object.keys(extensions).length === 0) {
289
+ throw new PipelineRequestError("Either pipe_code, mthds_contents or a server-specific extension arg (extra) must be provided to start().");
290
+ }
291
+ // `?? undefined` so JSON.stringify drops absent fields from the wire body.
292
+ const request = {
293
+ pipe_code: options.pipe_code ?? undefined,
294
+ mthds_contents: options.mthds_contents ?? undefined,
295
+ inputs: options.inputs ?? undefined,
296
+ output_name: options.output_name ?? undefined,
297
+ output_multiplicity: options.output_multiplicity ?? undefined,
298
+ dynamic_output_concept_ref: options.dynamic_output_concept_ref ?? undefined,
299
+ ...extensions,
300
+ };
301
+ const url = this.url("start");
302
+ const res = await this.requestRaw("POST", url, {
303
+ body: request,
304
+ timeoutMs: POLL_REQUEST_TIMEOUT_MS,
305
+ });
306
+ // A bare runner with no run store 404s here just as it does on the result
307
+ // routes — surface the same clear `RunLifecycleUnavailableError` (and let
308
+ // `startAndWaitForResult` fall back to the blocking `execute`).
309
+ this.throwIfLifecycleUnavailable(res, url);
310
+ if (res.status < 200 || res.status >= 300) {
311
+ this.throwApiResponseError("POST", "start", res);
312
+ }
313
+ return JSON.parse(res.body);
314
+ }
315
+ /**
316
+ * Parse, validate, and dry-run an MTHDS bundle — `POST /v1/validate`.
317
+ *
318
+ * `/validate` is a diagnostic endpoint: every produced verdict rides a **200**,
319
+ * discriminated on `is_valid`. This returns the `PipelexValidationResult` union
320
+ * verbatim — `is_valid: true` ⇒ the typed `PipelexValidationReport` (structural
321
+ * artifacts), `is_valid: false` ⇒ a `PipelexInvalidReport` (`validation_errors[]`).
322
+ * An invalid bundle is NOT thrown — the caller pattern-matches `is_valid`. Only a
323
+ * *no-verdict* condition (a malformed request, an `mthds_sources` length mismatch,
324
+ * auth, a server fault) is non-2xx and surfaces as `ApiResponseError`.
325
+ *
326
+ * `mthdsSources` (optional, parallel to `mthdsContents`) names each submitted
327
+ * content — a Pipelex-API extension threaded onto `blueprint.source`, so
328
+ * cross-file diagnostics name the owning file (an unnamed content yields
329
+ * `source: null`). The server 422s a length mismatch; this client sends the
330
+ * arrays verbatim and surfaces that as an `ApiResponseError`.
331
+ *
332
+ * `render` is the Pipelex-API presentation hint — a list of view-format tokens.
333
+ * This client always asks for Markdown so both valid results and produced
334
+ * validation-error verdicts carry `rendered_markdown`; callers may add more
335
+ * tokens. Unknown tokens are server-side lenient-ignored (never a 422).
336
+ */
337
+ async validate(mthdsContents, allowSignatures = false, mthdsSources, render) {
338
+ const body = {
339
+ mthds_contents: mthdsContents,
340
+ allow_signatures: allowSignatures,
341
+ };
342
+ if (mthdsSources !== undefined) {
343
+ body.mthds_sources = mthdsSources;
344
+ }
345
+ body.render = withValidateMarkdownRender(render);
346
+ const res = await this.requestRaw("POST", this.url("validate"), { body });
347
+ if (res.status < 200 || res.status >= 300) {
348
+ this.throwApiResponseError("POST", "validate", res);
349
+ }
350
+ return JSON.parse(res.body);
351
+ }
352
+ /**
353
+ * Validate paired MTHDS files while preserving URI attribution for diagnostics.
354
+ *
355
+ * This adapter intentionally keeps the low-level `validate(...)` payload shape
356
+ * intact for existing consumers. When any file has a URI, every content gets a
357
+ * parallel source label; inline labels are deterministic so the server never
358
+ * sees a length-mismatched `mthds_sources` array.
359
+ */
360
+ async validateFiles(files, options = {}) {
361
+ if (files.length === 0) {
362
+ throw new PipelineRequestError("At least one MTHDS file must be provided to validateFiles().");
363
+ }
364
+ const mthdsContents = files.map((file) => file.content);
365
+ const hasAnyUri = files.some((file) => file.uri !== undefined);
366
+ const mthdsSources = hasAnyUri
367
+ ? files.map((file, index) => file.uri ?? `inline://file-${index + 1}.mthds`)
368
+ : undefined;
369
+ return this.validate(mthdsContents, options.allowSignatures ?? false, mthdsSources, options.render);
370
+ }
371
+ /** The model deck the runner can route to — `GET /v1/models[?type=]`. */
372
+ async models(category) {
373
+ const endpoint = category ? `models?type=${encodeURIComponent(category)}` : "models";
374
+ const res = await this.requestRaw("GET", this.url(endpoint), {
375
+ timeoutMs: POLL_REQUEST_TIMEOUT_MS,
376
+ });
377
+ if (res.status < 200 || res.status >= 300) {
378
+ this.throwApiResponseError("GET", endpoint, res);
379
+ }
380
+ return JSON.parse(res.body);
381
+ }
382
+ /**
383
+ * Protocol + implementation versions — `GET /v1/version` (always public).
384
+ * The handshake for feature detection (hosted extensions or not).
385
+ */
386
+ async version() {
387
+ const res = await this.requestRaw("GET", this.url("version"), {
388
+ timeoutMs: POLL_REQUEST_TIMEOUT_MS,
389
+ });
390
+ if (res.status < 200 || res.status >= 300) {
391
+ this.throwApiResponseError("GET", "version", res);
392
+ }
393
+ return JSON.parse(res.body);
394
+ }
395
+ // ── Build extensions (Pipelex API layer 2 — `/v1/build/*`) ────────
396
+ async buildInputs(request) {
397
+ return this.postApi("build/inputs", request);
398
+ }
399
+ async buildOutput(request) {
400
+ return this.postApi("build/output", request);
401
+ }
402
+ async buildRunner(request) {
403
+ return this.postApi("build/runner", request);
404
+ }
405
+ async concept(request) {
406
+ return this.postApi("build/concept", request);
407
+ }
408
+ async pipeSpec(request) {
409
+ return this.postApi("build/pipe-spec", request);
410
+ }
411
+ // ── Hosted extension: durable run lifecycle (NOT part of the protocol) ──
412
+ /**
413
+ * Fetch a run's status by bare id — `GET /v1/runs/{pipeline_run_id}/status`.
414
+ *
415
+ * Self-healing: a finished-but-unrecorded run resolves to its true terminal
416
+ * status on read. `degraded: true` means Temporal was unreachable and
417
+ * `status` is the last-known value; `retry_after_seconds` carries the
418
+ * server's backoff hint when present. Throws `RunLifecycleUnavailableError`
419
+ * when the lifecycle routes are absent (a bare runner).
420
+ */
421
+ async getRunStatus(runId, options = {}) {
422
+ const endpoint = `${RUNS}/${encodeURIComponent(runId)}/status`;
423
+ const url = this.url(endpoint);
424
+ const res = await this.requestRaw("GET", url, {
425
+ timeoutMs: POLL_REQUEST_TIMEOUT_MS,
426
+ signal: options.signal,
427
+ });
428
+ this.throwIfLifecycleUnavailable(res, url);
429
+ if (res.status < 200 || res.status >= 300) {
430
+ this.throwApiResponseError("GET", endpoint, res);
431
+ }
432
+ const run = JSON.parse(res.body);
433
+ const retryAfter = parseRetryAfter(res.headers);
434
+ return retryAfter !== null ? { ...run, retry_after_seconds: retryAfter } : run;
435
+ }
436
+ /**
437
+ * Single-shot result lookup — `GET /v1/runs/{pipeline_run_id}/results`.
438
+ * Maps the server's poll semantics to a discriminated union:
439
+ * - HTTP 202 → `running` (with the `Retry-After` hint)
440
+ * - HTTP 200 → `completed` (with the result artifacts)
441
+ * - HTTP 409 → `failed` (terminal non-`COMPLETED`)
442
+ * - HTTP 503 → `running` (Temporal degraded — retry, never fail a poller)
443
+ *
444
+ * Throws `RunLifecycleUnavailableError` when the lifecycle routes are absent
445
+ * (a bare runner).
446
+ */
447
+ async getRunResult(runId, options = {}) {
448
+ const endpoint = `${RUNS}/${encodeURIComponent(runId)}/results`;
449
+ const url = this.url(endpoint);
450
+ const res = await this.requestRaw("GET", url, {
451
+ timeoutMs: POLL_REQUEST_TIMEOUT_MS,
452
+ signal: options.signal,
453
+ });
454
+ if (res.status === 202 || res.status === 503) {
455
+ return {
456
+ state: "running",
457
+ pipeline_run_id: runId,
458
+ retry_after_seconds: parseRetryAfter(res.headers) ?? DEFAULT_DEGRADED_RETRY_SECONDS,
459
+ };
460
+ }
461
+ if (res.status === 409) {
462
+ const { serverMessage } = parseErrorBody(res.body);
463
+ const message = serverMessage ?? "Run finished without a result.";
464
+ return {
465
+ state: "failed",
466
+ pipeline_run_id: runId,
467
+ status: extractRunStatusFromMessage(message),
468
+ message,
469
+ };
470
+ }
471
+ this.throwIfLifecycleUnavailable(res, url);
472
+ if (res.status < 200 || res.status >= 300) {
473
+ this.throwApiResponseError("GET", endpoint, res);
474
+ }
475
+ const result = JSON.parse(res.body);
476
+ return { state: "completed", pipeline_run_id: runId, result };
477
+ }
478
+ /** Poll an already-started run (by id) until it reaches a terminal state. */
479
+ async waitForResult(runId, options) {
480
+ return pollUntilResult((id, opts) => this.getRunResult(id, opts), runId, options);
481
+ }
482
+ /**
483
+ * Whether the configured server serves the durable run lifecycle, decided
484
+ * via the `GET /v1/version` handshake and cached for the client's lifetime. A
485
+ * bare `pipelex-api` runner has no run store; anything else is assumed hosted.
486
+ * When the handshake itself fails, assume hosted (the SDK default) and let the
487
+ * start call surface the real error.
488
+ */
489
+ async supportsRunLifecycle() {
490
+ if (this.lifecycleAvailable === undefined) {
491
+ try {
492
+ const info = await this.version();
493
+ const impl = info.implementation;
494
+ this.lifecycleAvailable = !(typeof impl === "string" && impl === BARE_RUNNER_IMPLEMENTATION);
495
+ }
496
+ catch {
497
+ this.lifecycleAvailable = true;
498
+ }
499
+ }
500
+ return this.lifecycleAvailable;
501
+ }
502
+ /**
503
+ * Start a run and wait for its result.
504
+ *
505
+ * - **Hosted** (per the `/v1/version` handshake): durable start + poll, the
506
+ * path that survives the gateway's ~30s synchronous ceiling.
507
+ * - **Bare runner** (no run store): the blocking `POST /v1/execute`, which
508
+ * has no gateway cap off-platform and returns the native `pipe_output`.
509
+ */
510
+ async startAndWaitForResult(options, pollOptions) {
511
+ if (await this.supportsRunLifecycle()) {
512
+ // A runner can look hosted yet lack the durable routes — `implementation`
513
+ // is an extension field, so a compliant bare runner that omits it is
514
+ // misdetected here. Such a runner raises `RunLifecycleUnavailableError`
515
+ // from `start()`, BEFORE any run is created, so falling back to the
516
+ // blocking path cannot double-run. Cache the negative so later calls skip
517
+ // the durable attempt.
518
+ let ack;
519
+ try {
520
+ ack = await this.start(options);
521
+ }
522
+ catch (err) {
523
+ if (!(err instanceof RunLifecycleUnavailableError))
524
+ throw err;
525
+ this.lifecycleAvailable = false;
526
+ return this.executeBlocking(options);
527
+ }
528
+ return this.waitForResult(ack.pipeline_run_id, pollOptions);
529
+ }
530
+ return this.executeBlocking(options);
531
+ }
532
+ // ── Pipelex product surface (hosted management routes) ─────────────────
533
+ //
534
+ // The hosted catalog/account routes the webapp drives. Every one rides the
535
+ // same `{base}/v1/*` surface, `Authorization: Bearer`, org-from-JWT contract
536
+ // as the protocol routes, and maps a non-2xx `problem+json` to a typed
537
+ // `ApiResponseError` (branch on `.code`, not the status).
538
+ /** The authenticated user's profile — `GET /v1/me`. */
539
+ async getMe() {
540
+ return this.requestProduct("GET", "me");
541
+ }
542
+ /** List the caller's saved methods — `GET /v1/methods`. */
543
+ async listMethods() {
544
+ return this.requestProduct("GET", "methods");
545
+ }
546
+ /** Fetch one method by id — `GET /v1/methods/{id}`. */
547
+ async getMethod(methodId) {
548
+ return this.requestProduct("GET", `methods/${encodeURIComponent(methodId)}`);
549
+ }
550
+ /** Create a method — `POST /v1/methods`. */
551
+ async createMethod(input) {
552
+ return this.requestProduct("POST", "methods", input);
553
+ }
554
+ /** Replace a method (rename = changed `name`) — `PUT /v1/methods/{id}`. */
555
+ async updateMethod(methodId, input) {
556
+ return this.requestProduct("PUT", `methods/${encodeURIComponent(methodId)}`, input);
557
+ }
558
+ /** Delete a method — `DELETE /v1/methods/{id}`. */
559
+ async deleteMethod(methodId) {
560
+ await this.requestProduct("DELETE", `methods/${encodeURIComponent(methodId)}`);
561
+ }
562
+ /** The caller's org memberships + active-org feature flags — `GET /v1/organizations/memberships`. */
563
+ async listMemberships() {
564
+ return this.requestProduct("GET", "organizations/memberships");
565
+ }
566
+ /** Create an organization — `POST /v1/organizations`. */
567
+ async createOrganization(input) {
568
+ return this.requestProduct("POST", "organizations", input);
569
+ }
570
+ /** Rename an organization — `PATCH /v1/organizations/{org_id}`. */
571
+ async renameOrganization(orgId, input) {
572
+ return this.requestProduct("PATCH", `organizations/${encodeURIComponent(orgId)}`, input);
573
+ }
574
+ /** The active org's subscription state — `GET /v1/billing/subscription`. */
575
+ async getSubscription() {
576
+ return this.requestProduct("GET", "billing/subscription");
577
+ }
578
+ /** Available plans (with `is_current`) — `GET /v1/billing/plans`. */
579
+ async listPlans() {
580
+ return this.requestProduct("GET", "billing/plans");
581
+ }
582
+ /** Past invoices — `GET /v1/billing/invoices`. */
583
+ async listInvoices() {
584
+ return this.requestProduct("GET", "billing/invoices");
585
+ }
586
+ /** Open a Stripe checkout for a plan — `POST /v1/billing/checkout`. */
587
+ async createCheckout(input) {
588
+ return this.requestProduct("POST", "billing/checkout", input);
589
+ }
590
+ /**
591
+ * Switch the existing subscription's plan — `POST /v1/billing/change-plan`.
592
+ * A 409 `conflict` (`ApiResponseError.code`) means there is no subscription
593
+ * to change — start one via `createCheckout` first.
594
+ */
595
+ async changePlan(input) {
596
+ return this.requestProduct("POST", "billing/change-plan", input);
597
+ }
598
+ /**
599
+ * A Stripe billing-portal session URL — `GET /v1/billing/portal`. A 409
600
+ * `conflict` (`ApiResponseError.code`) means there is no subscription yet.
601
+ */
602
+ async getBillingPortal() {
603
+ return this.requestProduct("GET", "billing/portal");
604
+ }
605
+ /** List the caller's Pipelex API keys — `GET /v1/pipelex-api-keys`. */
606
+ async listPipelexApiKeys() {
607
+ return this.requestProduct("GET", "pipelex-api-keys");
608
+ }
609
+ /**
610
+ * Mint a Pipelex API key — `POST /v1/pipelex-api-keys`. The plaintext
611
+ * `api_key` is returned ONCE. A 409 `pipelex_api_key_limit_reached`
612
+ * (`ApiResponseError.code`) means the per-account key limit is hit.
613
+ */
614
+ async createPipelexApiKey(input) {
615
+ return this.requestProduct("POST", "pipelex-api-keys", input);
616
+ }
617
+ /** Revoke a Pipelex API key — `DELETE /v1/pipelex-api-keys/{id}`. */
618
+ async revokePipelexApiKey(id) {
619
+ await this.requestProduct("DELETE", `pipelex-api-keys/${encodeURIComponent(id)}`);
620
+ }
621
+ /**
622
+ * Rotate a Pipelex API key — `POST /v1/pipelex-api-keys/{id}/rotate` (no
623
+ * body). Returns the new plaintext `api_key` once; the old key stops working.
624
+ */
625
+ async rotatePipelexApiKey(id) {
626
+ return this.requestProduct("POST", `pipelex-api-keys/${encodeURIComponent(id)}/rotate`);
627
+ }
628
+ /**
629
+ * Provision the gateway (LLM inference) API key — `POST /v1/gateway-api-key`.
630
+ * The JSON body is ALWAYS sent (even with `promo_code: null`) — the server
631
+ * 422s an empty body.
632
+ */
633
+ async createGatewayApiKey(input) {
634
+ return this.requestProduct("POST", "gateway-api-key", input);
635
+ }
636
+ /** The gateway key status (`null` until provisioned) — `GET /v1/gateway-api-key`. */
637
+ async getGatewayApiKey() {
638
+ return this.requestProduct("GET", "gateway-api-key");
639
+ }
640
+ /** Submit the onboarding questionnaire — `POST /v1/onboarding/submit`. */
641
+ async submitOnboarding(input) {
642
+ await this.requestProduct("POST", "onboarding/submit", input);
643
+ }
644
+ /** Resolve a storage URI to a presigned URL — `POST /v1/resolve-storage-url`. */
645
+ async resolveStorageUrl(input) {
646
+ return this.requestProduct("POST", "resolve-storage-url", input);
647
+ }
648
+ /** Upload a base64 file — `POST /v1/upload`. */
649
+ async upload(input) {
650
+ return this.requestProduct("POST", "upload", input);
651
+ }
652
+ /** List a method's runs — `GET /v1/runs?method_id={methodId}`. */
653
+ async listRuns(methodId) {
654
+ return this.requestProduct("GET", `runs?method_id=${encodeURIComponent(methodId)}`);
655
+ }
656
+ /** Patch a run's status (admin/manual) — `PUT /v1/runs/{id}`. */
657
+ async updateRun(runId, input) {
658
+ await this.requestProduct("PUT", `runs/${encodeURIComponent(runId)}`, input);
659
+ }
660
+ /**
661
+ * Blocking `POST /v1/execute` adapted onto `RunResults` — the bare-runner
662
+ * path. Forwards every protocol field PLUS the `extra` extension passthrough:
663
+ * an extension-only call (`{ extra }` with no pipe_code/bundle) or a vendor
664
+ * selector riding `extra` must survive this path, not just the durable one.
665
+ */
666
+ async executeBlocking(options) {
667
+ const response = await this.execute({
668
+ pipe_code: options.pipe_code ?? undefined,
669
+ mthds_contents: options.mthds_contents ?? undefined,
670
+ inputs: options.inputs ?? undefined,
671
+ output_name: options.output_name ?? undefined,
672
+ output_multiplicity: options.output_multiplicity ?? undefined,
673
+ dynamic_output_concept_ref: options.dynamic_output_concept_ref ?? undefined,
674
+ extra: options.extra ?? undefined,
675
+ });
676
+ return mapRunResultToRunResults(response);
677
+ }
678
+ }
679
+ // ── Module helpers ────────────────────────────────────────────────────
680
+ /**
681
+ * Whether a base URL is host-only — http/https, no path, query, fragment, or
682
+ * embedded credentials (auth travels in the Authorization header, never the URL).
683
+ * Endpoints compose as `{base}/v1/{endpoint}`, so a path-prefixed base would
684
+ * double the prefix.
685
+ */
686
+ function isValidBaseUrl(value) {
687
+ let parsed;
688
+ try {
689
+ parsed = new URL(value);
690
+ }
691
+ catch {
692
+ return false;
693
+ }
694
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
695
+ return false;
696
+ if (parsed.pathname !== "/" && parsed.pathname !== "")
697
+ return false;
698
+ if (parsed.username || parsed.password)
699
+ return false;
700
+ return !parsed.search && !parsed.hash;
701
+ }
702
+ /**
703
+ * Map the protocol's blocking `POST /v1/execute` response onto the lifecycle's
704
+ * `RunResults`. The bare-runner path returns `pipe_output` (native runner
705
+ * shape); `main_stuff` is a hosted-durable artifact and stays null here.
706
+ * Consumers read `main_stuff ?? pipe_output` (the documented hosted/bare
707
+ * output-shape difference).
708
+ */
709
+ function mapRunResultToRunResults(response) {
710
+ const pipeOutput = response.pipe_output;
711
+ return {
712
+ pipeline_run_id: response.pipeline_run_id,
713
+ main_stuff: null,
714
+ // The bare-runner blocking `pipe_output` carries no graph artifact; the
715
+ // hosted graph_spec rides the durable `/v1/runs/{id}/results` payload.
716
+ graph_spec: null,
717
+ pipe_output: pipeOutput ?? null,
718
+ };
719
+ }
720
+ // The protocol's own request fields — `extra` is for extension args only.
721
+ const PROTOCOL_REQUEST_KEYS = new Set([
722
+ "pipe_code",
723
+ "mthds_contents",
724
+ "inputs",
725
+ "output_name",
726
+ "output_multiplicity",
727
+ "dynamic_output_concept_ref",
728
+ ]);
729
+ /**
730
+ * Validate and copy the generic `extra` passthrough. Extension args ride the
731
+ * request body as top-level properties; protocol args must be passed as named
732
+ * options, never smuggled through `extra`.
733
+ */
734
+ function buildExtensions(extra) {
735
+ if (!extra)
736
+ return {};
737
+ const overlap = Object.keys(extra).filter((key) => PROTOCOL_REQUEST_KEYS.has(key));
738
+ if (overlap.length > 0) {
739
+ throw new PipelineRequestError(`extra carries protocol args [${overlap.sort().join(", ")}] — pass them as named options instead.`);
740
+ }
741
+ return { ...extra };
742
+ }
743
+ function withValidateMarkdownRender(render) {
744
+ const formats = new Set(render ?? []);
745
+ formats.add(VALIDATE_MARKDOWN_RENDER_FORMAT);
746
+ return [...formats];
747
+ }
748
+ // The hosted gateway caps synchronous requests at 30s. A failure at/after this
749
+ // threshold on the blocking execute is the timeout, not a transient outage —
750
+ // the threshold guards against mislabelling a fast 503 (runner genuinely down)
751
+ // as a timeout.
752
+ const GATEWAY_TIMEOUT_THRESHOLD_MS = 28_000;
753
+ function isGatewayTimeout(err, elapsedMs) {
754
+ if (elapsedMs < GATEWAY_TIMEOUT_THRESHOLD_MS)
755
+ return false;
756
+ if (err instanceof ApiResponseError)
757
+ return err.status === 503 || err.status === 504;
758
+ if (err instanceof ApiUnreachableError)
759
+ return err.code === "ABORT_TIMEOUT";
760
+ return false;
761
+ }
762
+ function extractNetworkErrorCode(err) {
763
+ if (err instanceof DOMException && err.name === "TimeoutError") {
764
+ return "ABORT_TIMEOUT";
765
+ }
766
+ if (err instanceof Error) {
767
+ const cause = err.cause;
768
+ if (cause && typeof cause === "object" && "code" in cause) {
769
+ const code = cause.code;
770
+ if (typeof code === "string")
771
+ return code;
772
+ }
773
+ }
774
+ return undefined;
775
+ }
776
+ /**
777
+ * Whether a 404 is an unmatched-route 404 (no platform deployed) rather than
778
+ * the platform's structured run-not-found 404. The platform wraps its 404s in
779
+ * a structured envelope with a stable `code`; a bare runner returns
780
+ * Starlette's default `{"detail": "Not Found"}` (no `code`).
781
+ */
782
+ function isMissingRoute404(body) {
783
+ if (!body)
784
+ return true;
785
+ let parsed;
786
+ try {
787
+ parsed = JSON.parse(body);
788
+ }
789
+ catch {
790
+ return true;
791
+ }
792
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
793
+ return true;
794
+ return !("code" in parsed);
795
+ }
796
+ /** Parse the `Retry-After` header (seconds form, which the platform uses). */
797
+ function parseRetryAfter(headers) {
798
+ const raw = headers.get("retry-after");
799
+ if (!raw)
800
+ return null;
801
+ const seconds = Number(raw);
802
+ return Number.isFinite(seconds) && seconds >= 0 ? seconds : null;
803
+ }
804
+ const KNOWN_RUN_STATUSES = [
805
+ "PENDING",
806
+ "STARTED",
807
+ "RUNNING",
808
+ "COMPLETED",
809
+ "FAILED",
810
+ "CANCELLED",
811
+ "TERMINATED",
812
+ "TIMED_OUT",
813
+ ];
814
+ /**
815
+ * The 409 detail reads "Run finished with status FAILED; no result available".
816
+ * Pull the status word out; default to FAILED if the shape ever changes.
817
+ */
818
+ function extractRunStatusFromMessage(message) {
819
+ const match = message.match(/status\s+([A-Z_]+)/);
820
+ const candidate = match?.[1];
821
+ if (candidate && KNOWN_RUN_STATUSES.includes(candidate)) {
822
+ return candidate;
823
+ }
824
+ return "FAILED";
825
+ }
826
+ /**
827
+ * The API serializes errors as `{"detail": {"error_type": ..., "message": ...}}`
828
+ * (HTTPException with dict detail) or `{"detail": "..."}` (auth 401s and RFC
829
+ * 7807 problems). Both shapes are extracted here. An invalid-bundle 422 problem
830
+ * additionally carries a top-level `validation_errors[]` list (the
831
+ * `ValidateBundleError` extension projected onto the envelope). Falls through
832
+ * silently on non-JSON bodies.
833
+ */
834
+ function parseErrorBody(body) {
835
+ const empty = {
836
+ errorType: undefined,
837
+ serverMessage: undefined,
838
+ validationErrors: undefined,
839
+ code: undefined,
840
+ };
841
+ if (!body)
842
+ return empty;
843
+ let parsed;
844
+ try {
845
+ parsed = JSON.parse(body);
846
+ }
847
+ catch {
848
+ return empty;
849
+ }
850
+ if (!parsed || typeof parsed !== "object") {
851
+ return empty;
852
+ }
853
+ const root = parsed;
854
+ const detail = root.detail;
855
+ let errorType;
856
+ let serverMessage;
857
+ if (detail && typeof detail === "object") {
858
+ const d = detail;
859
+ if (typeof d.error_type === "string")
860
+ errorType = d.error_type;
861
+ if (typeof d.message === "string")
862
+ serverMessage = d.message;
863
+ }
864
+ else if (typeof detail === "string") {
865
+ serverMessage = detail;
866
+ }
867
+ if (errorType === undefined && typeof root.error_type === "string")
868
+ errorType = root.error_type;
869
+ if (serverMessage === undefined && typeof root.message === "string")
870
+ serverMessage = root.message;
871
+ // `validation_errors` rides the problem envelope as a top-level array (the
872
+ // VERBOSE projection of `ErrorReport.validation_errors`, retained under STRICT
873
+ // too — it describes the caller's own bundle, not server internals). Kept as a
874
+ // shallow array guard; per-item shape is the typed `ValidationErrorItem` contract.
875
+ const validationErrors = Array.isArray(root.validation_errors)
876
+ ? root.validation_errors
877
+ : undefined;
878
+ // The product routes' RFC 9457 `problem+json` carries a stable top-level
879
+ // `code` discriminant (`conflict`, `not_found`, …) — the field consumers
880
+ // branch on, decoupled from the HTTP status.
881
+ const code = typeof root.code === "string" ? root.code : undefined;
882
+ return { errorType, serverMessage, validationErrors, code };
883
+ }
884
+ //# sourceMappingURL=client.js.map