From ec31f40cc3d297e6d4a17bd0dca9d656ba55174c Mon Sep 17 00:00:00 2001 From: Dominic Baggott Date: Tue, 15 Oct 2024 11:25:23 +0100 Subject: [PATCH 01/17] Update deployments.createPrediction to use wait A first draft of this interface used `block`, but we ended up going with `wait` (as either a boolean or a number) for the predictions.createPrediction method. This commit brings the two implementations inline, removing the undocumented and unintended `block` parameter. --- lib/deployments.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/deployments.js b/lib/deployments.js index 6cab2613..716c8e19 100644 --- a/lib/deployments.js +++ b/lib/deployments.js @@ -9,11 +9,11 @@ const { transformFileInputs } = require("./util"); * @param {object} options.input - Required. An object with the model inputs * @param {string} [options.webhook] - An HTTPS URL for receiving a webhook when the prediction has new output * @param {string[]} [options.webhook_events_filter] - You can change which events trigger webhook requests by specifying webhook events (`start`|`output`|`logs`|`completed`) - * @param {boolean} [options.block] - Whether to wait until the prediction is completed before returning. Defaults to false + * @param {boolean|integer} [options.wait] - Whether to wait until the prediction is completed before returning. If an integer is provided, it will wait for that many seconds. Defaults to false * @returns {Promise} Resolves with the created prediction data */ async function createPrediction(deployment_owner, deployment_name, options) { - const { input, block, ...data } = options; + const { input, wait, ...data } = options; if (data.webhook) { try { @@ -25,8 +25,13 @@ async function createPrediction(deployment_owner, deployment_name, options) { } const headers = {}; - if (block) { - headers["Prefer"] = "wait"; + if (wait) { + if (typeof wait === "number") { + const n = Math.max(1, Math.ceil(Number(wait)) || 1); + headers["Prefer"] = `wait=${n}`; + } else { + headers["Prefer"] = "wait"; + } } const response = await this.request( From 8b75b39ee8b122f1f85487890f91fd7b52b09d5b Mon Sep 17 00:00:00 2001 From: Aron Carroll Date: Mon, 21 Oct 2024 10:26:12 -0700 Subject: [PATCH 02/17] Update README.md to fix examples using FileOutput (#324) --- README.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9671a6d2..8afd43f6 100644 --- a/README.md +++ b/README.md @@ -57,8 +57,11 @@ const model = "stability-ai/stable-diffusion:27b93a2413e7f36cd83da926f3656280b29 const input = { prompt: "a 19th century portrait of a raccoon gentleman wearing a suit", }; -const output = await replicate.run(model, { input }); -// ['https://replicate.delivery/pbxt/GtQb3Sgve42ZZyVnt8xjquFk9EX5LP0fF68NTIWlgBMUpguQA/out-0.png'] +const [output] = await replicate.run(model, { input }); +// FileOutput('https://replicate.delivery/pbxt/GtQb3Sgve42ZZyVnt8xjquFk9EX5LP0fF68NTIWlgBMUpguQA/out-0.png') + +console.log(output.url()); // 'https://replicate.delivery/pbxt/GtQb3Sgve42ZZyVnt8xjquFk9EX5LP0fF68NTIWlgBMUpguQA/out-0.png' +console.log(output.blob()); // Blob ``` You can also run a model in the background: @@ -100,8 +103,8 @@ const model = "nightmareai/real-esrgan:42fed1c4974146d4d2414e2be2c5277c7fcf05fcc const input = { image: await fs.readFile("path/to/image.png"), }; -const output = await replicate.run(model, { input }); -// ['https://replicate.delivery/mgxm/e7b0e122-9daa-410e-8cde-006c7308ff4d/output.png'] +const [output] = await replicate.run(model, { input }); +// FileOutput('https://replicate.delivery/mgxm/e7b0e122-9daa-410e-8cde-006c7308ff4d/output.png') ``` > [!NOTE] From d0316950e0c74eecdd81b3d3ae9c2b1cac519796 Mon Sep 17 00:00:00 2001 From: Aron Carroll Date: Thu, 24 Oct 2024 13:28:42 -0700 Subject: [PATCH 03/17] Document the `FileOutput` and `wait` parameters (#326) This commit takes a first pass at documenting the `FileOutput` and `wait` parameters introduced in 1.0.0 as well as calling out various potential gotchas with the API around URLs and data-uris based on feedback from the issues. --- README.md | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 75 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8afd43f6..08b6e5c8 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,15 @@ console.log(output.url()); // 'https://replicate.delivery/pbxt/GtQb3Sgve42ZZyVnt console.log(output.blob()); // Blob ``` +> [!NOTE] +> A model that outputs file data returns a `FileOutput` object by default. This is an implementation +> of `ReadableStream` that returns the file contents. It has a `.blob()` method for accessing a +> `Blob` representation and a `.url()` method that will return the underlying data-source. +> +> **This data source can be either a remote URL or a data-uri with base64 encoded data. Check +> out the documentation on [creating a prediction](https://replicate.com/docs/topics/predictions/create-a-prediction) +> for more information.** + You can also run a model in the background: ```js @@ -277,6 +286,7 @@ const replicate = new Replicate(options); | `options.baseUrl` | string | Defaults to https://api.replicate.com/v1 | | `options.fetch` | function | Fetch function to use. Defaults to `globalThis.fetch` | | `options.fileEncodingStrategy` | string | Determines the file encoding strategy to use. Possible values: `"default"`, `"upload"`, or `"data-uri"`. Defaults to `"default"` | +| `options.useFileOutput` | boolean | Determines if the `replicate.run()` method should convert file output into `FileOutput` objects | The client makes requests to Replicate's API using @@ -333,8 +343,10 @@ const output = await replicate.run(identifier, options, progress); | ------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `identifier` | string | **Required**. The model version identifier in the format `{owner}/{name}:{version}`, for example `stability-ai/sdxl:8beff3369e81422112d93b89ca01426147de542cd4684c244b673b105188fe5f` | | `options.input` | object | **Required**. An object with the model inputs. | -| `options.wait` | object | Options for waiting for the prediction to finish | -| `options.wait.interval` | number | Polling interval in milliseconds. Defaults to 500 | +| `options.wait` | object | Options for waiting for the prediction to finish | +| `options.wait.type` | `"poll" \| "block"` | `"block"` holds the request open, `"poll"` makes repeated requests to fetch the prediction. Defaults to `"block"` | +| `options.wait.interval` | number | Polling interval in milliseconds. Defaults to 500 | +| `options.wait.timeout` | number | In `"block"` mode determines how long the request will be held open until falling back to polling. Defaults to 60 | | `options.webhook` | string | An HTTPS URL for receiving a webhook when the prediction has new output | | `options.webhook_events_filter` | string[] | An array of events which should trigger [webhooks](https://replicate.com/docs/webhooks). Allowable values are `start`, `output`, `logs`, and `completed` | | `options.signal` | object | An [AbortSignal](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) to cancel the prediction | @@ -342,7 +354,12 @@ const output = await replicate.run(identifier, options, progress); Throws `Error` if the prediction failed. -Returns `Promise` which resolves with the output of running the model. +Returns `Promise` which resolves with the output of running the model. + +> [!NOTE] +> Currently the TypeScript return type of `replicate.run()` is `Promise` this is +> misleading as a model can return array types as well as primative types like strings, +> numbers and booleans. Example: @@ -364,6 +381,26 @@ const onProgress = (prediction) => { const output = await replicate.run(model, { input }, onProgress) ``` +#### Sync vs. Async API (`"poll"` vs. `"block"`) + +The `replicate.run()` API takes advantage of the [Replicate sync API](https://replicate.com/docs/topics/predictions/create-a-prediction) +which is optimized for low latency requests to file models like `black-forest-labs/flux-dev` and +`black-forest-labs/flux-schnell`. When creating a prediction this will hold a connection open to the +server and return a `FileObject` containing the generated file as quickly as possible. + +> [!NOTE] +> In this mode the `url()` method on the `FileObject` may refer to either a remote URL or +> base64 encoded data-uri. The latter is an optimization we make on certain models to deliver +> the files faster to the client. +> +> If you need the prediction URLs for whatever reason you can opt out of the sync mode by +> passing `wait: { "type": "poll" }` to the `run()` method. +> +> ```js +> const output = await replicate.run(model, { input, wait: { type: "poll" } }); +> output.url() // URL +> ``` + ### `replicate.stream` Run a model and stream its output. Unlike [`replicate.prediction.create`](#replicatepredictionscreate), this method returns only the prediction output rather than the entire prediction object. @@ -1192,6 +1229,41 @@ The `replicate.request()` method is used by the other methods to interact with the Replicate API. You can call this method directly to make other requests to the API. +### `FileOutput` + +`FileOutput` is a `ReadableStream` instance that represents a model file output. It can be used to stream file data to disk or as a `Response` body to an HTTP request. + +```javascript +const [output] = await replicate.run("black-forest-labs/flux-schnell", { + input: { prompt: "astronaut riding a rocket like a horse" } +}); + +// To access the file URL (or data-uri): +console.log(output.url()); //=> "http://example.com" + +// To write the file to disk: +fs.writeFile("my-image.png", output); + +// To stream the file back to a browser: +return new Response(output); + +// To read the file in chunks: +for await (const chunk of output) { + console.log(chunk); // UInt8Array +} +``` + +You can opt out of FileOutput by passing `useFileOutput: false` to the `Replicate` constructor: + +```javascript +const replicate = new Replicate({ useFileOutput: false }); +``` + +| method | returns | description | +| -------------------- | ------ | ------------------------------------------------------------ | +| `url()` | string | A `URL` object representing the HTTP URL or data-uri | +| `blob()` | object | A `Blob` instance containing the binary file | + ## Troubleshooting ### Predictions hanging in Next.js From 6a338aa4b4ac79faae48c0d7081694c3e8f42129 Mon Sep 17 00:00:00 2001 From: Radovenchyk Date: Mon, 28 Oct 2024 18:25:33 +0200 Subject: [PATCH 04/17] Update README.md (#329) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 08b6e5c8..9f882472 100644 --- a/README.md +++ b/README.md @@ -219,7 +219,7 @@ To validate webhooks: 1. Check out the [webhooks guide](https://replicate.com/docs/webhooks) to get started. 1. [Retrieve your webhook signing secret](https://replicate.com/docs/webhooks#retrieving-the-webhook-signing-key) and store it in your enviroment. -1. Update your webhook handler to call `validateWebhook(request, secret)`, where `request` is an instance of a [web-standard `Request` object](https://developer.mozilla.org/en-US/docs/Web/API/object, and `secret` is the signing secret for your environment. +1. Update your webhook handler to call `validateWebhook(request, secret)`, where `request` is an instance of a [web-standard `Request` object](https://developer.mozilla.org/en-US/docs/Web/API/object), and `secret` is the signing secret for your environment. Here's an example of how to validate webhooks using Next.js: From 5a98ca7c9dcd33e30aa0be3a95ce990630546284 Mon Sep 17 00:00:00 2001 From: Aron Carroll Date: Wed, 30 Oct 2024 04:20:23 -0700 Subject: [PATCH 05/17] Remove mention of returning data URLs from sync API (#330) This change has now been reverted while we figure out how to provide a more consistent implementation. --- README.md | 22 ++++------------------ 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 9f882472..2fa9f0f6 100644 --- a/README.md +++ b/README.md @@ -69,9 +69,8 @@ console.log(output.blob()); // Blob > of `ReadableStream` that returns the file contents. It has a `.blob()` method for accessing a > `Blob` representation and a `.url()` method that will return the underlying data-source. > -> **This data source can be either a remote URL or a data-uri with base64 encoded data. Check -> out the documentation on [creating a prediction](https://replicate.com/docs/topics/predictions/create-a-prediction) -> for more information.** +> We recommend accessing file data directly either as readable stream or via `.blob()` as the +> `.url()` property may not always return a HTTP URL in future. You can also run a model in the background: @@ -388,19 +387,6 @@ which is optimized for low latency requests to file models like `black-forest-la `black-forest-labs/flux-schnell`. When creating a prediction this will hold a connection open to the server and return a `FileObject` containing the generated file as quickly as possible. -> [!NOTE] -> In this mode the `url()` method on the `FileObject` may refer to either a remote URL or -> base64 encoded data-uri. The latter is an optimization we make on certain models to deliver -> the files faster to the client. -> -> If you need the prediction URLs for whatever reason you can opt out of the sync mode by -> passing `wait: { "type": "poll" }` to the `run()` method. -> -> ```js -> const output = await replicate.run(model, { input, wait: { type: "poll" } }); -> output.url() // URL -> ``` - ### `replicate.stream` Run a model and stream its output. Unlike [`replicate.prediction.create`](#replicatepredictionscreate), this method returns only the prediction output rather than the entire prediction object. @@ -1238,7 +1224,7 @@ const [output] = await replicate.run("black-forest-labs/flux-schnell", { input: { prompt: "astronaut riding a rocket like a horse" } }); -// To access the file URL (or data-uri): +// To access the file URL: console.log(output.url()); //=> "http://example.com" // To write the file to disk: @@ -1261,8 +1247,8 @@ const replicate = new Replicate({ useFileOutput: false }); | method | returns | description | | -------------------- | ------ | ------------------------------------------------------------ | -| `url()` | string | A `URL` object representing the HTTP URL or data-uri | | `blob()` | object | A `Blob` instance containing the binary file | +| `url()` | string | A `URL` object pointing to the underlying data source. Please note that this may not always be an HTTP URL in future. | ## Troubleshooting From 0b7c8ebd9adb30f84ab420d1288e2ce1dac349cf Mon Sep 17 00:00:00 2001 From: Charlie Gleason Date: Fri, 8 Nov 2024 13:46:58 -0800 Subject: [PATCH 06/17] Fix typo on README (#334) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2fa9f0f6..1e14ec24 100644 --- a/README.md +++ b/README.md @@ -596,7 +596,7 @@ const response = await replicate.models.versions.list(model_owner, model_name); ### `replicate.models.versions.get` -Get metatadata for a specific version of a model. +Get metadata for a specific version of a model. ```js const response = await replicate.models.versions.get(model_owner, model_name, version_id); From 720f786b4c6f15576ba1bf331e8c31683204f4bd Mon Sep 17 00:00:00 2001 From: Marius Kleidl <1375043+Acconut@users.noreply.github.com> Date: Wed, 26 Mar 2025 14:23:13 +0100 Subject: [PATCH 07/17] Correct documentaiton for wait mode (#337) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1e14ec24..7d2d61e0 100644 --- a/README.md +++ b/README.md @@ -343,7 +343,7 @@ const output = await replicate.run(identifier, options, progress); | `identifier` | string | **Required**. The model version identifier in the format `{owner}/{name}:{version}`, for example `stability-ai/sdxl:8beff3369e81422112d93b89ca01426147de542cd4684c244b673b105188fe5f` | | `options.input` | object | **Required**. An object with the model inputs. | | `options.wait` | object | Options for waiting for the prediction to finish | -| `options.wait.type` | `"poll" \| "block"` | `"block"` holds the request open, `"poll"` makes repeated requests to fetch the prediction. Defaults to `"block"` | +| `options.wait.mode` | `"poll" \| "block"` | `"block"` holds the request open, `"poll"` makes repeated requests to fetch the prediction. Defaults to `"block"` | | `options.wait.interval` | number | Polling interval in milliseconds. Defaults to 500 | | `options.wait.timeout` | number | In `"block"` mode determines how long the request will be held open until falling back to polling. Defaults to 60 | | `options.webhook` | string | An HTTPS URL for receiving a webhook when the prediction has new output | From 01c4e09b1ac3408beb99589b464d2b81077e4e4b Mon Sep 17 00:00:00 2001 From: Aron Carroll Date: Wed, 26 Mar 2025 12:16:12 +0000 Subject: [PATCH 08/17] Remove usage of webcrypto from `node:crypto` in Node 18 The original code was intended to shim in support for the webcrypto interface in Node 18, which was included indirectly as `webcrypto` in the "node:crypto" module or globally via the `--no-experimental-global-webcrypto` flag. This change has caused many issues with bundlers and static analyzers which do not like the obfuscated call to `require()`. Node 18 will no longer receive security support as of 30 April 2025[1] and as such it feels like we can now drop this workaround in favor of documenting alternative approaches. [1]: https://endoflife.date/nodejs --- lib/util.js | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/lib/util.js b/lib/util.js index bd3c31e9..8dc06c8f 100644 --- a/lib/util.js +++ b/lib/util.js @@ -97,24 +97,6 @@ async function validateWebhook(requestData, secret) { */ async function createHMACSHA256(secret, data) { const encoder = new TextEncoder(); - let crypto = globalThis.crypto; - - // In Node 18 the `crypto` global is behind a --no-experimental-global-webcrypto flag - if (typeof crypto === "undefined" && typeof require === "function") { - // NOTE: Webpack (primarily as it's used by Next.js) and perhaps some - // other bundlers do not currently support the `node` protocol and will - // error if it's found in the source. Other platforms like CloudFlare - // will only support requires when using the node protocol. - // - // As this line is purely to support Node 18.x we make an indirect request - // to the require function which fools Webpack... - // - // We may be able to remove this in future as it looks like Webpack is getting - // support for requiring using the `node:` protocol. - // See: https://github.com/webpack/webpack/issues/18277 - crypto = require.call(null, "node:crypto").webcrypto; - } - const key = await crypto.subtle.importKey( "raw", base64ToBytes(secret), From 2d42001bf1c5c49b78b92914c62e56f99adb4997 Mon Sep 17 00:00:00 2001 From: Aron Carroll Date: Wed, 26 Mar 2025 13:04:05 +0000 Subject: [PATCH 09/17] Fix validateWebhook interfaces and update tests --- index.d.ts | 24 ++++++++++++++---------- index.test.ts | 36 ++++++++++++++++++++++++++++++++++-- lib/util.js | 41 ++++++++++++++++++++++++++++++++++------- 3 files changed, 82 insertions(+), 19 deletions(-) diff --git a/index.d.ts b/index.d.ts index 1e417ae7..5b35bf2d 100644 --- a/index.d.ts +++ b/index.d.ts @@ -340,16 +340,20 @@ declare module "replicate" { } export function validateWebhook( - requestData: - | Request - | { - id?: string; - timestamp?: string; - body: string; - secret?: string; - signature?: string; - }, - secret: string + request: Request, + secret: string, + crypto?: Crypto + ): Promise; + + export function validateWebhook( + requestData: { + id: string; + timestamp: string; + signature: string; + body: string; + secret: string; + }, + crypto?: Crypto ): Promise; export function parseProgressFromLogs(logs: Prediction | string): { diff --git a/index.test.ts b/index.test.ts index c67035a3..f5bd6093 100644 --- a/index.test.ts +++ b/index.test.ts @@ -9,6 +9,7 @@ import Replicate, { } from "replicate"; import nock from "nock"; import { createReadableStream } from "./lib/stream"; +import { webcrypto } from "node:crypto"; let client: Replicate; const BASE_URL = "https://api.replicate.com/v1"; @@ -1779,9 +1780,40 @@ describe("Replicate client", () => { // This is a test secret and should not be used in production const secret = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"; + if (globalThis.crypto) { + const isValid = await validateWebhook(request, secret); + expect(isValid).toBe(true); + } else { + const isValid = await validateWebhook( + request, + secret, + webcrypto as Crypto // Node 18 requires this to be passed manually + ); + expect(isValid).toBe(true); + } + }); - const isValid = await validateWebhook(request, secret); - expect(isValid).toBe(true); + test("Can be used to validate webhook", async () => { + // Test case from https://github.com/svix/svix-webhooks/blob/b41728cd98a7e7004a6407a623f43977b82fcba4/javascript/src/webhook.test.ts#L190-L200 + const requestData = { + id: "msg_p5jXN8AQM9LWM0D4loKWxJek", + timestamp: "1614265330", + signature: "v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=", + body: `{"test": 2432232314}`, + secret: "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw", + }; + + // This is a test secret and should not be used in production + if (globalThis.crypto) { + const isValid = await validateWebhook(requestData); + expect(isValid).toBe(true); + } else { + const isValid = await validateWebhook( + requestData, + webcrypto as Crypto // Node 18 requires this to be passed manually + ); + expect(isValid).toBe(true); + } }); // Add more tests for error handling, edge cases, etc. diff --git a/lib/util.js b/lib/util.js index 8dc06c8f..8ef59ac8 100644 --- a/lib/util.js +++ b/lib/util.js @@ -10,6 +10,7 @@ const { create: createFile } = require("./files"); * @param {string} requestData.body - The raw body of the incoming webhook request. * @param {string} requestData.secret - The webhook secret, obtained from `replicate.webhooks.defaul.secret` method. * @param {string} requestData.signature - The webhook signature header from the incoming request, comprising one or more space-delimited signatures. + * @param {Crypto} [crypto] - An optional `Crypto` implementation that conforms to the [browser Crypto interface](https://developer.mozilla.org/en-US/docs/Web/API/Window/crypto) */ /** @@ -22,6 +23,7 @@ const { create: createFile } = require("./files"); * @param {string} requestData.headers["webhook-signature"] - The webhook signature header from the incoming request, comprising one or more space-delimited signatures * @param {string} requestData.body - The raw body of the incoming webhook request * @param {string} secret - The webhook secret, obtained from `replicate.webhooks.defaul.secret` method + * @param {Crypto} [crypto] - An optional `Crypto` implementation that conforms to the [browser Crypto interface](https://developer.mozilla.org/en-US/docs/Web/API/Window/crypto) */ /** @@ -30,9 +32,13 @@ const { create: createFile } = require("./files"); * @returns {Promise} - True if the signature is valid * @throws {Error} - If the request is missing required headers, body, or secret */ -async function validateWebhook(requestData, secret) { - let { id, timestamp, body, signature } = requestData; - const signingSecret = secret || requestData.secret; +async function validateWebhook(requestData, secretOrCrypto, customCrypto) { + let id; + let body; + let timestamp; + let signature; + let secret; + let crypto = globalThis.crypto; if (requestData && requestData.headers && requestData.body) { if (typeof requestData.headers.get === "function") { @@ -47,6 +53,19 @@ async function validateWebhook(requestData, secret) { signature = requestData.headers["webhook-signature"]; } body = requestData.body; + secret = secretOrCrypto; + if (customCrypto) { + crypto = customCrypto; + } + } else { + id = requestData.id; + body = requestData.body; + timestamp = requestData.timestamp; + signature = requestData.signature; + secret = requestData.secret; + if (secretOrCrypto) { + crypto = secretOrCrypto; + } } if (body instanceof ReadableStream || body.readable) { @@ -71,15 +90,22 @@ async function validateWebhook(requestData, secret) { throw new Error("Missing required body"); } - if (!signingSecret) { + if (!secret) { throw new Error("Missing required secret"); } + if (!crypto) { + throw new Error( + 'Missing `crypto` implementation. If using Node 18 pass in require("node:crypto").webcrypto' + ); + } + const signedContent = `${id}.${timestamp}.${body}`; const computedSignature = await createHMACSHA256( - signingSecret.split("_").pop(), - signedContent + secret.split("_").pop(), + signedContent, + crypto ); const expectedSignatures = signature @@ -94,8 +120,9 @@ async function validateWebhook(requestData, secret) { /** * @param {string} secret - base64 encoded string * @param {string} data - text body of request + * @param {Crypto} crypto - an implementation of the web Crypto api */ -async function createHMACSHA256(secret, data) { +async function createHMACSHA256(secret, data, crypto) { const encoder = new TextEncoder(); const key = await crypto.subtle.importKey( "raw", From 37aa363e05590a738e2e0aef145645f641c5047e Mon Sep 17 00:00:00 2001 From: Aron Carroll Date: Wed, 26 Mar 2025 13:04:18 +0000 Subject: [PATCH 10/17] Document the validateWebhook interface on Node 18 --- README.md | 7 +++++++ lib/util.js | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/README.md b/README.md index 7d2d61e0..67c8cab1 100644 --- a/README.md +++ b/README.md @@ -264,6 +264,13 @@ const requestData = { const webhookIsValid = await validateWebhook(requestData); ``` +> [!NOTE] +> The `validateWebhook` function uses the global `crypto` API available in most JavaScript runtimes. Node <= 18 does not provide this global so in this case you need to either call node with the `--no-experimental-global-webcrypto` or provide the `webcrypto` module manually. +> ```js +> const crypto = require("node:crypto").webcrypto; +> const webhookIsValid = await valdiateWebhook(requestData, crypto); +> ``` + ## TypeScript The `Replicate` constructor and all `replicate.*` methods are fully typed. diff --git a/lib/util.js b/lib/util.js index 8ef59ac8..2fa49190 100644 --- a/lib/util.js +++ b/lib/util.js @@ -53,6 +53,12 @@ async function validateWebhook(requestData, secretOrCrypto, customCrypto) { signature = requestData.headers["webhook-signature"]; } body = requestData.body; + if (typeof secretOrCrypto !== "string") { + throw new Error( + "Unexpected value for secret passed to validateWebhook, expected a string" + ); + } + secret = secretOrCrypto; if (customCrypto) { crypto = customCrypto; From ac5caba3fd6c931803923689ae8fb01486b64646 Mon Sep 17 00:00:00 2001 From: Aron Carroll Date: Wed, 26 Mar 2025 17:30:42 +0000 Subject: [PATCH 11/17] Add support for `AbortSignal` to all API methods (#339) This PR adds support for passing `AbortSignal` to all API methods that make HTTP requests, these are passed directly into the native `fetch()` implementation, so it's up to the user to handle the `AbortError` raised, if any. ```js const controller = new AbortController(); try { const prediction = await replicate.predictions.create({ version: 'xyz', ..., signal: controller.signal, }); } catch (err) { if (err instanceof DOMException && err.name === "AbortError") { ... } } ``` The `paginate` function also checks to see whether the signal was aborted before proceeding to the next iteration. If so it returns immediately to avoid making a redundant fetch call. This allows the client to take advantage of various frameworks that provide an `AbortSignal` instance to tear down any in flight requests. --- README.md | 14 ++-- index.d.ts | 114 +++++++++++++++++++++++--------- index.js | 17 +++-- index.test.ts | 84 +++++++++++++++++++++++ integration/next/pages/index.js | 8 +-- lib/accounts.js | 5 +- lib/collections.js | 10 ++- lib/deployments.js | 40 +++++++++-- lib/files.js | 20 ++++-- lib/hardware.js | 5 +- lib/models.js | 35 ++++++++-- lib/predictions.js | 20 ++++-- lib/trainings.js | 19 ++++-- lib/util.js | 12 ++-- lib/webhooks.js | 5 +- 15 files changed, 326 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index 67c8cab1..cf12865c 100644 --- a/README.md +++ b/README.md @@ -1213,15 +1213,21 @@ Low-level method used by the Replicate client to interact with API endpoints. const response = await replicate.request(route, parameters); ``` -| name | type | description | -| -------------------- | ------ | ------------------------------------------------------------ | -| `options.route` | string | Required. REST API endpoint path. | -| `options.parameters` | object | URL, query, and request body parameters for the given route. | +| name | type | description | +| -------------------- | ------------------- | ----------- | +| `options.route` | `string` | Required. REST API endpoint path. +| `options.params` | `object` | URL query parameters for the given route. | +| `options.method` | `string` | HTTP method for the given route. | +| `options.headers` | `object` | Additional HTTP headers for the given route. | +| `options.data` | `object \| FormData` | Request body. | +| `options.signal` | `AbortSignal` | Optional `AbortSignal`. | The `replicate.request()` method is used by the other methods to interact with the Replicate API. You can call this method directly to make other requests to the API. +The method accepts an `AbortSignal` which can be used to cancel the request in flight. + ### `FileOutput` `FileOutput` is a `ReadableStream` instance that represents a model file output. It can be used to stream file data to disk or as a `Response` body to an HTTP request. diff --git a/index.d.ts b/index.d.ts index 5b35bf2d..709f4665 100644 --- a/index.d.ts +++ b/index.d.ts @@ -183,10 +183,14 @@ declare module "replicate" { headers?: object | Headers; params?: object; data?: object; + signal?: AbortSignal; } ): Promise; - paginate(endpoint: () => Promise>): AsyncGenerator<[T]>; + paginate( + endpoint: () => Promise>, + options?: { signal?: AbortSignal } + ): AsyncGenerator; wait( prediction: Prediction, @@ -197,12 +201,15 @@ declare module "replicate" { ): Promise; accounts: { - current(): Promise; + current(options?: { signal?: AbortSignal }): Promise; }; collections: { - list(): Promise>; - get(collection_slug: string): Promise; + list(options?: { signal?: AbortSignal }): Promise>; + get( + collection_slug: string, + options?: { signal?: AbortSignal } + ): Promise; }; deployments: { @@ -217,21 +224,26 @@ declare module "replicate" { webhook?: string; webhook_events_filter?: WebhookEventType[]; wait?: number | boolean; + signal?: AbortSignal; } ): Promise; }; get( deployment_owner: string, - deployment_name: string + deployment_name: string, + options?: { signal?: AbortSignal } + ): Promise; + create( + deployment_config: { + name: string; + model: string; + version: string; + hardware: string; + min_instances: number; + max_instances: number; + }, + options?: { signal?: AbortSignal } ): Promise; - create(deployment_config: { - name: string; - model: string; - version: string; - hardware: string; - min_instances: number; - max_instances: number; - }): Promise; update( deployment_owner: string, deployment_name: string, @@ -245,32 +257,45 @@ declare module "replicate" { | { hardware: string } | { min_instances: number } | { max_instances: number } - ) + ), + options?: { signal?: AbortSignal } ): Promise; delete( deployment_owner: string, - deployment_name: string + deployment_name: string, + options?: { signal?: AbortSignal } ): Promise; - list(): Promise>; + list(options?: { signal?: AbortSignal }): Promise>; }; files: { create( file: Blob | Buffer, - metadata?: Record + metadata?: Record, + options?: { signal?: AbortSignal } ): Promise; - list(): Promise>; - get(file_id: string): Promise; - delete(file_id: string): Promise; + list(options?: { signal?: AbortSignal }): Promise>; + get( + file_id: string, + options?: { signal?: AbortSignal } + ): Promise; + delete( + file_id: string, + options?: { signal?: AbortSignal } + ): Promise; }; hardware: { - list(): Promise; + list(options?: { signal?: AbortSignal }): Promise; }; models: { - get(model_owner: string, model_name: string): Promise; - list(): Promise>; + get( + model_owner: string, + model_name: string, + options?: { signal?: AbortSignal } + ): Promise; + list(options?: { signal?: AbortSignal }): Promise>; create( model_owner: string, model_name: string, @@ -282,17 +307,26 @@ declare module "replicate" { paper_url?: string; license_url?: string; cover_image_url?: string; + signal?: AbortSignal; } ): Promise; versions: { - list(model_owner: string, model_name: string): Promise; + list( + model_owner: string, + model_name: string, + options?: { signal?: AbortSignal } + ): Promise; get( model_owner: string, model_name: string, - version_id: string + version_id: string, + options?: { signal?: AbortSignal } ): Promise; }; - search(query: string): Promise>; + search( + query: string, + options?: { signal?: AbortSignal } + ): Promise>; }; predictions: { @@ -306,11 +340,18 @@ declare module "replicate" { webhook?: string; webhook_events_filter?: WebhookEventType[]; wait?: boolean | number; + signal?: AbortSignal; } & ({ version: string } | { model: string }) ): Promise; - get(prediction_id: string): Promise; - cancel(prediction_id: string): Promise; - list(): Promise>; + get( + prediction_id: string, + options?: { signal?: AbortSignal } + ): Promise; + cancel( + prediction_id: string, + options?: { signal?: AbortSignal } + ): Promise; + list(options?: { signal?: AbortSignal }): Promise>; }; trainings: { @@ -323,17 +364,24 @@ declare module "replicate" { input: object; webhook?: string; webhook_events_filter?: WebhookEventType[]; + signal?: AbortSignal; } ): Promise; - get(training_id: string): Promise; - cancel(training_id: string): Promise; - list(): Promise>; + get( + training_id: string, + options?: { signal?: AbortSignal } + ): Promise; + cancel( + training_id: string, + options?: { signal?: AbortSignal } + ): Promise; + list(options?: { signal?: AbortSignal }): Promise>; }; webhooks: { default: { secret: { - get(): Promise; + get(options?: { signal?: AbortSignal }): Promise; }; }; }; diff --git a/index.js b/index.js index a5755d91..b1248e70 100644 --- a/index.js +++ b/index.js @@ -225,6 +225,7 @@ class Replicate { * @param {object} [options.params] - Query parameters * @param {object|Headers} [options.headers] - HTTP headers * @param {object} [options.data] - Body parameters + * @param {AbortSignal} [options.signal] - AbortSignal to cancel the request * @returns {Promise} - Resolves with the response object * @throws {ApiError} If the request failed */ @@ -241,7 +242,7 @@ class Replicate { ); } - const { method = "GET", params = {}, data } = options; + const { method = "GET", params = {}, data, signal } = options; for (const [key, value] of Object.entries(params)) { url.searchParams.append(key, value); @@ -273,6 +274,7 @@ class Replicate { method, headers, body, + signal, }; const shouldRetry = @@ -354,15 +356,20 @@ class Replicate { * console.log(page); * } * @param {Function} endpoint - Function that returns a promise for the next page of results + * @param {object} [options] + * @param {AbortSignal} [options.signal] - AbortSignal to cancel the request. * @yields {object[]} Each page of results */ - async *paginate(endpoint) { + async *paginate(endpoint, options = {}) { const response = await endpoint(); yield response.results; - if (response.next) { + if (response.next && !(options.signal && options.signal.aborted)) { const nextPage = () => - this.request(response.next, { method: "GET" }).then((r) => r.json()); - yield* this.paginate(nextPage); + this.request(response.next, { + method: "GET", + signal: options.signal, + }).then((r) => r.json()); + yield* this.paginate(nextPage, options); } } diff --git a/index.test.ts b/index.test.ts index f5bd6093..4905908a 100644 --- a/index.test.ts +++ b/index.test.ts @@ -99,6 +99,90 @@ describe("Replicate client", () => { }); }); + describe("paginate", () => { + test("pages through results", async () => { + nock(BASE_URL) + .get("/collections") + .reply(200, { + results: [ + { + name: "Super resolution", + slug: "super-resolution", + description: + "Upscaling models that create high-quality images from low-quality images.", + }, + ], + next: `${BASE_URL}/collections?page=2`, + previous: null, + }); + nock(BASE_URL) + .get("/collections?page=2") + .reply(200, { + results: [ + { + name: "Image classification", + slug: "image-classification", + description: "Models that classify images.", + }, + ], + next: null, + previous: null, + }); + + const iterator = client.paginate(client.collections.list); + + const firstPage = (await iterator.next()).value; + expect(firstPage.length).toBe(1); + + const secondPage = (await iterator.next()).value; + expect(secondPage.length).toBe(1); + }); + + test("accepts an abort signal", async () => { + nock(BASE_URL) + .get("/collections") + .reply(200, { + results: [ + { + name: "Super resolution", + slug: "super-resolution", + description: + "Upscaling models that create high-quality images from low-quality images.", + }, + ], + next: `${BASE_URL}/collections?page=2`, + previous: null, + }); + nock(BASE_URL) + .get("/collections?page=2") + .reply(200, { + results: [ + { + name: "Image classification", + slug: "image-classification", + description: "Models that classify images.", + }, + ], + next: null, + previous: null, + }); + + const controller = new AbortController(); + const iterator = client.paginate(client.collections.list, { + signal: controller.signal, + }); + + const firstIteration = await iterator.next(); + expect(firstIteration.value.length).toBe(1); + + controller.abort(); + + const secondIteration = await iterator.next(); + expect(secondIteration.value).toBeUndefined(); + expect(secondIteration.done).toBe(true); + }); + }); + describe("account.get", () => { test("Calls the correct API route", async () => { nock(BASE_URL).get("/account").reply(200, { diff --git a/integration/next/pages/index.js b/integration/next/pages/index.js index fc9581a7..09124388 100644 --- a/integration/next/pages/index.js +++ b/integration/next/pages/index.js @@ -1,5 +1,5 @@ export default () => ( -
-

Welcome to Next.js

-
-) +
+

Welcome to Next.js

+
+); diff --git a/lib/accounts.js b/lib/accounts.js index b3bbd9fe..72a94afa 100644 --- a/lib/accounts.js +++ b/lib/accounts.js @@ -1,11 +1,14 @@ /** * Get the current account * + * @param {object} [options] + * @param {AbortSignal} [options.signal] - An optional AbortSignal * @returns {Promise} Resolves with the current account */ -async function getCurrentAccount() { +async function getCurrentAccount({ signal } = {}) { const response = await this.request("/account", { method: "GET", + signal, }); return response.json(); diff --git a/lib/collections.js b/lib/collections.js index 9332aaab..7b8e8f11 100644 --- a/lib/collections.js +++ b/lib/collections.js @@ -2,11 +2,14 @@ * Fetch a model collection * * @param {string} collection_slug - Required. The slug of the collection. See http://replicate.com/collections + * @param {object} [options] + * @param {AbortSignal} [options.signal] - An optional AbortSignal * @returns {Promise} - Resolves with the collection data */ -async function getCollection(collection_slug) { +async function getCollection(collection_slug, { signal } = {}) { const response = await this.request(`/collections/${collection_slug}`, { method: "GET", + signal, }); return response.json(); @@ -15,11 +18,14 @@ async function getCollection(collection_slug) { /** * Fetch a list of model collections * + * @param {object} [options] + * @param {AbortSignal} [options.signal] - An optional AbortSignal * @returns {Promise} - Resolves with the collections data */ -async function listCollections() { +async function listCollections({ signal } = {}) { const response = await this.request("/collections", { method: "GET", + signal, }); return response.json(); diff --git a/lib/deployments.js b/lib/deployments.js index 716c8e19..d45c4f39 100644 --- a/lib/deployments.js +++ b/lib/deployments.js @@ -10,10 +10,11 @@ const { transformFileInputs } = require("./util"); * @param {string} [options.webhook] - An HTTPS URL for receiving a webhook when the prediction has new output * @param {string[]} [options.webhook_events_filter] - You can change which events trigger webhook requests by specifying webhook events (`start`|`output`|`logs`|`completed`) * @param {boolean|integer} [options.wait] - Whether to wait until the prediction is completed before returning. If an integer is provided, it will wait for that many seconds. Defaults to false + * @param {AbortSignal} [options.signal] - An optional AbortSignal * @returns {Promise} Resolves with the created prediction data */ async function createPrediction(deployment_owner, deployment_name, options) { - const { input, wait, ...data } = options; + const { input, wait, signal, ...data } = options; if (data.webhook) { try { @@ -47,6 +48,7 @@ async function createPrediction(deployment_owner, deployment_name, options) { this.fileEncodingStrategy ), }, + signal, } ); @@ -58,13 +60,20 @@ async function createPrediction(deployment_owner, deployment_name, options) { * * @param {string} deployment_owner - Required. The username of the user or organization who owns the deployment * @param {string} deployment_name - Required. The name of the deployment + * @param {object] [options] + * @param {AbortSignal} [options.signal] - An optional AbortSignal * @returns {Promise} Resolves with the deployment data */ -async function getDeployment(deployment_owner, deployment_name) { +async function getDeployment( + deployment_owner, + deployment_name, + { signal } = {} +) { const response = await this.request( `/deployments/${deployment_owner}/${deployment_name}`, { method: "GET", + signal, } ); @@ -84,13 +93,16 @@ async function getDeployment(deployment_owner, deployment_name) { /** * Create a deployment * - * @param {DeploymentCreateRequest} config - Required. The deployment config. + * @param {DeploymentCreateRequest} deployment_config - Required. The deployment config. + * @param {object} [options] + * @param {AbortSignal} [options.signal] - An optional AbortSignal * @returns {Promise} Resolves with the deployment data */ -async function createDeployment(deployment_config) { +async function createDeployment(deployment_config, { signal } = {}) { const response = await this.request("/deployments", { method: "POST", data: deployment_config, + signal, }); return response.json(); @@ -110,18 +122,22 @@ async function createDeployment(deployment_config) { * @param {string} deployment_owner - Required. The username of the user or organization who owns the deployment * @param {string} deployment_name - Required. The name of the deployment * @param {DeploymentUpdateRequest} deployment_config - Required. The deployment changes. + * @param {object} [options] + * @param {AbortSignal} [options.signal] - An optional AbortSignal * @returns {Promise} Resolves with the deployment data */ async function updateDeployment( deployment_owner, deployment_name, - deployment_config + deployment_config, + { signal } = {} ) { const response = await this.request( `/deployments/${deployment_owner}/${deployment_name}`, { method: "PATCH", data: deployment_config, + signal, } ); @@ -133,13 +149,20 @@ async function updateDeployment( * * @param {string} deployment_owner - Required. The username of the user or organization who owns the deployment * @param {string} deployment_name - Required. The name of the deployment + * @param {object} [options] + * @param {AbortSignal} [options.signal] - An optional AbortSignal * @returns {Promise} Resolves with true if the deployment was deleted */ -async function deleteDeployment(deployment_owner, deployment_name) { +async function deleteDeployment( + deployment_owner, + deployment_name, + { signal } = {} +) { const response = await this.request( `/deployments/${deployment_owner}/${deployment_name}`, { method: "DELETE", + signal, } ); @@ -149,11 +172,14 @@ async function deleteDeployment(deployment_owner, deployment_name) { /** * List all deployments * + * @param {object} [options] + * @param {AbortSignal} [options.signal] - An optional AbortSignal * @returns {Promise} - Resolves with a page of deployments */ -async function listDeployments() { +async function listDeployments({ signal } = {}) { const response = await this.request("/deployments", { method: "GET", + signal, }); return response.json(); diff --git a/lib/files.js b/lib/files.js index de49c580..c8101398 100644 --- a/lib/files.js +++ b/lib/files.js @@ -3,9 +3,11 @@ * * @param {object} file - Required. The file object. * @param {object} metadata - Optional. User-provided metadata associated with the file. + * @param {object} [options] + * @param {AbortSignal} [options.signal] - An optional AbortSignal * @returns {Promise} - Resolves with the file data */ -async function createFile(file, metadata = {}) { +async function createFile(file, metadata = {}, { signal } = {}) { const form = new FormData(); let filename; @@ -36,6 +38,7 @@ async function createFile(file, metadata = {}) { headers: { "Content-Type": "multipart/form-data", }, + signal, }); return response.json(); @@ -44,11 +47,14 @@ async function createFile(file, metadata = {}) { /** * List all files * + * @param {object} [options] + * @param {AbortSignal} [options.signal] - An optional AbortSignal * @returns {Promise} - Resolves with the files data */ -async function listFiles() { +async function listFiles({ signal } = {}) { const response = await this.request("/files", { method: "GET", + signal, }); return response.json(); @@ -58,11 +64,14 @@ async function listFiles() { * Get a file * * @param {string} file_id - Required. The ID of the file. + * @param {object} [options] + * @param {AbortSignal} [options.signal] - An optional AbortSignal * @returns {Promise} - Resolves with the file data */ -async function getFile(file_id) { +async function getFile(file_id, { signal } = {}) { const response = await this.request(`/files/${file_id}`, { method: "GET", + signal, }); return response.json(); @@ -72,11 +81,14 @@ async function getFile(file_id) { * Delete a file * * @param {string} file_id - Required. The ID of the file. + * @param {object} [options] + * @param {AbortSignal} [options.signal] - An optional AbortSignal * @returns {Promise} - Resolves with true if the file was deleted */ -async function deleteFile(file_id) { +async function deleteFile(file_id, { signal } = {}) { const response = await this.request(`/files/${file_id}`, { method: "DELETE", + signal, }); return response.status === 204; diff --git a/lib/hardware.js b/lib/hardware.js index d717548f..e981b1fa 100644 --- a/lib/hardware.js +++ b/lib/hardware.js @@ -1,11 +1,14 @@ /** * List hardware * + * @param {object} [options] + * @param {AbortSignal} [options.signal] - An optional AbortSignal * @returns {Promise} Resolves with the array of hardware */ -async function listHardware() { +async function listHardware({ signal } = {}) { const response = await this.request("/hardware", { method: "GET", + signal, }); return response.json(); diff --git a/lib/models.js b/lib/models.js index 272d9edb..4a3fcdde 100644 --- a/lib/models.js +++ b/lib/models.js @@ -3,11 +3,14 @@ * * @param {string} model_owner - Required. The name of the user or organization that owns the model * @param {string} model_name - Required. The name of the model + * @param {object} [options] + * @param {AbortSignal} [options.signal] - An optional AbortSignal * @returns {Promise} Resolves with the model data */ -async function getModel(model_owner, model_name) { +async function getModel(model_owner, model_name, { signal } = {}) { const response = await this.request(`/models/${model_owner}/${model_name}`, { method: "GET", + signal, }); return response.json(); @@ -18,13 +21,16 @@ async function getModel(model_owner, model_name) { * * @param {string} model_owner - Required. The name of the user or organization that owns the model * @param {string} model_name - Required. The name of the model + * @param {object} [options] + * @param {AbortSignal} [options.signal] - An optional AbortSignal * @returns {Promise} Resolves with the list of model versions */ -async function listModelVersions(model_owner, model_name) { +async function listModelVersions(model_owner, model_name, { signal } = {}) { const response = await this.request( `/models/${model_owner}/${model_name}/versions`, { method: "GET", + signal, } ); @@ -37,13 +43,21 @@ async function listModelVersions(model_owner, model_name) { * @param {string} model_owner - Required. The name of the user or organization that owns the model * @param {string} model_name - Required. The name of the model * @param {string} version_id - Required. The model version + * @param {object} [options] + * @param {AbortSignal} [options.signal] - An optional AbortSignal * @returns {Promise} Resolves with the model version data */ -async function getModelVersion(model_owner, model_name, version_id) { +async function getModelVersion( + model_owner, + model_name, + version_id, + { signal } = {} +) { const response = await this.request( `/models/${model_owner}/${model_name}/versions/${version_id}`, { method: "GET", + signal, } ); @@ -53,11 +67,14 @@ async function getModelVersion(model_owner, model_name, version_id) { /** * List all public models * + * @param {object} [options] + * @param {AbortSignal} [options.signal] - An optional AbortSignal * @returns {Promise} Resolves with the model version data */ -async function listModels() { +async function listModels({ signal } = {}) { const response = await this.request("/models", { method: "GET", + signal, }); return response.json(); @@ -76,14 +93,17 @@ async function listModels() { * @param {string} options.paper_url - A URL for the model's paper. * @param {string} options.license_url - A URL for the model's license. * @param {string} options.cover_image_url - A URL for the model's cover image. This should be an image file. + * @param {AbortSignal} [options.signal] - An optional AbortSignal * @returns {Promise} Resolves with the model version data */ async function createModel(model_owner, model_name, options) { - const data = { owner: model_owner, name: model_name, ...options }; + const { signal, ...rest } = options; + const data = { owner: model_owner, name: model_name, ...rest }; const response = await this.request("/models", { method: "POST", data, + signal, }); return response.json(); @@ -93,15 +113,18 @@ async function createModel(model_owner, model_name, options) { * Search for public models * * @param {string} query - The search query + * @param {object} [options] + * @param {AbortSignal} [options.signal] - An optional AbortSignal * @returns {Promise} Resolves with a page of models matching the search query */ -async function search(query) { +async function search(query, { signal } = {}) { const response = await this.request("/models", { method: "QUERY", headers: { "Content-Type": "text/plain", }, data: query, + signal, }); return response.json(); diff --git a/lib/predictions.js b/lib/predictions.js index f8e1c5ac..708d04b3 100644 --- a/lib/predictions.js +++ b/lib/predictions.js @@ -10,10 +10,11 @@ const { transformFileInputs } = require("./util"); * @param {string} [options.webhook] - An HTTPS URL for receiving a webhook when the prediction has new output * @param {string[]} [options.webhook_events_filter] - You can change which events trigger webhook requests by specifying webhook events (`start`|`output`|`logs`|`completed`) * @param {boolean|integer} [options.wait] - Whether to wait until the prediction is completed before returning. If an integer is provided, it will wait for that many seconds. Defaults to false + * @param {AbortSignal} [options.signal] - An optional AbortSignal * @returns {Promise} Resolves with the created prediction */ async function createPrediction(options) { - const { model, version, input, wait, ...data } = options; + const { model, version, input, wait, signal, ...data } = options; if (data.webhook) { try { @@ -48,6 +49,7 @@ async function createPrediction(options) { ), version, }, + signal, }); } else if (model) { response = await this.request(`/models/${model}/predictions`, { @@ -61,6 +63,7 @@ async function createPrediction(options) { this.fileEncodingStrategy ), }, + signal, }); } else { throw new Error("Either model or version must be specified"); @@ -73,11 +76,14 @@ async function createPrediction(options) { * Fetch a prediction by ID * * @param {number} prediction_id - Required. The prediction ID + * @param {object} [options] + * @param {AbortSignal} [options.signal] - An optional AbortSignal * @returns {Promise} Resolves with the prediction data */ -async function getPrediction(prediction_id) { +async function getPrediction(prediction_id, { signal } = {}) { const response = await this.request(`/predictions/${prediction_id}`, { method: "GET", + signal, }); return response.json(); @@ -87,11 +93,14 @@ async function getPrediction(prediction_id) { * Cancel a prediction by ID * * @param {string} prediction_id - Required. The training ID + * @param {object} [options] + * @param {AbortSignal} [options.signal] - An optional AbortSignal * @returns {Promise} Resolves with the data for the training */ -async function cancelPrediction(prediction_id) { +async function cancelPrediction(prediction_id, { signal } = {}) { const response = await this.request(`/predictions/${prediction_id}/cancel`, { method: "POST", + signal, }); return response.json(); @@ -100,11 +109,14 @@ async function cancelPrediction(prediction_id) { /** * List all predictions * + * @param {object} [options] + * @param {AbortSignal} [options.signal] - An optional AbortSignal * @returns {Promise} - Resolves with a page of predictions */ -async function listPredictions() { +async function listPredictions({ signal } = {}) { const response = await this.request("/predictions", { method: "GET", + signal, }); return response.json(); diff --git a/lib/trainings.js b/lib/trainings.js index 6b13dca2..49640b9e 100644 --- a/lib/trainings.js +++ b/lib/trainings.js @@ -9,10 +9,11 @@ * @param {object} options.input - Required. An object with the model inputs * @param {string} [options.webhook] - An HTTPS URL for receiving a webhook when the training updates * @param {string[]} [options.webhook_events_filter] - You can change which events trigger webhook requests by specifying webhook events (`start`|`output`|`logs`|`completed`) + * @param {AbortSignal} [options.signal] - An optional AbortSignal * @returns {Promise} Resolves with the data for the created training */ async function createTraining(model_owner, model_name, version_id, options) { - const { ...data } = options; + const { signal, ...data } = options; if (data.webhook) { try { @@ -28,6 +29,7 @@ async function createTraining(model_owner, model_name, version_id, options) { { method: "POST", data, + signal, } ); @@ -38,11 +40,14 @@ async function createTraining(model_owner, model_name, version_id, options) { * Fetch a training by ID * * @param {string} training_id - Required. The training ID + * @param {object} [options] + * @param {AbortSignal} [options.signal] - An optional AbortSignal * @returns {Promise} Resolves with the data for the training */ -async function getTraining(training_id) { +async function getTraining(training_id, { signal } = {}) { const response = await this.request(`/trainings/${training_id}`, { method: "GET", + signal, }); return response.json(); @@ -52,11 +57,14 @@ async function getTraining(training_id) { * Cancel a training by ID * * @param {string} training_id - Required. The training ID + * @param {object} [options] + * @param {AbortSignal} [options.signal] - An optional AbortSignal * @returns {Promise} Resolves with the data for the training */ -async function cancelTraining(training_id) { +async function cancelTraining(training_id, { signal } = {}) { const response = await this.request(`/trainings/${training_id}/cancel`, { method: "POST", + signal, }); return response.json(); @@ -65,11 +73,14 @@ async function cancelTraining(training_id) { /** * List all trainings * + * @param {object} [options] + * @param {AbortSignal} [options.signal] - An optional AbortSignal * @returns {Promise} - Resolves with a page of trainings */ -async function listTrainings() { +async function listTrainings({ signal } = {}) { const response = await this.request("/trainings", { method: "GET", + signal, }); return response.json(); diff --git a/lib/util.js b/lib/util.js index 2fa49190..09665774 100644 --- a/lib/util.js +++ b/lib/util.js @@ -247,7 +247,7 @@ async function withAutomaticRetries(request, options = {}) { * @param {Replicate} client - The client used to upload the file * @param {object} inputs - The inputs to transform * @param {"default" | "upload" | "data-uri"} strategy - Whether to upload files to Replicate, encode as dataURIs or try both. - * @returns {object} - The transformed inputs + * @returns {Promise} - The transformed inputs * @throws {ApiError} If the request to upload the file fails */ async function transformFileInputs(client, inputs, strategy) { @@ -280,7 +280,7 @@ async function transformFileInputs(client, inputs, strategy) { * * @param {Replicate} client - The client used to upload the file * @param {object} inputs - The inputs to transform - * @returns {object} - The transformed inputs + * @returns {Promise} - The transformed inputs * @throws {ApiError} If the request to upload the file fails */ async function transformFileInputsToReplicateFileURLs(client, inputs) { @@ -301,8 +301,8 @@ const MAX_DATA_URI_SIZE = 10_000_000; * base64-encoded data URI. * * @param {object} inputs - The inputs to transform - * @returns {object} - The transformed inputs - * @throws {Error} If the size of inputs exceeds a given threshould set by MAX_DATA_URI_SIZE + * @returns {Promise} - The transformed inputs + * @throws {Error} If the size of inputs exceeds a given threshold set by MAX_DATA_URI_SIZE */ async function transformFileInputsToBase64EncodedDataURIs(inputs) { let totalBytes = 0; @@ -311,10 +311,10 @@ async function transformFileInputsToBase64EncodedDataURIs(inputs) { let mime; if (value instanceof Blob) { - // Currently we use a NodeJS only API for base64 encoding, as + // Currently, we use a NodeJS only API for base64 encoding, as // we move to support the browser we could support either using // btoa (which does string encoding), the FileReader API or - // a JavaScript implenentation like base64-js. + // a JavaScript implementation like base64-js. // See: https://developer.mozilla.org/en-US/docs/Glossary/Base64 // See: https://github.com/beatgammit/base64-js buffer = await value.arrayBuffer(); diff --git a/lib/webhooks.js b/lib/webhooks.js index f1324ec7..8da6fdf3 100644 --- a/lib/webhooks.js +++ b/lib/webhooks.js @@ -1,11 +1,14 @@ /** * Get the default webhook signing secret * + * @param {object} [options] + * @param {AbortSignal} [options.signal] - An optional AbortSignal * @returns {Promise} Resolves with the signing secret for the default webhook */ -async function getDefaultWebhookSecret() { +async function getDefaultWebhookSecret({ signal } = {}) { const response = await this.request("/webhooks/default/secret", { method: "GET", + signal, }); return response.json(); From 5ccf9f3bb9247026d983d2810d2a8b951e8c28ed Mon Sep 17 00:00:00 2001 From: Aron Carroll Date: Wed, 26 Mar 2025 17:43:47 +0000 Subject: [PATCH 12/17] Update interface for replicate.models.versions.list() (#349) The endpoint returns a paginated list rather than an array --- index.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.d.ts b/index.d.ts index 709f4665..2b183d04 100644 --- a/index.d.ts +++ b/index.d.ts @@ -315,7 +315,7 @@ declare module "replicate" { model_owner: string, model_name: string, options?: { signal?: AbortSignal } - ): Promise; + ): Promise>; get( model_owner: string, model_name: string, From f8ab2e8ead2997ac0f2e257652037483b7a3ff52 Mon Sep 17 00:00:00 2001 From: Matt Rothenberg Date: Mon, 12 May 2025 14:26:12 -0400 Subject: [PATCH 13/17] 1.1.0-0 (#352) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 300e5382..c0232d31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "replicate", - "version": "1.0.0", + "version": "1.1.0-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "replicate", - "version": "1.0.0", + "version": "1.1.0-0", "license": "Apache-2.0", "devDependencies": { "@biomejs/biome": "^1.4.1", diff --git a/package.json b/package.json index fcd47e8f..8bf27d6b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "replicate", - "version": "1.0.0", + "version": "1.1.0-0", "description": "JavaScript client for Replicate", "repository": "github:replicate/replicate-javascript", "homepage": "https://github.com/replicate/replicate-javascript#readme", From 7825a4333257062a1fe51428adb536bfd55e8e77 Mon Sep 17 00:00:00 2001 From: Will Sackfield Date: Wed, 20 Aug 2025 15:36:23 -0400 Subject: [PATCH 14/17] Add aborted to Status types (#355) * Aborted is now a first class status --- index.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.d.ts b/index.d.ts index 2b183d04..83bf34a7 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,5 +1,5 @@ declare module "replicate" { - type Status = "starting" | "processing" | "succeeded" | "failed" | "canceled"; + type Status = "starting" | "processing" | "succeeded" | "failed" | "canceled" | "aborted"; type Visibility = "public" | "private"; type WebhookEventType = "start" | "output" | "logs" | "completed"; From 80f1c37b97eeffbdb6badb6f054b72ac05de2a7d Mon Sep 17 00:00:00 2001 From: Matt Rothenberg Date: Wed, 20 Aug 2025 15:58:34 -0400 Subject: [PATCH 15/17] 1.1.0 (#356) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index c0232d31..0b97bfb4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "replicate", - "version": "1.1.0-0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "replicate", - "version": "1.1.0-0", + "version": "1.1.0", "license": "Apache-2.0", "devDependencies": { "@biomejs/biome": "^1.4.1", diff --git a/package.json b/package.json index 8bf27d6b..217f6164 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "replicate", - "version": "1.1.0-0", + "version": "1.1.0", "description": "JavaScript client for Replicate", "repository": "github:replicate/replicate-javascript", "homepage": "https://github.com/replicate/replicate-javascript#readme", From e6a83b929ba25a9bdb3a8d62e47d2849841e4fdc Mon Sep 17 00:00:00 2001 From: Matt Rothenberg Date: Thu, 18 Sep 2025 09:59:28 -0400 Subject: [PATCH 16/17] feat: expose is_official boolean on models (#358) --- index.d.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/index.d.ts b/index.d.ts index 83bf34a7..800a524a 100644 --- a/index.d.ts +++ b/index.d.ts @@ -71,6 +71,7 @@ declare module "replicate" { name: string; description?: string; visibility: "public" | "private"; + is_official: boolean; github_url?: string; paper_url?: string; license_url?: string; From 7e8e2c0cc8d2fe64c294605fdf1de7d6cc3b8801 Mon Sep 17 00:00:00 2001 From: Matt Rothenberg Date: Thu, 18 Sep 2025 10:03:48 -0400 Subject: [PATCH 17/17] 1.2.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0b97bfb4..22b3376a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "replicate", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "replicate", - "version": "1.1.0", + "version": "1.2.0", "license": "Apache-2.0", "devDependencies": { "@biomejs/biome": "^1.4.1", diff --git a/package.json b/package.json index 217f6164..3dfa3500 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "replicate", - "version": "1.1.0", + "version": "1.2.0", "description": "JavaScript client for Replicate", "repository": "github:replicate/replicate-javascript", "homepage": "https://github.com/replicate/replicate-javascript#readme",