@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/LICENSE +21 -0
- package/README.md +75 -0
- package/dist/client.d.ts +297 -0
- package/dist/client.js +884 -0
- package/dist/client.js.map +1 -0
- package/dist/errors.d.ts +132 -0
- package/dist/errors.js +166 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/models.d.ts +176 -0
- package/dist/models.js +11 -0
- package/dist/models.js.map +1 -0
- package/dist/product-models.d.ts +164 -0
- package/dist/product-models.js +13 -0
- package/dist/product-models.js.map +1 -0
- package/dist/runs.d.ts +128 -0
- package/dist/runs.js +92 -0
- package/dist/runs.js.map +1 -0
- package/package.json +56 -0
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
|