@pipelex/sdk 0.41.0 → 0.43.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/docs/errors.md CHANGED
@@ -43,14 +43,14 @@ On an error from a request that may have started a run, `retryable` is `false` w
43
43
  | Class | `errorDomain` | `retryable` | Why |
44
44
  |---|---|---|---|
45
45
  | `ApiResponseError` | the server's, else the fallback | the server's, else no for a sent `input` or `config` domain, else the fallback | See [below](#a-refused-requests-verdict). |
46
- | `RequestArgumentError` | `input` | no | The client refused a call's arguments before sending any request: no run source given to `execute` or `start`, run sources or method selectors that exclude each other, a selector rule of `validate`, an empty `validateFiles`, a reserved key in `extra`, a `publishMethod` without its token, a `getMethodVersion` version that is not a positive integer, a selector `parseMethodSelector` refuses, or an artifact selection that is empty or names an unknown artifact. Its options take an optional `verdict`, and the client declares `config` for a base URL that is not host-only, since it typically comes from `PIPELEX_BASE_URL`. That refusal shows the base URL's scheme and host alone and names the other parts it carried (credentials, a path, a query, a fragment) without their text, since those are where a secret travels in a URL; a value that is not an http or https URL is not shown at all. It is a `PipelineRequestError`, as the bare refusal it replaced was, and a refusal the standard's own run-source check raises keeps that original as `cause`. |
46
+ | `RequestArgumentError` | `input` | no | The client refused a call's arguments before sending any request: no run source given to `execute` or `start`, run sources or method selectors that exclude each other, a selector rule of `validate`, an empty `validateFiles`, a reserved key in `extra`, a `publishMethod` without its token, a `getMethodVersion` version that is not a positive integer, an id a route puts in its path (a method's, an example's, a run's, an organization's or an API key's) that is empty, `.` or `..` or is not a string, since the URL would resolve it into another route's path, or that holds a lone surrogate, which a URL cannot carry, a `createMethodExample` without its `name` or `inputs` or whose `run_id` is given but empty, an `updateMethodExample` naming neither `name` nor `inputs`, a `recordMethodExampleLatestRun` without a run id or a positive integer `input_revision`, a selector `parseMethodSelector` refuses, an artifact selection that is empty or names an unknown artifact, a start's `idempotencyKey` that is not 1 to 255 visible ASCII characters ([below](#a-starts-idempotency-key--idempotencykey)), or a request body JSON cannot carry, such as one holding a cycle, or a `bigint` on a runtime without `JSON.rawJSON` (the body is written with `stringifyExactJson`, which writes a `bigint` as its exact digits; see [exact-integers.md](./exact-integers.md)), with the failure as `cause`. Its options take an optional `verdict`, and the client declares `config` for a base URL that is not host-only, since it typically comes from `PIPELEX_BASE_URL`. That refusal shows the base URL's scheme and host alone and names the other parts it carried (credentials, a path, a query, a fragment) without their text, since those are where a secret travels in a URL; a value that is not an http or https URL is not shown at all. It is a `PipelineRequestError`, as the bare refusal it replaced was, and a refusal the standard's own run-source check raises keeps that original as `cause`. |
47
47
  | `ApiUnreachableError` | `config` | yes | The address or the network must be checked, and a later attempt can get through. With the code `ABORT_TIMEOUT`, the SDK's own request timeout, it is `runtime`: the API took the request and did not answer in time. |
48
48
  | `PipelineExecuteTimeoutError` | `input` | no | The blocking `execute` cannot outlast the gateway's limit; start the run and poll instead. The run goes on, so its `runMayHaveStarted` is always `true`. |
49
49
  | `RunFailedError` | the report's | the report's | See [a failed run](#a-failed-run--runerrorreport). |
50
50
  | `RunTimeoutError`, `RunStillRunningError` | `runtime` | yes | The run is still going; waiting again, by its id, can succeed. |
51
51
  | `RunLifecycleUnavailableError` | `config` | no | The base URL points at a bare runner without the run lifecycle. |
52
52
  | `MissingMainStuffError` | `runtime` | no | The API broke its own contract on a completed run. |
53
- | `PagingNotTerminatingError` | `runtime` | no | A server minting cursors forever; `pageLimit` is the ceiling `iterateMethods` or `iterateRuns` stopped at. |
53
+ | `PagingNotTerminatingError` | `runtime` | no | A server minting cursors forever; `pageLimit` is the ceiling `iterateMethods`, `iterateRuns` or `iterateMethodExamples` stopped at. |
54
54
  | `InputPreparationError` and `EmptyMethodSourceError`, `InvalidLocalSourceError`, `InvalidInputValueError`, `UploadNotAllowedError`, `MethodLoadError`, `RejectedAssetError` | `input` | no | The asset, the inputs or the method selector must change. The family's base takes an optional `verdict` in its options, so a subclass of your own can declare another; `prepareInputs` declares `runtime` for a pipe I/O answer that breaks the route's contract: no boolean `is_valid`, an `input_form` that does not describe the pipe it selected, or a descriptor that is malformed or names a kind this SDK does not know. |
55
55
  | `UnsupportedUploadCapabilityError`, `UploadAuthenticationError` | `config` | no | The deployment or the credential must change. |
56
56
  | `UploadTransportError` | the cause's, else by `code` | the cause's, else by `code` | `uploadFile` wraps the `ApiResponseError` or `ApiUnreachableError` the client threw, and takes its verdict, so a wrapped `402` is `config` and not retryable. Without such a cause, which is always the case from `uploadWithGrant`: `timeout`, `storage_timeout` and `conflict` are `runtime` and retryable, `conflict` because storage documents its `409 ConditionalRequestConflict` as retryable and the grant is not spent by it (a spent grant is the `412` of a `RejectedAssetError` with code `grant_used`); `server_error` is `runtime`, and retryable when [the fallback table](#a-refused-requests-verdict) would call its `status` retryable, any `5xx` but the `501` storage answers for a request it does not implement, or when it carries no status; `unexpected` is `runtime`, and retryable when that table would call its `status` retryable, which is the `408` or `429` storage answers when it times out or throttles the request rather than refusing the file, and not retryable for any other status or none; `unreachable` is `config` and retryable; `redirected` is `config` and not retryable; `invalid_grant_url` is `runtime` and not retryable. |
@@ -58,6 +58,7 @@ On an error from a request that may have started a run, `retryable` is `false` w
58
58
  | `ArtifactAuthenticationError` | `config` | no | The credential must change. Its `verdict` member is the download's result so far, not the error's. |
59
59
  | `ArtifactFetchError` | by `code` | by `code` | `invalid_storage_uri`, `forbidden`, `unsupported_url`, `not_found` and `too_large` are `input` and not retryable; `plain_http_refused` is `config` and not retryable; `redirect_refused` and `store_refused` are `runtime` and not retryable; `timeout` and `network` are `runtime` and retryable; `store_error` is `runtime`, and retryable when [the fallback table](#a-refused-requests-verdict) would call its `status` retryable: a `408`, a `429`, or a `5xx` other than `501`. |
60
60
  | `CodegenLockError` | `input` | no | The lock is the caller's own file. It extends `Error`, since nothing was requested over the wire, and declares the two members itself. |
61
+ | `ExactJsonUnsupportedError` | `config` | no | `parseExactJson` met a number past `Number.MAX_SAFE_INTEGER` on a runtime whose `JSON.parse` hands a reviver no source text, so it could only read it rounded; running on a runtime the package supports fixes it ([exact-integers.md](./exact-integers.md)). It extends `Error`, since nothing was requested, and declares the verdict's members itself. The client reports it as the `ApiResponseError` of an answer it cannot read, with this error as `cause`. |
61
62
 
62
63
  ### A refused request's verdict
63
64
 
@@ -75,6 +76,7 @@ The fallback reads the HTTP status, the platform's `code`, and whether the body
75
76
  | `408`, `429` | `runtime` | yes | Refused for its timing, not its content. |
76
77
  | `409` | `input` | no | The request conflicts with the stored state: a method being deleted, a stale draft token, a method never published addressed by its bare id, a taken label. |
77
78
  | `409` with code `pipelex_api_key_limit_reached` | `config` | no | The organization holds as many keys as it may; someone removes one. |
79
+ | `409` with code `idempotency_key_in_use` | `runtime` | yes | An earlier request under the same idempotency key is still being handled, and the same request sent again under that key is answered with its outcome. |
78
80
  | `501` | `config` | no | The deployment does not implement it, and asking again will not change that. |
79
81
  | `500`, `502`, `503`, `504`, and any `5xx` not listed here | `runtime` | yes | A fault in the service, which may pass. |
80
82
  | Anything else | `runtime` | no | A status no refusal carries, such as the `2xx` of an answer the SDK could not read; nothing says a retry helps. |
@@ -126,10 +128,44 @@ try {
126
128
  }
127
129
  ```
128
130
 
129
- **What lifts the rule** is a start that is safe to retry. Once the platform replays a start sent again under the same `Idempotency-Key`, the SDK can send one on every start and report such a failure as retryable under the same key. The SDK sends no key today.
131
+ **An idempotency key makes the retry safe, and leaves the verdict as it is.** A `start`, and so `startAndWaitForResult`, takes an optional `idempotencyKey`, which the SDK sends as the `Idempotency-Key` header of `POST /v1/start`; without one it sends no header. The hosted API binds the key to the run before it dispatches it, so the same start sent again under the same key is answered with that run and starts nothing, however the first attempt ended. A caller that sent a key therefore answers a failure whose `runMayHaveStarted` is `true` by sending the same start again, with the same key and the same options, and gets the run back instead of paying for a second one ([below](#a-starts-idempotency-key--idempotencykey)). The error does not say so itself: `runMayHaveStarted` stays `true` and `retryable` stays `false` with a key, since the SDK cannot know that the server honours the key (a bare runner ignores it), and a retry under a new key, or with other options, would start a second run. The platform reads its own answer the same way: the `502` with the code `bad_gateway` it answers for a start whose acknowledgement it lost is not retryable whether or not a key was sent, and it names the run that may be running in `problemDocument.pipeline_run_id`.
130
132
 
131
133
  The Python SDK carries the same member as `run_may_have_started`, with the reader `run_may_have_started_of`, and reads every failure the same way; the `run_start` rows of the shared case file hold the two to the same answer for every status.
132
134
 
135
+ ### A start's idempotency key — `idempotencyKey`
136
+
137
+ Generate one key per run you mean to start (a `crypto.randomUUID()` is a good one), and keep it with the options across that start's retries. A key is 1 to 255 visible ASCII characters, `!` to `~`; anything else, an empty string included, is a `RequestArgumentError` before anything is sent, since a start sent without the key its caller gave would lose the protection without a word.
138
+
139
+ ```ts
140
+ import { errorVerdictOf, runMayHaveStartedOf } from "@pipelex/sdk";
141
+
142
+ // One key per run, kept with the options, unchanged, across the start's retries.
143
+ const options = { method_id: "mt_abc123", inputs, idempotencyKey: crypto.randomUUID() };
144
+ try {
145
+ return await client.start(options);
146
+ } catch (err) {
147
+ // Under the same key, a start that may have started a run is answered with that run.
148
+ if (runMayHaveStartedOf(err) || errorVerdictOf(err)?.retryable) return await client.start(options);
149
+ throw err;
150
+ }
151
+ ```
152
+
153
+ What the hosted API answers a start sent under a key:
154
+
155
+ | The start | The answer |
156
+ |---|---|
157
+ | The first under the key | The run starts, the key bound to it before it is dispatched. |
158
+ | The same options under the same key, once the first was answered or bound to its run | The first start's acknowledgement, the run's state as it stands, and nothing starts. A first start refused for its content is refused again; one refused for its timing, such as a `429` in front of the runner, freed the key, and the retry is handled afresh. |
159
+ | The same options under the same key, while the first is still being handled | After a short wait, the first start's answer; failing that, a `409` with the code `idempotency_key_in_use`, `runtime` and retryable, with a `Retry-After` header that `ApiResponseError` does not expose. Nothing ran for it, so its `runMayHaveStarted` is `false`: send it again under the same key in a few seconds, never under a new one, which would start a second run. |
160
+ | Other options under the same key | A `409` with the generic code `conflict`, `input` and not retryable: a key reused for another request is a bug in the caller. Send the new request under a new key. |
161
+ | The same options under a key bound to a run since deleted | A `409` `conflict` too: send it under a new key to start a new run. |
162
+
163
+ `IdempotencyErrorCode` names the code a caller branches on, `idempotency_key_in_use`; the key's other refusals carry the generic `conflict`. The hosted API keeps a key for 24 hours, scoped to the caller and the organization, so a start sent again after that starts a new run.
164
+
165
+ **Keep the body identical.** The hosted API compares the request's bytes, so the retry must carry the same options in the same form. The SDK builds the same body from the same options, but inputs prepared twice are not the same options: `prepareInputs` uploads a local file again and returns a new storage reference each time, so prepare the inputs once and reuse what it returned for every attempt.
166
+
167
+ **A bare runner ignores the key.** It has no idempotency to honour, so a key sent to one protects nothing, and `startAndWaitForResult`'s blocking execute against one sends none.
168
+
133
169
  ## A failed run — `RunErrorReport`
134
170
 
135
171
  A run that ends without completing (`FAILED`, `CANCELLED`, `TERMINATED` or `TIMED_OUT`) has no result, and the results read answers `409`. The body of that `409` says why: its `detail` names the status and then the report's message, its `run_status` member holds the status, and its `error` member holds the run's stored error report, or `null` when the run has none. The SDK reads the status from `run_status`, falling back to the status word of the `detail` sentence on a platform that does not send the member yet, and types the report as `RunErrorReport`.
@@ -223,7 +259,7 @@ So `caller_facing_message` is how a consumer tells the author's copy from the pl
223
259
 
224
260
  Every non-2xx answer from a `/v1` route is a problem document, and the SDK throws an `ApiResponseError` whose fields are its members. Each field is `undefined` when the document did not carry it, and a member of the wrong type (a numeric `type`, a `retryable` of `"no"`, a `user_action` without a `detail`) reads as absent rather than as a wrong value. The nested members are checked one level down, exactly as a [stored report's](#a-failed-run--runerrorreport) are: `providerMetadata` and `migration` keep each named field only when it has its type, `validationErrors` keeps only the items that are objects with a string `category` and `message`, and each item of `errors` keeps its `field`, `code` and `detail` only when they are strings; members the SDK does not name are relayed as sent. The verdict is the exception: `errorDomain` and `retryable` are always decided, the server's value when it sent a valid one and otherwise [as the verdict section says](#a-refused-requests-verdict).
225
261
 
226
- **An answer the SDK could not read is an `ApiResponseError` too.** A `2xx` whose body is not JSON, such as a gateway's HTML page or an empty body where the route answers one, is no result the SDK can hand back, so every route throws an `ApiResponseError` for it instead of letting the runtime's `SyntaxError` escape. So is a `2xx` whose body is JSON but not the object the route answers — `null`, a number, a string, an array — on every protocol, extension and run-lifecycle route, on `health()`, and on the product routes whose answer the SDK or its caller reads before anything else (`listMethods`, `getMethod`, `createMethod`, `writeDraft`, `renameMethod`, `publishMethod`, `listMethodVersions`, `getMethodVersion`, `listRuns`, `getRunDetail`, `upload`, `requestUploadGrant`, `resolveStorageUrls`), instead of the `TypeError` that reading it would raise. So is an object missing the member those reads depend on: a page of `listMethods`, `listMethodVersions` or `listRuns` whose `items` is not an array or whose `next_cursor` is neither a string nor `null`, which `iterateMethods` and `iterateRuns` could not walk or would follow forever; a publish result whose `outcome` is not `published`, `unchanged` or `refused`, or whose `method` is not an object, which a caller branching on `outcome` could not read; an `upload` answer with no non-empty string `uri`, which names no stored file; and a stored method or a published version whose `mthds` field is neither a string nor absent (a `null` one is still no source, which `getMethodClosure` reports as `EmptyMethodSourceError`) or whose `python` field is not the serialized `[{ name, content }]` list, as `getMethod`, `createMethod`, `writeDraft`, `renameMethod`, `publishMethod` and `getMethodVersion` answer it: that is server data no change to the call fixes, not an argument the caller passed. Its `status` is the `2xx`, `responseBody` the raw text, `cause` what made the answer unreadable (the parse failure, or `mthds`'s refusal of the `python` field; nothing for a body that parsed to something the route does not answer), `problemDocument` and the problem members are `undefined`, and its verdict is the fallback's for a status no refusal carries: `runtime`, not retryable. On `start` and `execute` its `runMayHaveStarted` is `true`, since the server accepted the request; that includes a start acknowledgement whose `pipeline_run_id` is not a string, and an `execute` `202` that names no run, which leave the caller a run it could not follow, and an `execute` answer that is not the completed run: one whose `pipeline_run_id` is not a string, or whose `pipe_output` carries no string `pipeline_run_id` or no `working_memory` with `root` and `aliases` objects, the members the Python SDK's model of the answer requires. A product route that answers a `2xx` with no body at all, such as `revokePipelexApiKey`, still resolves to `undefined`, and the other product routes hand their decoded body back as it came.
262
+ **An answer the SDK could not read is an `ApiResponseError` too.** A `2xx` whose body is not JSON, such as a gateway's HTML page or an empty body where the route answers one, is no result the SDK can hand back, so every route throws an `ApiResponseError` for it instead of letting the runtime's `SyntaxError` escape. So is a `2xx` read exactly, such as a run's results or a method example, that holds a number past `Number.MAX_SAFE_INTEGER` on a runtime whose `JSON.parse` hands a reviver no source text, which could only read it rounded; its `cause` is the reader's `ExactJsonUnsupportedError` ([exact-integers.md](./exact-integers.md)). So is a `2xx` whose body is JSON but not the object the route answers — `null`, a number, a string, an array — on every protocol, extension and run-lifecycle route, on `health()`, and on the product routes whose answer the SDK or its caller reads before anything else (`listMethods`, `getMethod`, `createMethod`, `writeDraft`, `renameMethod`, `publishMethod`, `listMethodVersions`, `getMethodVersion`, the method example routes, `listRuns`, `getRunDetail`, `upload`, `requestUploadGrant`, `resolveStorageUrls`), instead of the `TypeError` that reading it would raise. So is an object missing the member those reads depend on: a page of `listMethods`, `listMethodVersions`, `listMethodExamples` or `listRuns` whose `items` is not an array or whose `next_cursor` is neither a string nor `null`, which the iterators could not walk or would follow forever; a publish result whose `outcome` is not `published`, `unchanged` or `refused`, or whose `method` is not an object, and a latest-run result whose `outcome` is not `recorded` or `stale`, or whose `example` is not an object, which a caller branching on `outcome` could not read; a method example, as `createMethodExample`, `getMethodExample` and `updateMethodExample` answer it, as a row of `listMethodExamples` and as a latest-run result's `example`, that misses a field the Python SDK's model of an example requires or holds one of another type, its message naming the field; an `upload` answer with no non-empty string `uri`, which names no stored file; and a stored method or a published version whose `mthds` field is neither a string nor absent (a `null` one is still no source, which `getMethodClosure` reports as `EmptyMethodSourceError`) or whose `python` field is not the serialized `[{ name, content }]` list, as `getMethod`, `createMethod`, `writeDraft`, `renameMethod`, `publishMethod` and `getMethodVersion` answer it: that is server data no change to the call fixes, not an argument the caller passed. Its `status` is the `2xx`, `responseBody` the raw text, `cause` what made the answer unreadable (the parse failure, or `mthds`'s refusal of the `python` field; nothing for a body that parsed to something the route does not answer), `problemDocument` and the problem members are `undefined`, and its verdict is the fallback's for a status no refusal carries: `runtime`, not retryable. On `start` and `execute` its `runMayHaveStarted` is `true`, since the server accepted the request; that includes a start acknowledgement whose `pipeline_run_id` is not a string, and an `execute` `202` that names no run, which leave the caller a run it could not follow, and an `execute` answer that is not the completed run: one whose `pipeline_run_id` is not a string, or whose `pipe_output` carries no string `pipeline_run_id` or no `working_memory` with `root` and `aliases` objects, the members the Python SDK's model of the answer requires. A product route that answers a `2xx` with no body at all, such as `revokePipelexApiKey`, still resolves to `undefined`, and the other product routes hand their decoded body back as it came.
227
263
 
228
264
  | Field | Wire member | Meaning |
229
265
  |---|---|---|
@@ -236,7 +272,7 @@ Every non-2xx answer from a `/v1` route is a problem document, and the SDK throw
236
272
  | `title` | `title` | The stable human label of the error class. |
237
273
  | `serverMessage` | `detail` (or `message`) | The per-occurrence message. |
238
274
  | `instance` | `instance` | The occurrence: a request URN (`urn:pipelex:request:<id>`) or a request path. |
239
- | `code` | `code` | The platform's native code, a closed set (`conflict`, `not_found`, `validation_failed`, `pipelex_api_key_limit_reached`, …), one-to-one with `type`; `MethodErrorCode` names the method ones (see [The method codes](#the-method-codes)). **A branch field for a client of the platform**, absent on a refusal it relays from the runner. |
275
+ | `code` | `code` | The platform's native code, a closed set (`conflict`, `not_found`, `validation_failed`, `pipelex_api_key_limit_reached`, …), one-to-one with `type`; `MethodErrorCode` names the method ones (see [The method codes](#the-method-codes)) and `IdempotencyErrorCode` the idempotency key's ([above](#a-starts-idempotency-key--idempotencykey)). **A branch field for a client of the platform**, absent on a refusal it relays from the runner. |
240
276
  | `errorType` | `error_type` | The runner's native code, its open exception class name. |
241
277
  | `errorCategory`, `model`, `provider`, `providerMetadata` | `error_category`, `model`, `provider`, `provider_metadata` | An inference failure's class and origin, as on the run report. |
242
278
  | `migration` | `migration` | A pending configuration migration that explains the failure. |
@@ -0,0 +1,32 @@
1
+ # Integers past `Number.MAX_SAFE_INTEGER`
2
+
3
+ JavaScript reads every JSON number as a double, which holds an integer exactly only up to `Number.MAX_SAFE_INTEGER` (2^53 - 1). A person's data can hold a larger one, such as `12345678901234567891`, which a double reads as `12345678901234567000`. Read that way and sent back, as a rerun's inputs or an example's, the rounded number would run the method on data nobody gave it, with no sign that anything changed. The Python SDK has no such limit, since Python's integers are exact.
4
+
5
+ So the client reads every answer that carries a person's data with `parseExactJson`, and writes every request body with `stringifyExactJson`. Both live in `src/exact-json.ts`, and the barrel exports them.
6
+
7
+ ## Which reads are exact
8
+
9
+ | Read | What it carries |
10
+ |---|---|
11
+ | `getRunDetail` | The run's `inputs`. |
12
+ | `getRunResult`, and `waitForResult` and `startAndWaitForResult` through it | A completed run's results: `main_stuff`, `working_memory`, `graph_spec` and every other field of the answer. |
13
+ | `execute`, and `startAndWaitForResult`'s blocking path through it | The working memory in `pipe_output`, and so the `main_stuff` resolved from it. |
14
+ | `createMethodExample`, `listMethodExamples`, `iterateMethodExamples`, `getMethodExample`, `updateMethodExample`, `recordMethodExampleLatestRun` | Each example's `inputs` ([method-examples.md](./method-examples.md#numbers-in-an-examples-inputs)). |
15
+
16
+ In these answers, an integer past the safe range in either direction, at any depth, reads as a `bigint` holding its exact digits, and every other value reads as `JSON.parse` reads it. Every other read uses `JSON.parse`: the run status read and the run history, whose fields are the run's bookkeeping rather than its data, and the rest of the product surface.
17
+
18
+ **A `bigint` needs the SDK's writer to become JSON text.** `String(value)` shows its digits, but `JSON.stringify` throws a `TypeError` on one, so code that shows such data as JSON text writes it with `stringifyExactJson(value, 2)`, and reads text a person edited back with `parseExactJson`. That includes a run's `graph_spec`: it is read exactly too, so a graph handed to a renderer can carry a `bigint`. The `pipelex-sdk` command prints a run's output this way. Arithmetic on a `bigint` takes another `bigint`, so code that sums the numbers of an output converts it first, knowing it may lose digits.
19
+
20
+ ## The writer
21
+
22
+ `stringifyExactJson` writes a value as `JSON.stringify` writes it, in one `JSON.stringify` call whose replacer writes each `bigint` as raw JSON text (`JSON.rawJSON`), so its digits go to the platform unchanged. Being one call, it runs every getter and every `toJSON` in the value once, and whatever one of them throws propagates as thrown, never retried. A `toJSON` a `bigint` inherits, such as the common polyfill `BigInt.prototype.toJSON = function () { return this.toString(); }`, runs before the replacer, so the writer looks the `bigint` up in the object or array holding it and writes its digits, never the quoted string the polyfill makes of it; the one `bigint` it cannot look up is one a getter returns, since reading it again would run the getter twice, and that one is written as the polyfill writes it. The client writes every request body this way before the request is made, so a body JSON cannot carry, such as one holding a cycle or whose own `toJSON` throws, is refused with `RequestArgumentError` before anything is sent, the failure as its `cause` ([errors.md](./errors.md)).
23
+
24
+ ## A float keeps its value, not its spelling
25
+
26
+ A number with a fraction or an exponent stays a double. A float with no fractional part, `3.0`, reads as `3` and is written back as `3`, as everywhere in JavaScript: the value is the same, so a run started with it runs as given. Only an example's edit can tell them apart, since the platform compares an example's inputs as canonical JSON ([method-examples.md](./method-examples.md#numbers-in-an-examples-inputs)).
27
+
28
+ ## The runtime it needs
29
+
30
+ Both functions rest on the JSON source-text proposal, which every runtime the package supports (Node 22.12 and later) provides: the reader on the source text `JSON.parse` hands its reviver, the writer on `JSON.rawJSON`. The reader is that one `JSON.parse` call with a reviver, so it reads strings, escapes, repeated keys and `__proto__` exactly as `JSON.parse` does; a text holding no run of 16 digits, and so no integer past the safe range, is read without the reviver.
31
+
32
+ On a runtime without them, neither ever rounds. The reader refuses a number past the safe range that reads as a whole number with `ExactJsonUnsupportedError` (`config`, not retryable), since without the source text it cannot tell `12345678901234567891` from `1.2345678901234567e19`; the client reports it as the `ApiResponseError` of an answer it cannot read (`runtime`, not retryable), with that refusal as `cause`. The writer refuses a `bigint` with the `TypeError` `JSON.stringify` throws for one, which the client reports as `RequestArgumentError` before anything is sent.
@@ -0,0 +1,89 @@
1
+ # A method's saved examples
2
+
3
+ An **example** is a named set of inputs saved on a stored method, so the method can be run on them again in one gesture, together with the latest run of exactly those inputs. A method has any number of them. Like the method itself, an example belongs to the organization: any member reads, edits, runs and deletes any example of any of its methods, and `created_by_user_id` only records who saved it. The client reaches them on the hosted API's `/v1/methods/{method_id}/examples` routes, and the types are in `src/product-models.ts`.
4
+
5
+ ## The example
6
+
7
+ | Field | Meaning |
8
+ |---|---|
9
+ | `example_id` | `ex_…`, minted by the platform. |
10
+ | `method_id` | The bare id of the method it belongs to. |
11
+ | `name` | 1 to 200 characters, not blank. |
12
+ | `inputs` | A JSON object: the inputs as a run of the method takes them. The platform answers them exactly as they were saved; the client reads an integer past `Number.MAX_SAFE_INTEGER` as a `bigint` holding its exact digits and every other number as a double, so `3.0` reads as `3` ([below](#numbers-in-an-examples-inputs)). |
13
+ | `input_revision` | Starts at 1 and rises by one each time the inputs change. |
14
+ | `latest_run_id` | The latest run of the current inputs, or `null`. It may name a run deleted since, whose read then answers `404`. |
15
+ | `created_by_user_id`, `created_at`, `updated_at` | Who saved it, when, and when it last changed. |
16
+
17
+ An example holds no output and no baseline: its result is its latest run's, read by the run's id (`getRunResult`).
18
+
19
+ ## The methods
20
+
21
+ | Method | Route | Answer |
22
+ |---|---|---|
23
+ | `createMethodExample(methodId, { name, inputs, run_id? })` | `POST /v1/methods/{id}/examples` | The `MethodExample` as stored, at revision 1. With `run_id`, a run of this method that used exactly these inputs, that run is its latest. |
24
+ | `listMethodExamples(methodId, { limit?, cursor? })` | `GET /v1/methods/{id}/examples` | One `MethodExamplePage`, `{ items, nextCursor }`, oldest first, inputs included. `limit` is 1 to 100, 50 by default. |
25
+ | `iterateMethodExamples(methodId, { limit? })` | the same, page after page | Every example, oldest first, as an async iterator. A method id it cannot take is refused at the call, before the iterator is pulled. |
26
+ | `getMethodExample(methodId, exampleId)` | `GET /v1/methods/{id}/examples/{example_id}` | The `MethodExample`. |
27
+ | `updateMethodExample(methodId, exampleId, { name?, inputs? })` | `PATCH /v1/methods/{id}/examples/{example_id}` | The `MethodExample` as it now stands. |
28
+ | `recordMethodExampleLatestRun(methodId, exampleId, { run_id, input_revision })` | `PUT /v1/methods/{id}/examples/{example_id}/latest-run` | A `MethodExampleLatestRunResult`: `{ outcome: "recorded" \| "stale", example }`. |
29
+ | `deleteMethodExample(methodId, exampleId)` | `DELETE /v1/methods/{id}/examples/{example_id}` | Nothing; the platform answers `204`. |
30
+
31
+ Every method takes a bare catalog id and refuses a suffixed one (`mt_x@3`) with `RequestArgumentError` before sending, as the other method routes do, and refuses a method id or an example id that is empty, `.` or `..`, or that holds a lone surrogate, the same way, as every route that puts an id in its path does ([What is refused](#what-is-refused-and-where)). The bodies are closed on the platform, so the client sends the fields the route takes and nothing else an untyped caller adds.
32
+
33
+ **Every example the client hands back has been checked.** Each route answering one, the page rows and the latest-run verdict's `example` included, checks it once, against every field the Python SDK's model requires, with the strict type that model holds it to: string `example_id`, `method_id`, `name`, `created_by_user_id`, `created_at` and `updated_at`, object `inputs`, `input_revision` an integer within `Number.MAX_SAFE_INTEGER` either way, and `latest_run_id` present as a string or `null`. An example breaking one is an answer the SDK cannot read, thrown as an `ApiResponseError` (`runtime`, not retryable) whose message names the field, so a missing `latest_run_id` never reads as an example with no latest run, and a revision of `"2"` or `true` is never read as one. Both SDKs refuse the same answers but one: a revision spelled `2.0`, which JavaScript reads as the number `2` and cannot tell from it, while the Python SDK refuses the float.
34
+
35
+ **The list is paged by an opaque cursor.** Pass `nextCursor` back as `cursor`; `null` is the last page. The platform reads examples whole and stops a read at 1 MB, so a page may be short while `nextCursor` is set: a caller that wants every example follows the cursor, which `iterateMethodExamples` does through the loop `iterateMethods` and `iterateRuns` share, stopping on a `null` cursor or one the server failed to advance and throwing `PagingNotTerminatingError` past the same page ceiling.
36
+
37
+ ## Revisions and the latest run
38
+
39
+ **New inputs raise the revision and clear the latest run.** An edit whose `inputs` differ from the stored ones sets `input_revision` to the next number and `latest_run_id` to `null`, since the run no longer shows what the inputs produce. The platform compares the inputs as canonical JSON with sorted keys, so the same inputs sent again, in another key order included, change nothing, while `3` becoming `3.0` is a change. A rename alone keeps both. The edit takes no revision or token of its own: concurrent edits are serialized on the platform, which answers a retryable `503` with nothing written when they kept landing under one.
40
+
41
+ **A run is recorded against the revision the client names.** To run an example and keep its result, read the example, start a run with its `inputs`, and record that run with the `input_revision` you read:
42
+
43
+ ```ts
44
+ const example = await client.getMethodExample(methodId, exampleId);
45
+ const started = await client.start({ method_id: methodId, inputs: example.inputs });
46
+ const verdict = await client.recordMethodExampleLatestRun(methodId, exampleId, {
47
+ run_id: started.pipeline_run_id,
48
+ input_revision: example.input_revision,
49
+ });
50
+ if (verdict.outcome === "stale") {
51
+ // Someone edited the inputs while the run was in flight: verdict.example holds them now.
52
+ }
53
+ ```
54
+
55
+ A run started this way sends the inputs the example holds, digit for digit, an integer past `Number.MAX_SAFE_INTEGER` included ([below](#numbers-in-an-examples-inputs)). The write is conditioned on the example still holding that revision. **A stale run is a verdict, not an error**: a run outlasting an edit is the expected course of events whenever someone edits an example while one of its runs is in flight, so the platform answers a `200` discriminated on `outcome`, as it does for a publish, and both arms carry the example as it stands. Branch on `outcome`; an answer whose `outcome` is neither, or whose `example` is not an example the client can read, is an answer the SDK cannot read, thrown as an `ApiResponseError` (`runtime`, not retryable). The platform trusts the revision and does not compare the run's own inputs with the example's, so record a run only against the revision whose inputs it was started with. A run recorded when the example is created goes in `createMethodExample`'s `run_id` instead.
56
+
57
+ ## Numbers in an example's inputs
58
+
59
+ The platform stores an example's inputs as JSON text and answers them exactly as they were saved, but JavaScript reads every JSON number as a double, which holds an integer exactly only up to `Number.MAX_SAFE_INTEGER` (2^53 - 1). Inputs saved by another client, the Python SDK or a raw request, can hold a larger one, such as `12345678901234567891`, which a double reads as `12345678901234567000`. Sent back, to `updateMethodExample` or as a run's inputs, the rounded number would overwrite the saved inputs, clearing the latest run, or run the method on inputs nobody saved and record that run under the revision that holds the real ones, since the platform trusts the revision it is given.
60
+
61
+ **So the client keeps such an integer exact.** Every answer carrying an example, the page of `listMethodExamples` and the verdict of `recordMethodExampleLatestRun` included, is read with `parseExactJson`, which reads an integer past `Number.MAX_SAFE_INTEGER` in either direction, at any depth of the inputs, as a `bigint` holding its exact digits, and every other value as `JSON.parse` reads it. Every request body is written with `stringifyExactJson`, which writes a `bigint` as those digits where `JSON.stringify` throws, so inputs read from an example go back unchanged, whichever route they are sent to. One such example never makes a page unreadable: the page reads whole, that integer as a `bigint`. A run's inputs and outputs are read the same way, so an example saved from a run's inputs keeps every digit too; [exact-integers.md](./exact-integers.md) lists every exact read, says what a `bigint` asks of code showing the inputs as JSON text (`stringifyExactJson(example.inputs, 2)`, and `parseExactJson` to read edited text back), and what a runtime without the JSON source-text proposal refuses rather than rounds.
62
+
63
+ **A float keeps its value, not its spelling.** A float with no fractional part, `3.0`, reads as `3` and goes back as `3`, as it does everywhere in JavaScript; the value is the same, so a run started with it runs as saved. The platform compares inputs as canonical JSON, though, where `3` and `3.0` differ, so `updateMethodExample` given inputs read from an example that held one raises `input_revision` and clears `latest_run_id`, though the caller changed nothing. Send `inputs` only when the person changed them; a rename sent alone keeps the revision and the latest run. Inputs saved from JavaScript never hold a `3.0`, since JavaScript cannot write one.
64
+
65
+ ## What is refused, and where
66
+
67
+ The client refuses before sending, with `RequestArgumentError` (`input`, not retryable):
68
+
69
+ - a suffixed method id, on every method;
70
+ - a method id or an example id that is empty, `.` or `..`, or that is not a string: the URL resolves a dot segment before the request leaves, so an example id `..` would address the method itself, and deleting it would erase the method, while an empty example id would address the examples list;
71
+ - a method id or an example id holding a lone surrogate, which has no UTF-8 spelling and so cannot enter a URL;
72
+ - a `createMethodExample` without its `name` or its `inputs` (a `null` one counting as not given), or whose `run_id` is given but is not a non-empty string, a `null` reading as not given;
73
+ - an `updateMethodExample` naming neither `name` nor `inputs` (a `null` field counts as not given, as the platform reads it);
74
+ - a `recordMethodExampleLatestRun` whose `run_id` is not a non-empty string or whose `input_revision` is not a positive integer, the example id checked first, then the run id, then the revision;
75
+ - an `iterateMethodExamples` whose method id any method above refuses, at the call itself rather than when the iterator is first pulled;
76
+ - a body JSON cannot carry, such as inputs holding a cycle, or a `bigint` on a runtime without `JSON.rawJSON` ([exact-integers.md](./exact-integers.md)).
77
+
78
+ The platform refuses the rest, each as an `ApiResponseError` whose `code` names it (see [errors.md](./errors.md)):
79
+
80
+ | `code` | Status | When |
81
+ |---|---|---|
82
+ | `not_found` | `404` | An unknown method or example, an example already deleted included. |
83
+ | `run_not_found` | `404` | A `run_id`, on the create or the latest-run write, that is not one of this method's runs in the caller's organization; nothing is written. |
84
+ | `method_being_deleted` | `409` | The method's erasure has started. Its examples are erased with it. |
85
+ | `payload_too_large` | `413` | An example larger than one stored item holds. |
86
+ | `validation_failed` | `422` | A name empty, blank or over 200 characters, inputs that are not a JSON object, a `limit` outside 1 to 100, or an example id longer than one can be (64 characters). |
87
+ | `malformed_request` | `400` | A cursor the listing did not issue for this method. |
88
+
89
+ Every write is `403` to a read-only (`purpose=sandbox`) key, which may still read the examples.
@@ -123,6 +123,8 @@ It is typed `unknown` because the content is polymorphic: a structured output ar
123
123
 
124
124
  For a multiple output that means reading `items` off the object and mapping the generated per-concept parser over its members, because codegen emits a parser per concept and no wrapper type for the envelope. **Do not rely on the parser to catch the mistake for you.** The generated schemas are not strict, so a concept with a required field rejects the envelope loudly, while one whose fields are all optional parses it to `{}` and discards the output in silence.
125
125
 
126
+ **An integer past `Number.MAX_SAFE_INTEGER` reads as a `bigint`.** The results are read with `parseExactJson`, on both paths, so such an integer anywhere in them, in `main_stuff`, `working_memory` or `graph_spec`, holds its exact digits, and an output passed back to `start` goes back as the run produced it. `JSON.stringify` throws on a `bigint`, so code showing the results as JSON text writes them with `stringifyExactJson` ([exact-integers.md](./exact-integers.md)).
127
+
126
128
  ```ts
127
129
  const state = await client.getRunResult(runId);
128
130
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pipelex/sdk",
3
- "version": "0.41.0",
3
+ "version": "0.43.0",
4
4
  "description": "TypeScript SDK for the Pipelex hosted API — execute methods, manage runs, and call the product surface.",
5
5
  "license": "MIT",
6
6
  "repository": {