This commit is contained in:
2026-04-12 01:02:14 +08:00
parent 509487f155
commit 9b053e302b
14085 changed files with 2680009 additions and 12 deletions

View File

@@ -0,0 +1,3 @@
from importlib.metadata import version
__version__ = version(__package__ if __package__ is not None else "replicate")

View File

@@ -0,0 +1,23 @@
from replicate.client import Client
from replicate.pagination import async_paginate as _async_paginate
from replicate.pagination import paginate as _paginate
default_client = Client()
run = default_client.run
async_run = default_client.async_run
stream = default_client.stream
async_stream = default_client.async_stream
paginate = _paginate
async_paginate = _async_paginate
collections = default_client.collections
deployments = default_client.deployments
files = default_client.files
hardware = default_client.hardware
models = default_client.models
predictions = default_client.predictions
trainings = default_client.trainings
webhooks = default_client.webhooks

View File

@@ -0,0 +1,57 @@
from typing import Any, Dict, Literal, Optional
from replicate.resource import Namespace, Resource
class Account(Resource):
"""
A user or organization account on Replicate.
"""
type: Literal["user", "organization"]
"""The type of account."""
username: str
"""The username of the account."""
name: str
"""The name of the account."""
github_url: Optional[str]
"""The GitHub URL of the account."""
class Accounts(Namespace):
"""
Namespace for operations related to accounts.
"""
def current(self) -> Account:
"""
Get the current account.
Returns:
Account: The current account.
"""
resp = self._client._request("GET", "/v1/account")
obj = resp.json()
return _json_to_account(obj)
async def async_current(self) -> Account:
"""
Get the current account.
Returns:
Account: The current account.
"""
resp = await self._client._async_request("GET", "/v1/account")
obj = resp.json()
return _json_to_account(obj)
def _json_to_account(json: Dict[str, Any]) -> Account:
return Account(**json)

View File

@@ -0,0 +1,407 @@
import asyncio
import os
import random
import time
from datetime import datetime
from typing import (
TYPE_CHECKING,
Any,
AsyncIterator,
Dict,
Iterable,
Iterator,
Mapping,
Optional,
Type,
Union,
)
import httpx
from typing_extensions import Unpack
from replicate.__about__ import __version__
from replicate.account import Accounts
from replicate.collection import Collections
from replicate.deployment import Deployments
from replicate.exceptions import ReplicateError
from replicate.file import Files
from replicate.hardware import HardwareNamespace as Hardware
from replicate.model import Models
from replicate.prediction import Predictions
from replicate.run import async_run, run
from replicate.stream import async_stream, stream
from replicate.training import Trainings
from replicate.webhook import Webhooks
if TYPE_CHECKING:
from replicate.stream import ServerSentEvent
class Client:
"""A Replicate API client library"""
__client: Optional[httpx.Client] = None
__async_client: Optional[httpx.AsyncClient] = None
def __init__(
self,
api_token: Optional[str] = None,
*,
base_url: Optional[str] = None,
timeout: Optional[httpx.Timeout] = None,
**kwargs,
) -> None:
super().__init__()
self._api_token = api_token
self._base_url = base_url
self._timeout = timeout
self._client_kwargs = kwargs
self.poll_interval = float(os.environ.get("REPLICATE_POLL_INTERVAL", "0.5"))
@property
def _client(self) -> httpx.Client:
if not self.__client:
self.__client = _build_httpx_client(
httpx.Client,
self._api_token,
self._base_url,
self._timeout,
**self._client_kwargs,
) # type: ignore[assignment]
return self.__client # type: ignore[return-value]
@property
def _async_client(self) -> httpx.AsyncClient:
if not self.__async_client:
self.__async_client = _build_httpx_client(
httpx.AsyncClient,
self._api_token,
self._base_url,
self._timeout,
**self._client_kwargs,
) # type: ignore[assignment]
return self.__async_client # type: ignore[return-value]
def _request(self, method: str, path: str, **kwargs) -> httpx.Response:
resp = self._client.request(method, path, **kwargs)
_raise_for_status(resp)
return resp
async def _async_request(self, method: str, path: str, **kwargs) -> httpx.Response:
resp = await self._async_client.request(method, path, **kwargs)
_raise_for_status(resp)
return resp
@property
def accounts(self) -> Accounts:
"""
Namespace for operations related to accounts.
"""
return Accounts(client=self)
@property
def collections(self) -> Collections:
"""
Namespace for operations related to collections of models.
"""
return Collections(client=self)
@property
def deployments(self) -> Deployments:
"""
Namespace for operations related to deployments.
"""
return Deployments(client=self)
@property
def files(self) -> Files:
"""
Namespace for operations related to files.
"""
return Files(client=self)
@property
def hardware(self) -> Hardware:
"""
Namespace for operations related to hardware.
"""
return Hardware(client=self)
@property
def models(self) -> Models:
"""
Namespace for operations related to models.
"""
return Models(client=self)
@property
def predictions(self) -> Predictions:
"""
Namespace for operations related to predictions.
"""
return Predictions(client=self)
@property
def trainings(self) -> Trainings:
"""
Namespace for operations related to trainings.
"""
return Trainings(client=self)
@property
def webhooks(self) -> Webhooks:
"""
Namespace for operations related to webhooks.
"""
return Webhooks(client=self)
def run(
self,
ref: str,
input: Optional[Dict[str, Any]] = None,
*,
use_file_output: Optional[bool] = True,
**params: Unpack["Predictions.CreatePredictionParams"],
) -> Union[Any, Iterator[Any]]: # noqa: ANN401
"""
Run a model and wait for its output.
"""
return run(self, ref, input, use_file_output=use_file_output, **params)
async def async_run(
self,
ref: str,
input: Optional[Dict[str, Any]] = None,
*,
use_file_output: Optional[bool] = True,
**params: Unpack["Predictions.CreatePredictionParams"],
) -> Union[Any, AsyncIterator[Any]]: # noqa: ANN401
"""
Run a model and wait for its output asynchronously.
"""
return await async_run(
self, ref, input, use_file_output=use_file_output, **params
)
def stream(
self,
ref: str,
*,
input: Optional[Dict[str, Any]] = None,
use_file_output: Optional[bool] = True,
**params: Unpack["Predictions.CreatePredictionParams"],
) -> Iterator["ServerSentEvent"]:
"""
Stream a model's output.
"""
return stream(self, ref, input, use_file_output=use_file_output, **params)
async def async_stream(
self,
ref: str,
input: Optional[Dict[str, Any]] = None,
*,
use_file_output: Optional[bool] = True,
**params: Unpack["Predictions.CreatePredictionParams"],
) -> AsyncIterator["ServerSentEvent"]:
"""
Stream a model's output asynchronously.
"""
return async_stream(self, ref, input, use_file_output=use_file_output, **params)
# Adapted from https://github.com/encode/httpx/issues/108#issuecomment-1132753155
class RetryTransport(httpx.AsyncBaseTransport, httpx.BaseTransport):
"""A custom HTTP transport that automatically retries requests using an exponential backoff strategy
for specific HTTP status codes and request methods.
"""
RETRYABLE_METHODS = frozenset(["HEAD", "GET", "PUT", "DELETE", "OPTIONS", "TRACE"])
RETRYABLE_STATUS_CODES = frozenset(
[
429, # Too Many Requests
503, # Service Unavailable
504, # Gateway Timeout
]
)
MAX_BACKOFF_WAIT = 60
def __init__( # pylint: disable=too-many-arguments
self,
wrapped_transport: Union[httpx.BaseTransport, httpx.AsyncBaseTransport],
*,
max_attempts: int = 10,
max_backoff_wait: float = MAX_BACKOFF_WAIT,
backoff_factor: float = 0.1,
jitter_ratio: float = 0.1,
retryable_methods: Optional[Iterable[str]] = None,
retry_status_codes: Optional[Iterable[int]] = None,
) -> None:
self._wrapped_transport = wrapped_transport
if jitter_ratio < 0 or jitter_ratio > 0.5:
raise ValueError(
f"jitter ratio should be between 0 and 0.5, actual {jitter_ratio}"
)
self.max_attempts = max_attempts
self.backoff_factor = backoff_factor
self.retryable_methods = (
frozenset(retryable_methods)
if retryable_methods
else self.RETRYABLE_METHODS
)
self.retry_status_codes = (
frozenset(retry_status_codes)
if retry_status_codes
else self.RETRYABLE_STATUS_CODES
)
self.jitter_ratio = jitter_ratio
self.max_backoff_wait = max_backoff_wait
def _calculate_sleep(
self, attempts_made: int, headers: Union[httpx.Headers, Mapping[str, str]]
) -> float:
retry_after_header = (headers.get("Retry-After") or "").strip()
if retry_after_header:
if retry_after_header.isdigit():
return float(retry_after_header)
try:
parsed_date = datetime.fromisoformat(retry_after_header).astimezone()
diff = (parsed_date - datetime.now().astimezone()).total_seconds()
if diff > 0:
return min(diff, self.max_backoff_wait)
except ValueError:
pass
backoff = self.backoff_factor * (2 ** (attempts_made - 1))
jitter = (backoff * self.jitter_ratio) * random.choice([1, -1]) # noqa: S311
total_backoff = backoff + jitter
return min(total_backoff, self.max_backoff_wait)
def handle_request(self, request: httpx.Request) -> httpx.Response:
response = self._wrapped_transport.handle_request(request) # type: ignore
if request.method not in self.retryable_methods:
return response
remaining_attempts = self.max_attempts - 1
attempts_made = 1
while True:
if (
remaining_attempts < 1
or response.status_code not in self.retry_status_codes
):
return response
response.close()
sleep_for = self._calculate_sleep(attempts_made, response.headers)
time.sleep(sleep_for)
response = self._wrapped_transport.handle_request(request) # type: ignore
attempts_made += 1
remaining_attempts -= 1
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
response = await self._wrapped_transport.handle_async_request(request) # type: ignore
if request.method not in self.retryable_methods:
return response
remaining_attempts = self.max_attempts - 1
attempts_made = 1
while True:
if (
remaining_attempts < 1
or response.status_code not in self.retry_status_codes
):
return response
await response.aclose()
sleep_for = self._calculate_sleep(attempts_made, response.headers)
await asyncio.sleep(sleep_for)
response = await self._wrapped_transport.handle_async_request(request) # type: ignore
attempts_made += 1
remaining_attempts -= 1
async def aclose(self) -> None:
await self._wrapped_transport.aclose() # type: ignore
def close(self) -> None:
self._wrapped_transport.close() # type: ignore
def _get_api_token_from_environment() -> Optional[str]:
"""Get API token from cog current scope if available, otherwise from environment."""
try:
import cog # noqa: I001 # pyright: ignore [reportMissingImports]
for key, value in cog.current_scope().context.items():
if key.upper() == "REPLICATE_API_TOKEN":
return value
except: # noqa: S110,E722,BLE001 we don't want this code to cause clients to fail
pass
return os.environ.get("REPLICATE_API_TOKEN")
def _build_httpx_client(
client_type: Type[Union[httpx.Client, httpx.AsyncClient]],
api_token: Optional[str] = None,
base_url: Optional[str] = None,
timeout: Optional[httpx.Timeout] = None,
**kwargs,
) -> Union[httpx.Client, httpx.AsyncClient]:
headers = kwargs.pop("headers", {})
if "User-Agent" not in headers:
headers["User-Agent"] = f"replicate-python/{__version__}"
if "Authorization" not in headers and (
api_token := api_token or _get_api_token_from_environment()
):
headers["Authorization"] = f"Bearer {api_token}"
base_url = (
base_url or os.environ.get("REPLICATE_BASE_URL") or "https://api.replicate.com"
)
if base_url == "":
base_url = "https://api.replicate.com"
timeout = timeout or httpx.Timeout(
5.0, read=30.0, write=30.0, connect=5.0, pool=10.0
)
transport = kwargs.pop("transport", None) or (
httpx.HTTPTransport()
if client_type is httpx.Client
else httpx.AsyncHTTPTransport()
)
return client_type(
base_url=base_url,
headers=headers,
timeout=timeout,
transport=RetryTransport(wrapped_transport=transport), # type: ignore[arg-type]
**kwargs,
)
def _raise_for_status(resp: httpx.Response) -> None:
if 400 <= resp.status_code < 600:
raise ReplicateError.from_response(resp)

View File

@@ -0,0 +1,147 @@
from typing import Any, Dict, Iterator, List, Optional, Union, overload
from typing_extensions import deprecated
from replicate.model import Model
from replicate.pagination import Page
from replicate.resource import Namespace, Resource
class Collection(Resource):
"""
A collection of models on Replicate.
"""
slug: str
"""The slug used to identify the collection."""
name: str
"""The name of the collection."""
description: str
"""A description of the collection."""
models: Optional[List[Model]] = None
"""The models in the collection."""
@property
@deprecated("Use `slug` instead of `id`")
def id(self) -> str:
"""
DEPRECATED: Use `slug` instead.
"""
return self.slug
def __iter__(self) -> Iterator[Model]:
if self.models is not None:
return iter(self.models)
return iter([])
@overload
def __getitem__(self, index: int) -> Optional[Model]: ...
@overload
def __getitem__(self, index: slice) -> Optional[List[Model]]: ...
def __getitem__(
self, index: Union[int, slice]
) -> Union[Optional[Model], Optional[List[Model]]]:
if self.models is not None:
return self.models[index]
return None
def __len__(self) -> int:
if self.models is not None:
return len(self.models)
return 0
class Collections(Namespace):
"""
A namespace for operations related to collections of models.
"""
def list(
self,
cursor: Union[str, "ellipsis", None] = ..., # noqa: F821
) -> Page[Collection]:
"""
List collections of models.
Parameters:
cursor: The cursor to use for pagination. Use the value of `Page.next` or `Page.previous`.
Returns:
Page[Collection]: A page of of model collections.
Raises:
ValueError: If `cursor` is `None`.
"""
if cursor is None:
raise ValueError("cursor cannot be None")
resp = self._client._request(
"GET", "/v1/collections" if cursor is ... else cursor
)
obj = resp.json()
obj["results"] = [_json_to_collection(result) for result in obj["results"]]
return Page[Collection](**obj)
async def async_list(
self,
cursor: Union[str, "ellipsis", None] = ..., # noqa: F821
) -> Page[Collection]:
"""
List collections of models.
Parameters:
cursor: The cursor to use for pagination. Use the value of `Page.next` or `Page.previous`.
Returns:
Page[Collection]: A page of of model collections.
Raises:
ValueError: If `cursor` is `None`.
"""
if cursor is None:
raise ValueError("cursor cannot be None")
resp = await self._client._async_request(
"GET", "/v1/collections" if cursor is ... else cursor
)
obj = resp.json()
obj["results"] = [_json_to_collection(result) for result in obj["results"]]
return Page[Collection](**obj)
def get(self, slug: str) -> Collection:
"""Get a model by name.
Args:
name: The name of the model, in the format `owner/model-name`.
Returns:
The model.
"""
resp = self._client._request("GET", f"/v1/collections/{slug}")
return _json_to_collection(resp.json())
async def async_get(self, slug: str) -> Collection:
"""Get a model by name.
Args:
name: The name of the model, in the format `owner/model-name`.
Returns:
The model.
"""
resp = await self._client._async_request("GET", f"/v1/collections/{slug}")
return _json_to_collection(resp.json())
def _json_to_collection(json: Dict[str, Any]) -> Collection:
return Collection(**json)

View File

@@ -0,0 +1,555 @@
from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, TypedDict, Union
from typing_extensions import Unpack, deprecated
from replicate.account import Account
from replicate.helpers import async_encode_json, encode_json
from replicate.pagination import Page
from replicate.prediction import (
Prediction,
_create_prediction_body,
_create_prediction_request_params,
_json_to_prediction,
)
from replicate.resource import Namespace, Resource
try:
from pydantic import v1 as pydantic # type: ignore
except ImportError:
import pydantic # type: ignore
if TYPE_CHECKING:
from replicate.client import Client
from replicate.prediction import Predictions
class Deployment(Resource):
"""
A deployment of a model hosted on Replicate.
"""
_client: "Client" = pydantic.PrivateAttr()
owner: str
"""
The name of the user or organization that owns the deployment.
"""
name: str
"""
The name of the deployment.
"""
class Release(Resource):
"""
A release of a deployment.
"""
number: int
"""
The release number.
"""
model: str
"""
The model identifier string in the format of `{model_owner}/{model_name}`.
"""
version: str
"""
The ID of the model version used in the release.
"""
created_at: str
"""
The time the release was created.
"""
created_by: Optional[Account]
"""
The account that created the release.
"""
class Configuration(Resource):
"""
A configuration for a deployment.
"""
hardware: str
"""
The SKU for the hardware used to run the model.
"""
min_instances: int
"""
The minimum number of instances for scaling.
"""
max_instances: int
"""
The maximum number of instances for scaling.
"""
configuration: Configuration
"""
The deployment configuration.
"""
current_release: Optional[Release]
"""
The current release of the deployment.
"""
@property
@deprecated("Use `deployment.owner` instead.")
def username(self) -> str:
"""
The name of the user or organization that owns the deployment.
This attribute is deprecated and will be removed in future versions.
"""
return self.owner
@property
def id(self) -> str:
"""
Return the qualified deployment name, in the format `owner/name`.
"""
return f"{self.owner}/{self.name}"
@property
def predictions(self) -> "DeploymentPredictions":
"""
Get the predictions for this deployment.
"""
return DeploymentPredictions(client=self._client, deployment=self)
class Deployments(Namespace):
"""
Namespace for operations related to deployments.
"""
_client: "Client"
def list(
self,
cursor: Union[str, "ellipsis", None] = ..., # noqa: F821
) -> Page[Deployment]:
"""
List all deployments.
Returns:
A page of Deployments.
"""
if cursor is None:
raise ValueError("cursor cannot be None")
resp = self._client._request(
"GET", "/v1/deployments" if cursor is ... else cursor
)
obj = resp.json()
obj["results"] = [
_json_to_deployment(self._client, result) for result in obj["results"]
]
return Page[Deployment](**obj)
async def async_list(
self,
cursor: Union[str, "ellipsis", None] = ..., # noqa: F821
) -> Page[Deployment]:
"""
List all deployments.
Returns:
A page of Deployments.
"""
if cursor is None:
raise ValueError("cursor cannot be None")
resp = await self._client._async_request(
"GET", "/v1/deployments" if cursor is ... else cursor
)
obj = resp.json()
obj["results"] = [
_json_to_deployment(self._client, result) for result in obj["results"]
]
return Page[Deployment](**obj)
def get(self, name: str) -> Deployment:
"""
Get a deployment by name.
Args:
name: The name of the deployment, in the format `owner/model-name`.
Returns:
The model.
"""
owner, name = name.split("/", 1)
resp = self._client._request(
"GET",
f"/v1/deployments/{owner}/{name}",
)
return _json_to_deployment(self._client, resp.json())
async def async_get(self, name: str) -> Deployment:
"""
Get a deployment by name.
Args:
name: The name of the deployment, in the format `owner/model-name`.
Returns:
The model.
"""
owner, name = name.split("/", 1)
resp = await self._client._async_request(
"GET",
f"/v1/deployments/{owner}/{name}",
)
return _json_to_deployment(self._client, resp.json())
class CreateDeploymentParams(TypedDict):
"""
Parameters for creating a new deployment.
"""
name: str
"""The name of the deployment."""
model: str
"""The model identifier string in the format of `{model_owner}/{model_name}`."""
version: str
"""The version of the model to deploy."""
hardware: str
"""The SKU for the hardware used to run the model."""
min_instances: int
"""The minimum number of instances for scaling."""
max_instances: int
"""The maximum number of instances for scaling."""
def create(self, **params: Unpack[CreateDeploymentParams]) -> Deployment:
"""
Create a new deployment.
Args:
params: Configuration for the new deployment.
Returns:
The newly created Deployment.
"""
if name := params.get("name", None):
if "/" in name:
_, name = name.split("/", 1)
params["name"] = name
resp = self._client._request(
"POST",
"/v1/deployments",
json=params,
)
return _json_to_deployment(self._client, resp.json())
async def async_create(
self, **params: Unpack[CreateDeploymentParams]
) -> Deployment:
"""
Create a new deployment.
Args:
params: Configuration for the new deployment.
Returns:
The newly created Deployment.
"""
if name := params.get("name", None):
if "/" in name:
_, name = name.split("/", 1)
params["name"] = name
resp = await self._client._async_request(
"POST",
"/v1/deployments",
json=params,
)
return _json_to_deployment(self._client, resp.json())
class UpdateDeploymentParams(TypedDict, total=False):
"""
Parameters for updating an existing deployment.
"""
version: str
"""The version of the model to deploy."""
hardware: str
"""The SKU for the hardware used to run the model."""
min_instances: int
"""The minimum number of instances for scaling."""
max_instances: int
"""The maximum number of instances for scaling."""
def update(
self,
deployment_owner: str,
deployment_name: str,
**params: Unpack[UpdateDeploymentParams],
) -> Deployment:
"""
Update an existing deployment.
Args:
deployment_owner: The owner of the deployment.
deployment_name: The name of the deployment.
params: Configuration updates for the deployment.
Returns:
The updated Deployment.
"""
resp = self._client._request(
"PATCH",
f"/v1/deployments/{deployment_owner}/{deployment_name}",
json=params,
)
return _json_to_deployment(self._client, resp.json())
async def async_update(
self,
deployment_owner: str,
deployment_name: str,
**params: Unpack[UpdateDeploymentParams],
) -> Deployment:
"""
Update an existing deployment.
Args:
deployment_owner: The owner of the deployment.
deployment_name: The name of the deployment.
params: Configuration updates for the deployment.
Returns:
The updated Deployment.
"""
resp = await self._client._async_request(
"PATCH",
f"/v1/deployments/{deployment_owner}/{deployment_name}",
json=params,
)
return _json_to_deployment(self._client, resp.json())
def delete(self, deployment_owner: str, deployment_name: str) -> bool:
"""
Delete an existing deployment.
Args:
deployment_owner: The owner of the deployment.
deployment_name: The name of the deployment.
"""
resp = self._client._request(
"DELETE",
f"/v1/deployments/{deployment_owner}/{deployment_name}",
)
return resp.status_code == 204
async def async_delete(self, deployment_owner: str, deployment_name: str) -> bool:
"""
Delete an existing deployment asynchronously.
Args:
deployment_owner: The owner of the deployment.
deployment_name: The name of the deployment.
"""
resp = await self._client._async_request(
"DELETE",
f"/v1/deployments/{deployment_owner}/{deployment_name}",
)
return resp.status_code == 204
@property
def predictions(self) -> "DeploymentsPredictions":
"""
Get predictions for deployments.
"""
return DeploymentsPredictions(client=self._client)
def _json_to_deployment(client: "Client", json: Dict[str, Any]) -> Deployment:
deployment = Deployment(**json)
deployment._client = client
return deployment
class DeploymentPredictions(Namespace):
"""
Namespace for operations related to predictions in a deployment.
"""
_deployment: Deployment
def __init__(self, client: "Client", deployment: Deployment) -> None:
super().__init__(client=client)
self._deployment = deployment
def create(
self,
input: Dict[str, Any],
**params: Unpack["Predictions.CreatePredictionParams"],
) -> Prediction:
"""
Create a new prediction with the deployment.
"""
wait = params.pop("wait", None)
file_encoding_strategy = params.pop("file_encoding_strategy", None)
if input is not None:
input = encode_json(
input,
client=self._client,
file_encoding_strategy=file_encoding_strategy,
)
body = _create_prediction_body(version=None, input=input, **params)
extras = _create_prediction_request_params(
wait=wait,
)
resp = self._client._request(
"POST",
f"/v1/deployments/{self._deployment.owner}/{self._deployment.name}/predictions",
json=body,
**extras,
)
return _json_to_prediction(self._client, resp.json())
async def async_create(
self,
input: Dict[str, Any],
**params: Unpack["Predictions.CreatePredictionParams"],
) -> Prediction:
"""
Create a new prediction with the deployment.
"""
wait = params.pop("wait", None)
file_encoding_strategy = params.pop("file_encoding_strategy", None)
if input is not None:
input = await async_encode_json(
input,
client=self._client,
file_encoding_strategy=file_encoding_strategy,
)
body = _create_prediction_body(version=None, input=input, **params)
extras = _create_prediction_request_params(
wait=wait,
)
resp = await self._client._async_request(
"POST",
f"/v1/deployments/{self._deployment.owner}/{self._deployment.name}/predictions",
json=body,
**extras,
)
return _json_to_prediction(self._client, resp.json())
class DeploymentsPredictions(Namespace):
"""
Namespace for operations related to predictions in deployments.
"""
def create(
self,
deployment: Union[str, Tuple[str, str], Deployment],
input: Dict[str, Any],
**params: Unpack["Predictions.CreatePredictionParams"],
) -> Prediction:
"""
Create a new prediction with the deployment.
"""
wait = params.pop("wait", None)
file_encoding_strategy = params.pop("file_encoding_strategy", None)
url = _create_prediction_url_from_deployment(deployment)
if input is not None:
input = encode_json(
input,
client=self._client,
file_encoding_strategy=file_encoding_strategy,
)
body = _create_prediction_body(version=None, input=input, **params)
extras = _create_prediction_request_params(wait=wait)
resp = self._client._request("POST", url, json=body, **extras)
return _json_to_prediction(self._client, resp.json())
async def async_create(
self,
deployment: Union[str, Tuple[str, str], Deployment],
input: Dict[str, Any],
**params: Unpack["Predictions.CreatePredictionParams"],
) -> Prediction:
"""
Create a new prediction with the deployment.
"""
wait = params.pop("wait", None)
file_encoding_strategy = params.pop("file_encoding_strategy", None)
url = _create_prediction_url_from_deployment(deployment)
if input is not None:
input = await async_encode_json(
input,
client=self._client,
file_encoding_strategy=file_encoding_strategy,
)
body = _create_prediction_body(version=None, input=input, **params)
extras = _create_prediction_request_params(wait=wait)
resp = await self._client._async_request("POST", url, json=body, **extras)
return _json_to_prediction(self._client, resp.json())
def _create_prediction_url_from_deployment(
deployment: Union[str, Tuple[str, str], Deployment],
) -> str:
owner, name = None, None
if isinstance(deployment, Deployment):
owner, name = deployment.owner, deployment.name
elif isinstance(deployment, tuple):
owner, name = deployment[0], deployment[1]
elif isinstance(deployment, str):
owner, name = deployment.split("/", 1)
if owner is None or name is None:
raise ValueError(
"deployment must be a Deployment, a tuple of (owner, name), or a string in the format 'owner/name'"
)
return f"/v1/deployments/{owner}/{name}/predictions"

View File

@@ -0,0 +1,107 @@
from typing import TYPE_CHECKING, Optional
import httpx
if TYPE_CHECKING:
from replicate.prediction import Prediction
class ReplicateException(Exception):
"""A base class for all Replicate exceptions."""
class ModelError(ReplicateException):
"""An error from user's code in a model."""
prediction: "Prediction"
def __init__(self, prediction: "Prediction") -> None:
self.prediction = prediction
super().__init__(prediction.error)
class ReplicateError(ReplicateException):
"""
An error from Replicate's API.
This class represents a problem details response as defined in RFC 7807.
"""
type: Optional[str]
"""A URI that identifies the error type."""
title: Optional[str]
"""A short, human-readable summary of the error."""
status: Optional[int]
"""The HTTP status code."""
detail: Optional[str]
"""A human-readable explanation specific to this occurrence of the error."""
instance: Optional[str]
"""A URI that identifies the specific occurrence of the error."""
def __init__( # pylint: disable=too-many-arguments
self,
type: Optional[str] = None,
title: Optional[str] = None,
status: Optional[int] = None,
detail: Optional[str] = None,
instance: Optional[str] = None,
) -> None:
self.type = type
self.title = title
self.status = status
self.detail = detail
self.instance = instance
@classmethod
def from_response(cls, response: httpx.Response) -> "ReplicateError":
"""Create a ReplicateError from an HTTP response."""
try:
data = response.json()
except ValueError:
data = {}
return cls(
type=data.get("type"),
title=data.get("title"),
detail=data.get("detail"),
status=response.status_code,
instance=data.get("instance"),
)
def to_dict(self) -> dict:
"""Get a dictionary representation of the error."""
return {
key: value
for key, value in {
"type": self.type,
"title": self.title,
"status": self.status,
"detail": self.detail,
"instance": self.instance,
}.items()
if value is not None
}
def __str__(self) -> str:
return "ReplicateError Details:\n" + "\n".join(
[f"{key}: {value}" for key, value in self.to_dict().items()]
)
def __repr__(self) -> str:
class_name = self.__class__.__name__
params = ", ".join(
[
f"type={repr(self.type)}",
f"title={repr(self.title)}",
f"status={repr(self.status)}",
f"detail={repr(self.detail)}",
f"instance={repr(self.instance)}",
]
)
return f"{class_name}({params})"

View File

@@ -0,0 +1,177 @@
import io
import json
import mimetypes
import os
import pathlib
from typing import Any, BinaryIO, Dict, List, Optional, TypedDict, Union
from typing_extensions import Literal, NotRequired, Unpack
from replicate.resource import Namespace, Resource
FileEncodingStrategy = Literal["base64", "url"]
class File(Resource):
"""
A file uploaded to Replicate that can be used as an input to a model.
"""
id: str
"""The ID of the file."""
name: str
"""The name of the file."""
content_type: str
"""The content type of the file."""
size: int
"""The size of the file in bytes."""
etag: str
"""The ETag of the file."""
checksums: Dict[str, str]
"""The checksums of the file."""
metadata: Dict[str, Any]
"""The metadata of the file."""
created_at: str
"""The time the file was created."""
expires_at: Optional[str]
"""The time the file will expire."""
urls: Dict[str, str]
"""The URLs of the file."""
class Files(Namespace):
class CreateFileParams(TypedDict):
"""Parameters for creating a file."""
filename: NotRequired[str]
"""The name of the file."""
content_type: NotRequired[str]
"""The content type of the file."""
metadata: NotRequired[Dict[str, Any]]
"""The file metadata."""
def create(
self,
file: Union[str, pathlib.Path, BinaryIO, io.IOBase],
**params: Unpack["Files.CreateFileParams"],
) -> File:
"""
Upload a file that can be passed as an input when running a model.
"""
if isinstance(file, (str, pathlib.Path)):
file_path = pathlib.Path(file)
params["filename"] = file_path.name
with open(file, "rb") as f:
return self.create(f, **params)
elif not isinstance(file, (io.IOBase, BinaryIO)):
raise ValueError(
"Unsupported file type. Must be a file path or file-like object."
)
resp = self._client._request(
"POST", "/v1/files", timeout=None, **_create_file_params(file, **params)
)
return _json_to_file(resp.json())
async def async_create(
self,
file: Union[str, pathlib.Path, BinaryIO, io.IOBase],
**params: Unpack["Files.CreateFileParams"],
) -> File:
"""Upload a file asynchronously that can be passed as an input when running a model."""
if isinstance(file, (str, pathlib.Path)):
file_path = pathlib.Path(file)
params["filename"] = file_path.name
with open(file_path, "rb") as f:
return await self.async_create(f, **params)
elif not isinstance(file, (io.IOBase, BinaryIO)):
raise ValueError(
"Unsupported file type. Must be a file path or file-like object."
)
resp = await self._client._async_request(
"POST", "/v1/files", timeout=None, **_create_file_params(file, **params)
)
return _json_to_file(resp.json())
def get(self, file_id: str) -> File:
"""Get an uploaded file by its ID."""
resp = self._client._request("GET", f"/v1/files/{file_id}")
return _json_to_file(resp.json())
async def async_get(self, file_id: str) -> File:
"""Get an uploaded file by its ID asynchronously."""
resp = await self._client._async_request("GET", f"/v1/files/{file_id}")
return _json_to_file(resp.json())
def list(self) -> List[File]:
"""List all uploaded files."""
resp = self._client._request("GET", "/v1/files")
return [_json_to_file(obj) for obj in resp.json().get("results", [])]
async def async_list(self) -> List[File]:
"""List all uploaded files asynchronously."""
resp = await self._client._async_request("GET", "/v1/files")
return [_json_to_file(obj) for obj in resp.json().get("results", [])]
def delete(self, file_id: str) -> bool:
"""Delete an uploaded file by its ID."""
resp = self._client._request("DELETE", f"/v1/files/{file_id}")
return resp.status_code == 204
async def async_delete(self, file_id: str) -> bool:
"""Delete an uploaded file by its ID asynchronously."""
resp = await self._client._async_request("DELETE", f"/v1/files/{file_id}")
return resp.status_code == 204
def _create_file_params(
file: Union[BinaryIO, io.IOBase],
**params: Unpack["Files.CreateFileParams"],
) -> Dict[str, Any]:
file.seek(0)
if params is None:
params = {}
filename = params.get("filename", os.path.basename(getattr(file, "name", "file")))
content_type = (
params.get("content_type")
or mimetypes.guess_type(filename)[0]
or "application/octet-stream"
)
metadata = params.get("metadata")
data = {}
if metadata:
data["metadata"] = json.dumps(metadata)
return {
"files": {"content": (filename, file, content_type)},
"data": data,
}
def _json_to_file(json: Dict[str, Any]) -> File: # pylint: disable=redefined-outer-name
return File(**json)

View File

@@ -0,0 +1,68 @@
from typing import TYPE_CHECKING, Any, Dict, List
from typing_extensions import deprecated
from replicate.resource import Namespace, Resource
if TYPE_CHECKING:
pass
class Hardware(Resource):
"""
Hardware for running a model on Replicate.
"""
sku: str
"""
The SKU of the hardware.
"""
name: str
"""
The name of the hardware.
"""
@property
@deprecated("Use `sku` instead of `id`")
def id(self) -> str:
"""
DEPRECATED: Use `sku` instead.
"""
return self.sku
class HardwareNamespace(Namespace):
"""
Namespace for operations related to hardware.
"""
def list(self) -> List[Hardware]:
"""
List all hardware available for you to run models on Replicate.
Returns:
List[Hardware]: A list of hardware.
"""
resp = self._client._request("GET", "/v1/hardware")
obj = resp.json()
return [_json_to_hardware(entry) for entry in obj]
async def async_list(self) -> List[Hardware]:
"""
List all hardware available for you to run models on Replicate.
Returns:
List[Hardware]: A list of hardware.
"""
resp = await self._client._async_request("GET", "/v1/hardware")
obj = resp.json()
return [_json_to_hardware(entry) for entry in obj]
def _json_to_hardware(json: Dict[str, Any]) -> Hardware:
return Hardware(**json)

View File

@@ -0,0 +1,192 @@
import base64
import io
import mimetypes
from collections.abc import Mapping, Sequence
from pathlib import Path
from types import GeneratorType
from typing import TYPE_CHECKING, Any, AsyncIterator, Iterator, Optional
import httpx
if TYPE_CHECKING:
from replicate.client import Client
from replicate.file import FileEncodingStrategy
try:
import numpy as np # type: ignore
HAS_NUMPY = True
except ImportError:
HAS_NUMPY = False
# pylint: disable=too-many-return-statements
def encode_json(
obj: Any, # noqa: ANN401
client: "Client",
file_encoding_strategy: Optional["FileEncodingStrategy"] = None,
) -> Any: # noqa: ANN401
"""
Return a JSON-compatible version of the object.
"""
if isinstance(obj, dict):
return {
key: encode_json(value, client, file_encoding_strategy)
for key, value in obj.items()
}
if isinstance(obj, (list, set, frozenset, GeneratorType, tuple)):
return [encode_json(value, client, file_encoding_strategy) for value in obj]
if isinstance(obj, Path):
with obj.open("rb") as file:
return encode_json(file, client, file_encoding_strategy)
if isinstance(obj, io.IOBase):
if file_encoding_strategy == "base64":
return base64_encode_file(obj)
return client.files.create(obj).urls["get"]
if HAS_NUMPY:
if isinstance(obj, np.integer): # type: ignore
return int(obj)
if isinstance(obj, np.floating): # type: ignore
return float(obj)
if isinstance(obj, np.ndarray): # type: ignore
return obj.tolist()
return obj
async def async_encode_json(
obj: Any, # noqa: ANN401
client: "Client",
file_encoding_strategy: Optional["FileEncodingStrategy"] = None,
) -> Any: # noqa: ANN401
"""
Asynchronously return a JSON-compatible version of the object.
"""
if isinstance(obj, dict):
return {
key: (await async_encode_json(value, client, file_encoding_strategy))
for key, value in obj.items()
}
if isinstance(obj, (list, set, frozenset, GeneratorType, tuple)):
return [
(await async_encode_json(value, client, file_encoding_strategy))
for value in obj
]
if isinstance(obj, Path):
with obj.open("rb") as file:
return await async_encode_json(file, client, file_encoding_strategy)
if isinstance(obj, io.IOBase):
if file_encoding_strategy == "base64":
# TODO: This should ideally use an async based file reader path.
return base64_encode_file(obj)
return (await client.files.async_create(obj)).urls["get"]
if HAS_NUMPY:
if isinstance(obj, np.integer): # type: ignore
return int(obj)
if isinstance(obj, np.floating): # type: ignore
return float(obj)
if isinstance(obj, np.ndarray): # type: ignore
return obj.tolist()
return obj
def base64_encode_file(file: io.IOBase) -> str:
"""
Base64 encode a file.
Args:
file: A file handle to upload.
Returns:
str: A base64-encoded data URI.
"""
file.seek(0)
body = file.read()
# Ensure the file handle is in bytes
body = body.encode("utf-8") if isinstance(body, str) else body
encoded_body = base64.b64encode(body).decode("utf-8")
mime_type = (
mimetypes.guess_type(getattr(file, "name", ""))[0] or "application/octet-stream"
)
return f"data:{mime_type};base64,{encoded_body}"
class FileOutput(httpx.SyncByteStream, httpx.AsyncByteStream):
"""
An object that can be used to read the contents of an output file
created by running a Replicate model.
"""
url: str
"""
The file URL.
"""
_client: "Client"
def __init__(self, url: str, client: "Client") -> None:
self.url = url
self._client = client
def read(self) -> bytes:
if self.url.startswith("data:"):
_, encoded = self.url.split(",", 1)
return base64.b64decode(encoded)
with self._client._client.stream("GET", self.url) as response:
response.raise_for_status()
return response.read()
def __iter__(self) -> Iterator[bytes]:
if self.url.startswith("data:"):
yield self.read()
return
with self._client._client.stream("GET", self.url) as response:
response.raise_for_status()
yield from response.iter_bytes()
async def aread(self) -> bytes:
if self.url.startswith("data:"):
_, encoded = self.url.split(",", 1)
return base64.b64decode(encoded)
async with self._client._async_client.stream("GET", self.url) as response:
response.raise_for_status()
return await response.aread()
async def __aiter__(self) -> AsyncIterator[bytes]:
if self.url.startswith("data:"):
yield await self.aread()
return
async with self._client._async_client.stream("GET", self.url) as response:
response.raise_for_status()
async for chunk in response.aiter_bytes():
yield chunk
def __str__(self) -> str:
return self.url
def transform_output(value: Any, client: "Client") -> Any:
"""
Transform the output of a prediction to a `FileOutput` object if it's a URL.
"""
def transform(obj: Any) -> Any:
if isinstance(obj, Mapping):
return {k: transform(v) for k, v in obj.items()}
if isinstance(obj, Sequence) and not isinstance(obj, str):
return [transform(item) for item in obj]
if isinstance(obj, str) and (
obj.startswith("https:") or obj.startswith("data:")
):
return FileOutput(obj, client)
return obj
return transform(value)

View File

@@ -0,0 +1,50 @@
import re
from typing import TYPE_CHECKING, NamedTuple, Optional, Tuple, Union
if TYPE_CHECKING:
from replicate.model import Model
from replicate.version import Version
class ModelVersionIdentifier(NamedTuple):
"""
A reference to a model version in the format owner/name or owner/name:version.
"""
owner: str
name: str
version: Optional[str] = None
@classmethod
def parse(cls, ref: str) -> "ModelVersionIdentifier":
"""
Split a reference in the format owner/name:version into its components.
"""
match = re.match(r"^(?P<owner>[^/]+)/(?P<name>[^/:]+)(:(?P<version>.+))?$", ref)
if not match:
raise ValueError(
f"Invalid reference to model version: {ref}. Expected format: owner/name:version"
)
return cls(match.group("owner"), match.group("name"), match.group("version"))
def _resolve(
ref: Union["Model", "Version", "ModelVersionIdentifier", str],
) -> Tuple[Optional["Version"], Optional[str], Optional[str], Optional[str]]:
from replicate.model import Model # pylint: disable=import-outside-toplevel
from replicate.version import Version # pylint: disable=import-outside-toplevel
version = None
owner, name, version_id = None, None, None
if isinstance(ref, Model):
owner, name = ref.owner, ref.name
elif isinstance(ref, Version):
version = ref
version_id = ref.id
elif isinstance(ref, ModelVersionIdentifier):
owner, name, version_id = ref
elif isinstance(ref, str):
owner, name, version_id = ModelVersionIdentifier.parse(ref)
return version, owner, name, version_id

View File

@@ -0,0 +1,538 @@
from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Tuple, Union, overload
from typing_extensions import NotRequired, TypedDict, Unpack, deprecated
from replicate.exceptions import ReplicateException
from replicate.helpers import async_encode_json, encode_json
from replicate.identifier import ModelVersionIdentifier
from replicate.pagination import Page
from replicate.prediction import (
Prediction,
_create_prediction_body,
_create_prediction_request_params,
_json_to_prediction,
)
from replicate.resource import Namespace, Resource
from replicate.version import Version, Versions
try:
from pydantic import v1 as pydantic # type: ignore
except ImportError:
import pydantic # type: ignore
if TYPE_CHECKING:
from replicate.client import Client
from replicate.prediction import Predictions
class Model(Resource):
"""
A machine learning model hosted on Replicate.
"""
_client: "Client" = pydantic.PrivateAttr()
url: str
"""
The URL of the model.
"""
owner: str
"""
The owner of the model.
"""
name: str
"""
The name of the model.
"""
description: Optional[str]
"""
The description of the model.
"""
visibility: Literal["public", "private"]
"""
The visibility of the model. Can be 'public' or 'private'.
"""
github_url: Optional[str]
"""
The GitHub URL of the model.
"""
paper_url: Optional[str]
"""
The URL of the paper related to the model.
"""
license_url: Optional[str]
"""
The URL of the license for the model.
"""
run_count: int
"""
The number of runs of the model.
"""
cover_image_url: Optional[str]
"""
The URL of the cover image for the model.
"""
default_example: Optional[Prediction]
"""
The default example of the model.
"""
latest_version: Optional[Version]
"""
The latest version of the model.
"""
@property
def id(self) -> str:
"""
Return the qualified model name, in the format `owner/name`.
"""
return f"{self.owner}/{self.name}"
@property
@deprecated("Use `model.owner` instead.")
def username(self) -> str:
"""
The name of the user or organization that owns the model.
This attribute is deprecated and will be removed in future versions.
"""
return self.owner
@username.setter
@deprecated("Use `model.owner` instead.")
def username(self, value: str) -> None:
self.owner = value
def predict(self, *args, **kwargs) -> None:
"""
DEPRECATED: Use `replicate.run()` instead.
"""
raise ReplicateException(
"The `model.predict()` method has been removed, because it's unstable: if a new version of the model you're using is pushed and its API has changed, your code may break. Use `replicate.run()` instead. See https://github.com/replicate/replicate-python#readme"
)
@property
def versions(self) -> Versions:
"""
Get the versions of this model.
"""
return Versions(client=self._client, model=self)
def reload(self) -> None:
"""
Load this object from the server.
"""
obj = self._client.models.get(f"{self.owner}/{self.name}")
for name, value in obj.dict().items():
setattr(self, name, value)
class Models(Namespace):
"""
Namespace for operations related to models.
"""
model = Model
@property
def predictions(self) -> "ModelsPredictions":
"""
Get a namespace for operations related to predictions on a model.
"""
return ModelsPredictions(client=self._client)
def list(self, cursor: Union[str, "ellipsis", None] = ...) -> Page[Model]: # noqa: F821
"""
List all public models.
Parameters:
cursor: The cursor to use for pagination. Use the value of `Page.next` or `Page.previous`.
Returns:
Page[Model]: A page of of models.
Raises:
ValueError: If `cursor` is `None`.
"""
if cursor is None:
raise ValueError("cursor cannot be None")
resp = self._client._request("GET", "/v1/models" if cursor is ... else cursor)
obj = resp.json()
obj["results"] = [
_json_to_model(self._client, result) for result in obj["results"]
]
return Page[Model](**obj)
async def async_list(
self,
cursor: Union[str, "ellipsis", None] = ..., # noqa: F821
) -> Page[Model]:
"""
List all public models.
Parameters:
cursor: The cursor to use for pagination. Use the value of `Page.next` or `Page.previous`.
Returns:
Page[Model]: A page of of models.
Raises:
ValueError: If `cursor` is `None`.
"""
if cursor is None:
raise ValueError("cursor cannot be None")
resp = await self._client._async_request(
"GET", "/v1/models" if cursor is ... else cursor
)
obj = resp.json()
obj["results"] = [
_json_to_model(self._client, result) for result in obj["results"]
]
return Page[Model](**obj)
def search(self, query: str) -> Page[Model]:
"""
Search for public models.
Parameters:
query: The search query.
Returns:
Page[Model]: A page of models matching the search query.
"""
resp = self._client._request(
"QUERY", "/v1/models", content=query, headers={"Content-Type": "text/plain"}
)
obj = resp.json()
obj["results"] = [
_json_to_model(self._client, result) for result in obj["results"]
]
return Page[Model](**obj)
async def async_search(self, query: str) -> Page[Model]:
"""
Asynchronously search for public models.
Parameters:
query: The search query.
Returns:
Page[Model]: A page of models matching the search query.
"""
resp = await self._client._async_request(
"QUERY", "/v1/models", content=query, headers={"Content-Type": "text/plain"}
)
obj = resp.json()
obj["results"] = [
_json_to_model(self._client, result) for result in obj["results"]
]
return Page[Model](**obj)
@overload
def get(self, key: str) -> Model: ...
@overload
def get(self, owner: str, name: str) -> Model: ...
def get(self, *args, **kwargs) -> Model:
"""
Get a model by name.
"""
url = _get_model_url(*args, **kwargs)
resp = self._client._request("GET", url)
return _json_to_model(self._client, resp.json())
@overload
async def async_get(self, key: str) -> Model: ...
@overload
async def async_get(self, owner: str, name: str) -> Model: ...
async def async_get(self, *args, **kwargs) -> Model:
"""
Get a model by name.
Args:
key: The qualified name of the model, in the format `owner/name`.
Returns:
The model.
"""
url = _get_model_url(*args, **kwargs)
resp = await self._client._async_request("GET", url)
return _json_to_model(self._client, resp.json())
@overload
def delete(self, key: str) -> bool: ...
@overload
def delete(self, owner: str, name: str) -> bool: ...
def delete(self, *args, **kwargs) -> bool:
"""
Delete a model by name.
Returns:
`True` if deletion was successful, otherwise `False`.
"""
url = _delete_model_url(*args, **kwargs)
resp = self._client._request("DELETE", url)
return resp.status_code == 204
@overload
async def async_delete(self, key: str) -> bool: ...
@overload
async def async_delete(self, owner: str, name: str) -> bool: ...
async def async_delete(self, *args, **kwargs) -> bool:
"""
Asynchronously delete a model by name.
Returns:
`True` if deletion was successful, otherwise `False`.
"""
url = _delete_model_url(*args, **kwargs)
resp = await self._client._async_request("DELETE", url)
return resp.status_code == 204
class CreateModelParams(TypedDict):
"""Parameters for creating a model."""
hardware: str
"""The SKU for the hardware used to run the model.
Possible values can be found by calling `replicate.hardware.list()`."""
visibility: Literal["public", "private"]
"""Whether the model should be public or private."""
description: NotRequired[str]
"""The description of the model."""
github_url: NotRequired[str]
"""A URL for the model's source code on GitHub."""
paper_url: NotRequired[str]
"""A URL for the model's paper."""
license_url: NotRequired[str]
"""A URL for the model's license."""
cover_image_url: NotRequired[str]
"""A URL for the model's cover image."""
def create(
self,
owner: str,
name: str,
**params: Unpack["Models.CreateModelParams"],
) -> Model:
"""
Create a model.
"""
body = _create_model_body(owner, name, **params)
resp = self._client._request("POST", "/v1/models", json=body)
return _json_to_model(self._client, resp.json())
async def async_create(
self, owner: str, name: str, **params: Unpack["Models.CreateModelParams"]
) -> Model:
"""
Create a model.
"""
body = body = _create_model_body(owner, name, **params)
resp = await self._client._async_request("POST", "/v1/models", json=body)
return _json_to_model(self._client, resp.json())
class ModelsPredictions(Namespace):
"""
Namespace for operations related to predictions in a deployment.
"""
def create(
self,
model: Union[str, Tuple[str, str], "Model"],
input: Dict[str, Any],
**params: Unpack["Predictions.CreatePredictionParams"],
) -> Prediction:
"""
Create a new prediction with the deployment.
"""
wait = params.pop("wait", None)
file_encoding_strategy = params.pop("file_encoding_strategy", None)
path = _create_prediction_path_from_model(model)
if input is not None:
input = encode_json(
input,
client=self._client,
file_encoding_strategy=file_encoding_strategy,
)
body = _create_prediction_body(version=None, input=input, **params)
extras = _create_prediction_request_params(wait=wait)
resp = self._client._request("POST", path, json=body, **extras)
return _json_to_prediction(self._client, resp.json())
async def async_create(
self,
model: Union[str, Tuple[str, str], "Model"],
input: Dict[str, Any],
**params: Unpack["Predictions.CreatePredictionParams"],
) -> Prediction:
"""
Create a new prediction with the deployment.
"""
wait = params.pop("wait", None)
file_encoding_strategy = params.pop("file_encoding_strategy", None)
path = _create_prediction_path_from_model(model)
if input is not None:
input = await async_encode_json(
input,
client=self._client,
file_encoding_strategy=file_encoding_strategy,
)
body = _create_prediction_body(version=None, input=input, **params)
extras = _create_prediction_request_params(wait=wait)
resp = await self._client._async_request("POST", path, json=body, **extras)
return _json_to_prediction(self._client, resp.json())
def _create_model_body( # pylint: disable=too-many-arguments
owner: str,
name: str,
*,
visibility: str,
hardware: str,
description: Optional[str] = None,
github_url: Optional[str] = None,
paper_url: Optional[str] = None,
license_url: Optional[str] = None,
cover_image_url: Optional[str] = None,
) -> Dict[str, Any]:
body = {
"owner": owner,
"name": name,
"visibility": visibility,
"hardware": hardware,
}
if description is not None:
body["description"] = description
if github_url is not None:
body["github_url"] = github_url
if paper_url is not None:
body["paper_url"] = paper_url
if license_url is not None:
body["license_url"] = license_url
if cover_image_url is not None:
body["cover_image_url"] = cover_image_url
return body
def _get_model_url(*args, **kwargs) -> str:
if len(args) > 0 and len(kwargs) > 0:
raise ValueError("Cannot mix positional and keyword arguments")
owner = kwargs.get("owner", None)
name = kwargs.get("name", None)
key = kwargs.get("key", None)
if key and (owner or name):
raise ValueError(
"Must specify exactly one of 'owner' and 'name' or single 'key' in the format 'owner/name'"
)
if args:
if len(args) == 1:
key = args[0]
elif len(args) == 2:
owner, name = args
else:
raise ValueError("Invalid number of arguments")
if not key:
if not (owner and name):
raise ValueError(
"Both 'owner' and 'name' must be provided if 'key' is not specified."
)
key = f"{owner}/{name}"
return f"/v1/models/{key}"
def _delete_model_url(*args, **kwargs) -> str:
return _get_model_url(*args, **kwargs)
def _json_to_model(client: "Client", json: Dict[str, Any]) -> Model:
model = Model(**json)
model._client = client
if model.default_example is not None:
model.default_example._client = client
return model
def _create_prediction_path_from_model(
model: Union[str, Tuple[str, str], "Model"],
) -> str:
owner, name = None, None
if isinstance(model, Model):
owner, name = model.owner, model.name
elif isinstance(model, tuple):
owner, name = model[0], model[1]
elif isinstance(model, str):
owner, name, version_id = ModelVersionIdentifier.parse(model)
if version_id is not None:
raise ValueError(
f"Invalid reference to model version: {model}. Expected model or reference in the format owner/name"
)
if owner is None or name is None:
raise ValueError(
"model must be a Model, a tuple of (owner, name), or a string in the format 'owner/name'"
)
return f"/v1/models/{owner}/{name}/predictions"

View File

@@ -0,0 +1,80 @@
from typing import (
TYPE_CHECKING,
AsyncGenerator,
Awaitable,
Callable,
Generator,
Generic,
List,
Optional,
TypeVar,
Union,
)
try:
from pydantic import v1 as pydantic # type: ignore
except ImportError:
import pydantic # type: ignore
from replicate.resource import Resource
T = TypeVar("T", bound=Resource)
if TYPE_CHECKING:
pass
class Page(pydantic.BaseModel, Generic[T]): # type: ignore
"""
A page of results from the API.
"""
previous: Optional[str] = None
"""A pointer to the previous page of results"""
next: Optional[str] = None
"""A pointer to the next page of results"""
results: List[T]
"""The results on this page"""
def __iter__(self): # noqa: ANN204
return iter(self.results)
def __getitem__(self, index: int) -> T:
return self.results[index]
def __len__(self) -> int:
return len(self.results)
def paginate(
list_method: Callable[[Union[str, "ellipsis", None]], Page[T]], # noqa: F821
) -> Generator[Page[T], None, None]:
"""
Iterate over all items using the provided list method.
Args:
list_method: A method that takes a cursor argument and returns a Page of items.
"""
cursor: Union[str, "ellipsis", None] = ... # noqa: F821
while cursor is not None:
page = list_method(cursor)
yield page
cursor = page.next
async def async_paginate(
list_method: Callable[[Union[str, "ellipsis", None]], Awaitable[Page[T]]], # noqa: F821
) -> AsyncGenerator[Page[T], None]:
"""
Asynchronously iterate over all items using the provided list method.
Args:
list_method: An async method that takes a cursor argument and returns a Page of items.
"""
cursor: Union[str, "ellipsis", None] = ... # noqa: F821
while cursor is not None:
page = await list_method(cursor)
yield page
cursor = page.next

View File

@@ -0,0 +1,710 @@
import asyncio
import re
import time
from dataclasses import dataclass
from typing import (
TYPE_CHECKING,
Any,
AsyncIterator,
Dict,
Iterator,
List,
Literal,
Optional,
Tuple,
Union,
overload,
)
import httpx
from typing_extensions import NotRequired, TypedDict, Unpack
from replicate.exceptions import ModelError, ReplicateError
from replicate.file import FileEncodingStrategy
from replicate.helpers import async_encode_json, encode_json
from replicate.pagination import Page
from replicate.resource import Namespace, Resource
from replicate.stream import EventSource
from replicate.version import Version
try:
from pydantic import v1 as pydantic # type: ignore
except ImportError:
import pydantic # type: ignore
if TYPE_CHECKING:
from replicate.client import Client
from replicate.deployment import Deployment
from replicate.model import Model
from replicate.stream import ServerSentEvent
class Prediction(Resource):
"""
A prediction made by a model hosted on Replicate.
"""
_client: "Client" = pydantic.PrivateAttr()
id: str
"""The unique ID of the prediction."""
model: str
"""An identifier for the model used to create the prediction, in the form `owner/name`."""
version: str
"""An identifier for the version of the model used to create the prediction."""
status: Literal["starting", "processing", "succeeded", "failed", "canceled"]
"""The status of the prediction."""
input: Optional[Dict[str, Any]]
"""The input to the prediction."""
output: Optional[Any]
"""The output of the prediction."""
logs: Optional[str]
"""The logs of the prediction."""
error: Optional[str]
"""The error encountered during the prediction, if any."""
metrics: Optional[Dict[str, Any]]
"""Metrics for the prediction."""
created_at: Optional[str]
"""When the prediction was created."""
started_at: Optional[str]
"""When the prediction was started."""
completed_at: Optional[str]
"""When the prediction was completed, if finished."""
urls: Optional[Dict[str, str]]
"""
URLs associated with the prediction.
The following keys are available:
- `get`: A URL to fetch the prediction.
- `cancel`: A URL to cancel the prediction.
"""
@dataclass
class Progress:
"""
The progress of a prediction.
"""
percentage: float
"""The percentage of the prediction that has completed."""
current: int
"""The number of items that have been processed."""
total: int
"""The total number of items to process."""
_pattern = re.compile(
r"^\s*(?P<percentage>\d+)%\s*\|.+?\|\s*(?P<current>\d+)\/(?P<total>\d+)"
)
@classmethod
def parse(cls, logs: str) -> Optional["Prediction.Progress"]:
"""Parse the progress from the logs of a prediction."""
lines = logs.split("\n")
for idx in reversed(range(len(lines))):
line = lines[idx].strip()
if cls._pattern.match(line):
matches = cls._pattern.findall(line)
if len(matches) == 1:
percentage, current, total = map(int, matches[0])
return cls(percentage / 100.0, current, total)
return None
@property
def progress(self) -> Optional[Progress]:
"""
The progress of the prediction, if available.
"""
if self.logs is None or self.logs == "":
return None
return Prediction.Progress.parse(self.logs)
def wait(self) -> None:
"""
Wait for prediction to finish.
"""
while self.status not in ["succeeded", "failed", "canceled"]:
time.sleep(self._client.poll_interval)
self.reload()
async def async_wait(self) -> None:
"""
Wait for prediction to finish asynchronously.
"""
while self.status not in ["succeeded", "failed", "canceled"]:
await asyncio.sleep(self._client.poll_interval)
await self.async_reload()
def stream(
self,
use_file_output: Optional[bool] = None,
) -> Iterator["ServerSentEvent"]:
"""
Stream the prediction output.
Raises:
ReplicateError: If the model does not support streaming.
"""
url = self.urls and self.urls.get("stream", None)
if not url or not isinstance(url, str):
raise ReplicateError("Model does not support streaming")
headers = {}
headers["Accept"] = "text/event-stream"
headers["Cache-Control"] = "no-store"
with self._client._client.stream("GET", url, headers=headers) as response:
yield from EventSource(
self._client, response, use_file_output=use_file_output
)
async def async_stream(
self,
use_file_output: Optional[bool] = None,
) -> AsyncIterator["ServerSentEvent"]:
"""
Stream the prediction output asynchronously.
Raises:
ReplicateError: If the model does not support streaming.
"""
# no-op to enforce the use of 'await' when calling this method
await asyncio.sleep(0)
url = self.urls and self.urls.get("stream", None)
if not url or not isinstance(url, str):
raise ReplicateError("Model does not support streaming")
headers = {}
headers["Accept"] = "text/event-stream"
headers["Cache-Control"] = "no-store"
async with self._client._async_client.stream(
"GET", url, headers=headers
) as response:
async for event in EventSource(
self._client, response, use_file_output=use_file_output
):
yield event
def cancel(self) -> None:
"""
Cancels a running prediction.
"""
canceled = self._client.predictions.cancel(self.id)
for name, value in canceled.dict().items():
setattr(self, name, value)
async def async_cancel(self) -> None:
"""
Cancels a running prediction asynchronously.
"""
canceled = await self._client.predictions.async_cancel(self.id)
for name, value in canceled.dict().items():
setattr(self, name, value)
def reload(self) -> None:
"""
Load this prediction from the server.
"""
updated = self._client.predictions.get(self.id)
for name, value in updated.dict().items():
setattr(self, name, value)
async def async_reload(self) -> None:
"""
Load this prediction from the server asynchronously.
"""
updated = await self._client.predictions.async_get(self.id)
for name, value in updated.dict().items():
setattr(self, name, value)
def output_iterator(self) -> Iterator[Any]:
"""
Return an iterator of the prediction output.
"""
# TODO: check output is list
previous_output = self.output or []
while self.status not in ["succeeded", "failed", "canceled"]:
output = self.output or []
new_output = output[len(previous_output) :]
yield from new_output
previous_output = output
time.sleep(self._client.poll_interval) # pylint: disable=no-member
self.reload()
if self.status == "failed":
raise ModelError(self)
output = self.output or []
new_output = output[len(previous_output) :]
yield from new_output
async def async_output_iterator(self) -> AsyncIterator[Any]:
"""
Return an asynchronous iterator of the prediction output.
"""
# TODO: check output is list
previous_output = self.output or []
while self.status not in ["succeeded", "failed", "canceled"]:
output = self.output or []
new_output = output[len(previous_output) :]
for item in new_output:
yield item
previous_output = output
await asyncio.sleep(self._client.poll_interval) # pylint: disable=no-member
await self.async_reload()
if self.status == "failed":
raise ModelError(self)
output = self.output or []
new_output = output[len(previous_output) :]
for output in new_output:
yield output
class Predictions(Namespace):
"""
Namespace for operations related to predictions.
"""
def list(self, cursor: Union[str, "ellipsis", None] = ...) -> Page[Prediction]: # noqa: F821
"""
List your predictions.
Parameters:
cursor: The cursor to use for pagination. Use the value of `Page.next` or `Page.previous`.
Returns:
Page[Prediction]: A page of of predictions.
Raises:
ValueError: If `cursor` is `None`.
"""
if cursor is None:
raise ValueError("cursor cannot be None")
resp = self._client._request(
"GET", "/v1/predictions" if cursor is ... else cursor
)
obj = resp.json()
obj["results"] = [
_json_to_prediction(self._client, result) for result in obj["results"]
]
return Page[Prediction](**obj)
async def async_list(
self,
cursor: Union[str, "ellipsis", None] = ..., # noqa: F821
) -> Page[Prediction]:
"""
List your predictions.
Parameters:
cursor: The cursor to use for pagination. Use the value of `Page.next` or `Page.previous`.
Returns:
Page[Prediction]: A page of of predictions.
Raises:
ValueError: If `cursor` is `None`.
"""
if cursor is None:
raise ValueError("cursor cannot be None")
resp = await self._client._async_request(
"GET", "/v1/predictions" if cursor is ... else cursor
)
obj = resp.json()
obj["results"] = [
_json_to_prediction(self._client, result) for result in obj["results"]
]
return Page[Prediction](**obj)
def get(self, id: str) -> Prediction:
"""
Get a prediction by ID.
Args:
id: The ID of the prediction.
Returns:
Prediction: The prediction object.
"""
resp = self._client._request("GET", f"/v1/predictions/{id}")
return _json_to_prediction(self._client, resp.json())
async def async_get(self, id: str) -> Prediction:
"""
Get a prediction by ID.
Args:
id: The ID of the prediction.
Returns:
Prediction: The prediction object.
"""
resp = await self._client._async_request("GET", f"/v1/predictions/{id}")
return _json_to_prediction(self._client, resp.json())
class CreatePredictionParams(TypedDict):
"""Parameters for creating a prediction."""
webhook: NotRequired[str]
"""The URL to receive a POST request with prediction updates."""
webhook_completed: NotRequired[str]
"""The URL to receive a POST request when the prediction is completed."""
webhook_events_filter: NotRequired[List[str]]
"""List of events to trigger webhooks."""
stream: NotRequired[bool]
"""Enable streaming of prediction output."""
wait: NotRequired[Union[int, bool]]
"""
Block until the prediction is completed before returning.
If `True`, keep the request open for up to 60 seconds, falling back to
polling until the prediction is completed.
If an `int`, same as True but hold the request for a specified number of
seconds (between 1 and 60).
If `False`, poll for the prediction status until completed.
"""
file_encoding_strategy: NotRequired[FileEncodingStrategy]
"""The strategy to use for encoding files in the prediction input."""
@overload
def create(
self,
version: Union[Version, str],
input: Optional[Dict[str, Any]],
**params: Unpack["Predictions.CreatePredictionParams"],
) -> Prediction: ...
@overload
def create(
self,
*,
model: Union[str, Tuple[str, str], "Model"],
input: Optional[Dict[str, Any]],
**params: Unpack["Predictions.CreatePredictionParams"],
) -> Prediction: ...
@overload
def create(
self,
*,
deployment: Union[str, Tuple[str, str], "Deployment"],
input: Optional[Dict[str, Any]],
**params: Unpack["Predictions.CreatePredictionParams"],
) -> Prediction: ...
def create( # type: ignore
self,
*args,
model: Optional[Union[str, Tuple[str, str], "Model"]] = None,
version: Optional[Union[Version, str, "Version"]] = None,
deployment: Optional[Union[str, Tuple[str, str], "Deployment"]] = None,
input: Optional[Dict[str, Any]] = None,
**params: Unpack["Predictions.CreatePredictionParams"],
) -> Prediction:
"""
Create a new prediction for the specified model, version, or deployment.
"""
wait = params.pop("wait", None)
file_encoding_strategy = params.pop("file_encoding_strategy", None)
if args:
version = args[0] if len(args) > 0 else None
input = args[1] if len(args) > 1 else input
if sum(bool(x) for x in [model, version, deployment]) != 1:
raise ValueError(
"Exactly one of 'model', 'version', or 'deployment' must be specified."
)
if model is not None:
from replicate.model import ( # pylint: disable=import-outside-toplevel
Models,
)
return Models(self._client).predictions.create(
model=model,
input=input or {},
**params,
)
if deployment is not None:
from replicate.deployment import ( # pylint: disable=import-outside-toplevel
Deployments,
)
return Deployments(self._client).predictions.create(
deployment=deployment,
input=input or {},
**params,
)
if input is not None:
input = encode_json(
input,
client=self._client,
file_encoding_strategy=file_encoding_strategy,
)
body = _create_prediction_body(
version,
input,
**params,
)
extras = _create_prediction_request_params(wait=wait)
resp = self._client._request("POST", "/v1/predictions", json=body, **extras)
return _json_to_prediction(self._client, resp.json())
@overload
async def async_create(
self,
version: Union[Version, str],
input: Optional[Dict[str, Any]],
**params: Unpack["Predictions.CreatePredictionParams"],
) -> Prediction: ...
@overload
async def async_create(
self,
*,
model: Union[str, Tuple[str, str], "Model"],
input: Optional[Dict[str, Any]],
**params: Unpack["Predictions.CreatePredictionParams"],
) -> Prediction: ...
@overload
async def async_create(
self,
*,
deployment: Union[str, Tuple[str, str], "Deployment"],
input: Optional[Dict[str, Any]],
**params: Unpack["Predictions.CreatePredictionParams"],
) -> Prediction: ...
async def async_create( # type: ignore
self,
*args,
model: Optional[Union[str, Tuple[str, str], "Model"]] = None,
version: Optional[Union[Version, str, "Version"]] = None,
deployment: Optional[Union[str, Tuple[str, str], "Deployment"]] = None,
input: Optional[Dict[str, Any]] = None,
**params: Unpack["Predictions.CreatePredictionParams"],
) -> Prediction:
"""
Create a new prediction for the specified model, version, or deployment.
"""
wait = params.pop("wait", None)
file_encoding_strategy = params.pop("file_encoding_strategy", None)
if args:
version = args[0] if len(args) > 0 else None
input = args[1] if len(args) > 1 else input
if sum(bool(x) for x in [model, version, deployment]) != 1:
raise ValueError(
"Exactly one of 'model', 'version', or 'deployment' must be specified."
)
if model is not None:
from replicate.model import ( # pylint: disable=import-outside-toplevel
Models,
)
return await Models(self._client).predictions.async_create(
model=model,
input=input or {},
**params,
)
if deployment is not None:
from replicate.deployment import ( # pylint: disable=import-outside-toplevel
Deployments,
)
return await Deployments(self._client).predictions.async_create(
deployment=deployment,
input=input or {},
**params,
)
if input is not None:
input = await async_encode_json(
input,
client=self._client,
file_encoding_strategy=file_encoding_strategy,
)
body = _create_prediction_body(
version,
input,
**params,
)
extras = _create_prediction_request_params(wait=wait)
resp = await self._client._async_request(
"POST", "/v1/predictions", json=body, **extras
)
return _json_to_prediction(self._client, resp.json())
def cancel(self, id: str) -> Prediction:
"""
Cancel a prediction.
Args:
id: The ID of the prediction to cancel.
Returns:
Prediction: The canceled prediction object.
"""
resp = self._client._request(
"POST",
f"/v1/predictions/{id}/cancel",
)
return _json_to_prediction(self._client, resp.json())
async def async_cancel(self, id: str) -> Prediction:
"""
Cancel a prediction.
Args:
id: The ID of the prediction to cancel.
Returns:
Prediction: The canceled prediction object.
"""
resp = await self._client._async_request(
"POST",
f"/v1/predictions/{id}/cancel",
)
return _json_to_prediction(self._client, resp.json())
class CreatePredictionRequestParams(TypedDict):
headers: NotRequired[Optional[dict]]
timeout: NotRequired[Optional[httpx.Timeout]]
def _create_prediction_request_params(
wait: Optional[Union[int, bool]],
) -> CreatePredictionRequestParams:
timeout = _create_prediction_timeout(wait=wait)
headers = _create_prediction_headers(wait=wait)
return {
"headers": headers,
"timeout": timeout,
}
def _create_prediction_timeout(
*, wait: Optional[Union[int, bool]] = None
) -> Union[httpx.Timeout, None]:
"""
Returns an `httpx.Timeout` instances appropriate for the optional
`Prefer: wait=x` header that can be provided with the request. This
will ensure that we give the server enough time to respond with
a partial prediction in the event that the request times out.
"""
if not wait:
return None
read_timeout = 60.0 if isinstance(wait, bool) else wait
return httpx.Timeout(5.0, read=read_timeout + 0.5)
def _create_prediction_headers(
*,
wait: Optional[Union[int, bool]] = None,
) -> Dict[str, Any]:
headers = {}
if wait:
if isinstance(wait, bool):
headers["Prefer"] = "wait"
elif isinstance(wait, int):
headers["Prefer"] = f"wait={wait}"
return headers
def _create_prediction_body( # pylint: disable=too-many-arguments
version: Optional[Union[Version, str]],
input: Optional[Dict[str, Any]],
webhook: Optional[str] = None,
webhook_completed: Optional[str] = None,
webhook_events_filter: Optional[List[str]] = None,
stream: Optional[bool] = None,
**_kwargs,
) -> Dict[str, Any]:
body = {}
if input is not None:
body["input"] = input
if version is not None:
body["version"] = version.id if isinstance(version, Version) else version
if webhook is not None:
body["webhook"] = webhook
if webhook_completed is not None:
body["webhook_completed"] = webhook_completed
if webhook_events_filter is not None:
body["webhook_events_filter"] = webhook_events_filter
if stream is not None:
body["stream"] = stream
return body
def _json_to_prediction(client: "Client", json: Dict[str, Any]) -> Prediction:
prediction = Prediction(**json)
prediction._client = client
return prediction

View File

@@ -0,0 +1,27 @@
import abc
from typing import TYPE_CHECKING
try:
from pydantic import v1 as pydantic # type: ignore
except ImportError:
import pydantic # type: ignore
if TYPE_CHECKING:
from replicate.client import Client
class Resource(pydantic.BaseModel): # type: ignore
"""
A base class for representing a single object on the server.
"""
class Namespace(abc.ABC):
"""
A base class for representing objects of a particular type on the server.
"""
_client: "Client"
def __init__(self, client: "Client") -> None:
self._client = client

View File

@@ -0,0 +1,185 @@
from typing import (
TYPE_CHECKING,
Any,
AsyncIterator,
Dict,
Iterator,
List,
Optional,
Union,
)
from typing_extensions import Unpack
from replicate import identifier
from replicate.exceptions import ModelError
from replicate.helpers import transform_output
from replicate.model import Model
from replicate.schema import make_schema_backwards_compatible
from replicate.version import Version, Versions
if TYPE_CHECKING:
from replicate.client import Client
from replicate.identifier import ModelVersionIdentifier
from replicate.prediction import Predictions
def run(
client: "Client",
ref: Union["Model", "Version", "ModelVersionIdentifier", str],
input: Optional[Dict[str, Any]] = None,
*,
use_file_output: Optional[bool] = True,
**params: Unpack["Predictions.CreatePredictionParams"],
) -> Union[Any, Iterator[Any]]: # noqa: ANN401
"""
Run a model and wait for its output.
"""
if "wait" not in params:
params["wait"] = True
is_blocking = params["wait"] is not False
version, owner, name, version_id = identifier._resolve(ref)
if version_id is not None:
prediction = client.predictions.create(
version=version_id, input=input or {}, **params
)
elif owner and name:
prediction = client.models.predictions.create(
model=(owner, name), input=input or {}, **params
)
else:
raise ValueError(
f"Invalid argument: {ref}. Expected model, version, or reference in the format owner/name or owner/name:version"
)
if not version and (owner and name and version_id):
version = Versions(client, model=(owner, name)).get(version_id)
# Currently the "Prefer: wait" interface will return a prediction with a status
# of "processing" rather than a terminal state because it returns before the
# prediction has been fully processed. If request exceeds the wait time, even if
# it is actually processing, the prediction will be in a "starting" state.
#
# We should fix this in the blocking API itself. Predictions that are done should
# be in a terminal state and predictions that are processing should be in state
# "processing".
in_terminal_state = is_blocking and prediction.status != "starting"
if not in_terminal_state:
# Return a "polling" iterator if the model has an output iterator array type.
if version and _has_output_iterator_array_type(version):
return (
transform_output(chunk, client)
for chunk in prediction.output_iterator()
)
prediction.wait()
if prediction.status == "failed":
raise ModelError(prediction)
# Return an iterator for the completed prediction when needed.
if (
version
and _has_output_iterator_array_type(version)
and prediction.output is not None
):
return (transform_output(chunk, client) for chunk in prediction.output)
if use_file_output:
return transform_output(prediction.output, client)
return prediction.output
async def async_run(
client: "Client",
ref: Union["Model", "Version", "ModelVersionIdentifier", str],
input: Optional[Dict[str, Any]] = None,
*,
use_file_output: Optional[bool] = True,
**params: Unpack["Predictions.CreatePredictionParams"],
) -> Union[Any, AsyncIterator[Any]]: # noqa: ANN401
"""
Run a model and wait for its output asynchronously.
"""
if "wait" not in params:
params["wait"] = True
is_blocking = params["wait"] is not False
version, owner, name, version_id = identifier._resolve(ref)
if version or version_id:
prediction = await client.predictions.async_create(
version=(version or version_id), input=input or {}, **params
)
elif owner and name:
prediction = await client.models.predictions.async_create(
model=(owner, name), input=input or {}, **params
)
else:
raise ValueError(
f"Invalid argument: {ref}. Expected model, version, or reference in the format owner/name or owner/name:version"
)
if not version and (owner and name and version_id):
version = await Versions(client, model=(owner, name)).async_get(version_id)
# Currently the "Prefer: wait" interface will return a prediction with a status
# of "processing" rather than a terminal state because it returns before the
# prediction has been fully processed. If request exceeds the wait time, even if
# it is actually processing, the prediction will be in a "starting" state.
#
# We should fix this in the blocking API itself. Predictions that are done should
# be in a terminal state and predictions that are processing should be in state
# "processing".
in_terminal_state = is_blocking and prediction.status != "starting"
if not in_terminal_state:
# Return a "polling" iterator if the model has an output iterator array type.
if version and _has_output_iterator_array_type(version):
return (
transform_output(chunk, client)
async for chunk in prediction.async_output_iterator()
)
await prediction.async_wait()
if prediction.status == "failed":
raise ModelError(prediction)
# Return an iterator for completed output if the model has an output iterator array type.
if (
version
and _has_output_iterator_array_type(version)
and prediction.output is not None
):
return (
transform_output(chunk, client)
async for chunk in _make_async_iterator(prediction.output)
)
if use_file_output:
return transform_output(prediction.output, client)
return prediction.output
def _has_output_iterator_array_type(version: Version) -> bool:
schema = make_schema_backwards_compatible(
version.openapi_schema, version.cog_version
)
output = schema.get("components", {}).get("schemas", {}).get("Output", {})
return (
output.get("type") == "array" and output.get("x-cog-array-type") == "iterator"
)
async def _make_async_iterator(list: list) -> AsyncIterator:
for item in list:
yield item
__all__: List = []

View File

@@ -0,0 +1,27 @@
from typing import Optional
from packaging import version
# TODO: this code is shared with replicate's backend. Maybe we should put it in the Cog Python package as the source of truth?
def version_has_no_array_type(cog_version: str) -> Optional[bool]:
"""Iterators have x-cog-array-type=iterator in the schema from 0.3.9 onward"""
try:
return version.parse(cog_version) < version.parse("0.3.9")
except version.InvalidVersion:
return None
def make_schema_backwards_compatible(
schema: dict,
cog_version: str,
) -> dict:
"""A place to add backwards compatibility logic for our openapi schema"""
# If the top-level output is an array, assume it is an iterator in old versions which didn't have an array type
if version_has_no_array_type(cog_version):
output = schema["components"]["schemas"]["Output"]
if output.get("type") == "array":
output["x-cog-array-type"] = "iterator"
return schema

View File

@@ -0,0 +1,280 @@
from enum import Enum
from typing import (
TYPE_CHECKING,
Any,
AsyncIterator,
Dict,
Iterator,
List,
Optional,
Union,
)
import httpx
from typing_extensions import Unpack
from replicate import identifier
from replicate.exceptions import ReplicateError
from replicate.helpers import transform_output
try:
from pydantic import v1 as pydantic # type: ignore
except ImportError:
import pydantic # type: ignore
if TYPE_CHECKING:
from replicate.client import Client
from replicate.identifier import ModelVersionIdentifier
from replicate.model import Model
from replicate.prediction import Predictions
from replicate.version import Version
class ServerSentEvent(pydantic.BaseModel): # type: ignore
"""
A server-sent event.
"""
class EventType(Enum):
"""
A server-sent event type.
"""
OUTPUT = "output"
LOGS = "logs"
ERROR = "error"
DONE = "done"
event: EventType
data: str
id: str
retry: Optional[int]
def __str__(self) -> str:
if self.event == ServerSentEvent.EventType.OUTPUT:
return self.data
return ""
class EventSource:
"""
A server-sent event source.
"""
client: "Client"
response: "httpx.Response"
use_file_output: bool
def __init__(
self,
client: "Client",
response: "httpx.Response",
*,
use_file_output: Optional[bool] = True,
) -> None:
self.client = client
self.response = response
self.use_file_output = use_file_output or True
content_type, _, _ = response.headers["content-type"].partition(";")
if content_type != "text/event-stream":
raise ValueError(
"Expected response Content-Type to be 'text/event-stream', "
f"got {content_type!r}"
)
class Decoder:
"""
A decoder for server-sent events.
"""
event: Optional["ServerSentEvent.EventType"]
data: List[str]
last_event_id: Optional[str]
retry: Optional[int]
def __init__(self) -> None:
self.event = None
self.data = []
self.last_event_id = None
self.retry = None
def decode(self, line: str) -> Optional[ServerSentEvent]:
"""
Decode a line and return a server-sent event if applicable.
"""
if not line:
if (
not any([self.event, self.data, self.last_event_id, self.retry])
or self.event is None
or self.last_event_id is None
):
return None
sse = ServerSentEvent(
event=self.event,
data="\n".join(self.data),
id=self.last_event_id,
retry=self.retry,
)
self.event = None
self.data = []
self.retry = None
return sse
if line.startswith(":"):
return None
fieldname, _, value = line.partition(":")
value = value[1:] if value.startswith(" ") else value
if fieldname == "event":
if event := ServerSentEvent.EventType(value):
self.event = event
elif fieldname == "data":
self.data.append(value)
elif fieldname == "id":
if "\0" not in value:
self.last_event_id = value
elif fieldname == "retry":
try:
self.retry = int(value)
except (TypeError, ValueError):
pass
return None
def __iter__(self) -> Iterator[ServerSentEvent]:
decoder = EventSource.Decoder()
for line in self.response.iter_lines():
line = line.rstrip("\n")
sse = decoder.decode(line)
if sse is not None:
if sse.event == ServerSentEvent.EventType.ERROR:
raise RuntimeError(sse.data)
if (
self.use_file_output
and sse.event == ServerSentEvent.EventType.OUTPUT
):
sse.data = transform_output(sse.data, client=self.client)
yield sse
if sse.event == ServerSentEvent.EventType.DONE:
return
async def __aiter__(self) -> AsyncIterator[ServerSentEvent]:
decoder = EventSource.Decoder()
async for line in self.response.aiter_lines():
line = line.rstrip("\n")
sse = decoder.decode(line)
if sse is not None:
if sse.event == ServerSentEvent.EventType.ERROR:
raise RuntimeError(sse.data)
if (
self.use_file_output
and sse.event == ServerSentEvent.EventType.OUTPUT
):
sse.data = transform_output(sse.data, client=self.client)
yield sse
if sse.event == ServerSentEvent.EventType.DONE:
return
def stream(
client: "Client",
ref: Union["Model", "Version", "ModelVersionIdentifier", str],
input: Optional[Dict[str, Any]] = None,
*,
use_file_output: Optional[bool] = True,
**params: Unpack["Predictions.CreatePredictionParams"],
) -> Iterator[ServerSentEvent]:
"""
Run a model and stream its output.
"""
params = params or {}
params["stream"] = True
version, owner, name, version_id = identifier._resolve(ref)
if version or version_id:
prediction = client.predictions.create(
version=(version or version_id), input=input or {}, **params
)
elif owner and name:
prediction = client.models.predictions.create(
model=(owner, name), input=input or {}, **params
)
else:
raise ValueError(
f"Invalid argument: {ref}. Expected model, version, or reference in the format owner/name or owner/name:version"
)
url = prediction.urls and prediction.urls.get("stream", None)
if not url or not isinstance(url, str):
raise ReplicateError("Model does not support streaming")
headers = {}
headers["Accept"] = "text/event-stream"
headers["Cache-Control"] = "no-store"
with client._client.stream("GET", url, headers=headers) as response:
yield from EventSource(client, response, use_file_output=use_file_output)
async def async_stream(
client: "Client",
ref: Union["Model", "Version", "ModelVersionIdentifier", str],
input: Optional[Dict[str, Any]] = None,
*,
use_file_output: Optional[bool] = True,
**params: Unpack["Predictions.CreatePredictionParams"],
) -> AsyncIterator[ServerSentEvent]:
"""
Run a model and stream its output asynchronously.
"""
params = params or {}
params["stream"] = True
version, owner, name, version_id = identifier._resolve(ref)
if version or version_id:
prediction = await client.predictions.async_create(
version=(version or version_id), input=input or {}, **params
)
elif owner and name:
prediction = await client.models.predictions.async_create(
model=(owner, name), input=input or {}, **params
)
else:
raise ValueError(
f"Invalid argument: {ref}. Expected model, version, or reference in the format owner/name or owner/name:version"
)
url = prediction.urls and prediction.urls.get("stream", None)
if not url or not isinstance(url, str):
raise ReplicateError("Model does not support streaming")
headers = {}
headers["Accept"] = "text/event-stream"
headers["Cache-Control"] = "no-store"
async with client._async_client.stream("GET", url, headers=headers) as response:
async for event in EventSource(
client, response, use_file_output=use_file_output
):
yield event
__all__ = ["ServerSentEvent"]

View File

@@ -0,0 +1,460 @@
from typing import (
TYPE_CHECKING,
Any,
Dict,
List,
Literal,
Optional,
Tuple,
TypedDict,
Union,
overload,
)
from typing_extensions import NotRequired, Unpack
from replicate.helpers import async_encode_json, encode_json
from replicate.identifier import ModelVersionIdentifier
from replicate.model import Model
from replicate.pagination import Page
from replicate.resource import Namespace, Resource
from replicate.version import Version
try:
from pydantic import v1 as pydantic # type: ignore
except ImportError:
import pydantic # type: ignore
if TYPE_CHECKING:
from replicate.client import Client
from replicate.file import FileEncodingStrategy
class Training(Resource):
"""
A training made for a model hosted on Replicate.
"""
_client: "Client" = pydantic.PrivateAttr()
id: str
"""The unique ID of the training."""
model: str
"""An identifier for the model used to create the prediction, in the form `owner/name`."""
version: Union[str, Version]
"""The version of the model used to create the training."""
destination: Optional[str]
"""The model destination of the training."""
status: Literal["starting", "processing", "succeeded", "failed", "canceled"]
"""The status of the training."""
input: Optional[Dict[str, Any]]
"""The input to the training."""
output: Optional[Any]
"""The output of the training."""
logs: Optional[str]
"""The logs of the training."""
error: Optional[str]
"""The error encountered during the training, if any."""
created_at: Optional[str]
"""When the training was created."""
started_at: Optional[str]
"""When the training was started."""
completed_at: Optional[str]
"""When the training was completed, if finished."""
urls: Optional[Dict[str, str]]
"""
URLs associated with the training.
The following keys are available:
- `get`: A URL to fetch the training.
- `cancel`: A URL to cancel the training.
"""
def cancel(self) -> None:
"""
Cancel a running training.
"""
canceled = self._client.trainings.cancel(self.id)
for name, value in canceled.dict().items():
setattr(self, name, value)
async def async_cancel(self) -> None:
"""
Cancel a running training asynchronously.
"""
canceled = await self._client.trainings.async_cancel(self.id)
for name, value in canceled.dict().items():
setattr(self, name, value)
def reload(self) -> None:
"""
Load the training from the server.
"""
updated = self._client.trainings.get(self.id)
for name, value in updated.dict().items():
setattr(self, name, value)
async def async_reload(self) -> None:
"""
Load the training from the server asynchronously.
"""
updated = await self._client.trainings.async_get(self.id)
for name, value in updated.dict().items():
setattr(self, name, value)
class Trainings(Namespace):
"""
Namespace for operations related to trainings.
"""
def list(self, cursor: Union[str, "ellipsis", None] = ...) -> Page[Training]: # noqa: F821
"""
List your trainings.
Parameters:
cursor: The cursor to use for pagination. Use the value of `Page.next` or `Page.previous`.
Returns:
Page[Training]: A page of trainings.
Raises:
ValueError: If `cursor` is `None`.
"""
if cursor is None:
raise ValueError("cursor cannot be None")
resp = self._client._request(
"GET", "/v1/trainings" if cursor is ... else cursor
)
obj = resp.json()
obj["results"] = [
_json_to_training(self._client, result) for result in obj["results"]
]
return Page[Training](**obj)
async def async_list(
self,
cursor: Union[str, "ellipsis", None] = ..., # noqa: F821
) -> Page[Training]:
"""
List your trainings.
Parameters:
cursor: The cursor to use for pagination. Use the value of `Page.next` or `Page.previous`.
Returns:
Page[Training]: A page of trainings.
Raises:
ValueError: If `cursor` is `None`.
"""
if cursor is None:
raise ValueError("cursor cannot be None")
resp = await self._client._async_request(
"GET", "/v1/trainings" if cursor is ... else cursor
)
obj = resp.json()
obj["results"] = [
_json_to_training(self._client, result) for result in obj["results"]
]
return Page[Training](**obj)
def get(self, id: str) -> Training:
"""
Get a training by ID.
Args:
id: The ID of the training.
Returns:
Training: The training object.
"""
resp = self._client._request(
"GET",
f"/v1/trainings/{id}",
)
return _json_to_training(self._client, resp.json())
async def async_get(self, id: str) -> Training:
"""
Get a training by ID.
Args:
id: The ID of the training.
Returns:
Training: The training object.
"""
resp = await self._client._async_request(
"GET",
f"/v1/trainings/{id}",
)
return _json_to_training(self._client, resp.json())
class CreateTrainingParams(TypedDict):
"""Parameters for creating a training."""
destination: Union[str, Tuple[str, str], "Model"]
"""The destination for the trained model."""
webhook: NotRequired[str]
"""The URL to receive a POST request with training updates."""
webhook_completed: NotRequired[str]
"""The URL to receive a POST request when the training is completed."""
webhook_events_filter: NotRequired[List[str]]
"""List of events to trigger webhooks."""
file_encoding_strategy: NotRequired["FileEncodingStrategy"]
"""The strategy to use for encoding files in the training input."""
@overload
def create( # pylint: disable=too-many-arguments
self,
version: str,
input: Dict[str, Any],
destination: str,
webhook: Optional[str] = None,
webhook_events_filter: Optional[List[str]] = None,
**kwargs,
) -> Training: ...
@overload
def create(
self,
model: Union[str, Tuple[str, str], "Model"],
version: Union[str, Version],
input: Optional[Dict[str, Any]] = None,
**params: Unpack["Trainings.CreateTrainingParams"],
) -> Training: ...
def create( # type: ignore
self,
*args,
model: Optional[Union[str, Tuple[str, str], "Model"]] = None,
version: Optional[Union[str, Version]] = None,
input: Optional[Dict[str, Any]] = None,
**params: Unpack["Trainings.CreateTrainingParams"],
) -> Training:
"""
Create a new training using the specified model version as a base.
"""
url = None
# Support positional arguments for backwards compatibility
if args:
if shorthand := args[0] if len(args) > 0 else None:
url = _create_training_url_from_shorthand(shorthand)
input = args[1] if len(args) > 1 else input
if len(args) > 2:
params["destination"] = args[2]
if len(args) > 3:
params["webhook"] = args[3]
if len(args) > 4:
params["webhook_completed"] = args[4]
if len(args) > 5:
params["webhook_events_filter"] = args[5]
elif model and version:
url = _create_training_url_from_model_and_version(model, version)
elif model is None and isinstance(version, str):
url = _create_training_url_from_shorthand(version)
if not url:
raise ValueError("model and version or shorthand version must be specified")
file_encoding_strategy = params.pop("file_encoding_strategy", None)
if input is not None:
input = encode_json(
input,
client=self._client,
file_encoding_strategy=file_encoding_strategy,
)
body = _create_training_body(input, **params)
resp = self._client._request(
"POST",
url,
json=body,
)
return _json_to_training(self._client, resp.json())
async def async_create(
self,
model: Union[str, Tuple[str, str], "Model"],
version: Union[str, Version],
input: Dict[str, Any],
**params: Unpack["Trainings.CreateTrainingParams"],
) -> Training:
"""
Create a new training using the specified model version as a base.
Args:
version: The ID of the base model version that you're using to train a new model version.
input: The input to the training.
destination: The desired model to push to in the format `{owner}/{model_name}`. This should be an existing model owned by the user or organization making the API request.
webhook: The URL to send a POST request to when the training is completed. Defaults to None.
webhook_completed: The URL to receive a POST request when the prediction is completed.
webhook_events_filter: The events to send to the webhook. Defaults to None.
Returns:
The training object.
"""
url = _create_training_url_from_model_and_version(model, version)
file_encoding_strategy = params.pop("file_encoding_strategy", None)
if input is not None:
input = await async_encode_json(
input,
client=self._client,
file_encoding_strategy=file_encoding_strategy,
)
body = _create_training_body(input, **params)
resp = await self._client._async_request(
"POST",
url,
json=body,
)
return _json_to_training(self._client, resp.json())
def cancel(self, id: str) -> Training:
"""
Cancel a training.
Args:
id: The ID of the training to cancel.
Returns:
Training: The canceled training object.
"""
resp = self._client._request(
"POST",
f"/v1/trainings/{id}/cancel",
)
return _json_to_training(self._client, resp.json())
async def async_cancel(self, id: str) -> Training:
"""
Cancel a training.
Args:
id: The ID of the training to cancel.
Returns:
Training: The canceled training object.
"""
resp = await self._client._async_request(
"POST",
f"/v1/trainings/{id}/cancel",
)
return _json_to_training(self._client, resp.json())
def _create_training_body(
input: Optional[Dict[str, Any]] = None,
*,
destination: Optional[Union[str, Tuple[str, str], "Model"]] = None,
webhook: Optional[str] = None,
webhook_completed: Optional[str] = None,
webhook_events_filter: Optional[List[str]] = None,
**_kwargs,
) -> Dict[str, Any]:
body = {}
if input is not None:
body["input"] = input
if destination is None:
raise ValueError(
"A destination must be provided as a positional or keyword argument."
)
if isinstance(destination, Model):
destination = f"{destination.owner}/{destination.name}"
elif isinstance(destination, tuple):
destination = f"{destination[0]}/{destination[1]}"
body["destination"] = destination
if webhook is not None:
body["webhook"] = webhook
if webhook_completed is not None:
body["webhook_completed"] = webhook_completed
if webhook_events_filter is not None:
body["webhook_events_filter"] = webhook_events_filter
return body
def _create_training_url_from_shorthand(ref: str) -> str:
owner, name, version_id = ModelVersionIdentifier.parse(ref)
return f"/v1/models/{owner}/{name}/versions/{version_id}/trainings"
def _create_training_url_from_model_and_version(
model: Union[str, Tuple[str, str], "Model"],
version: Union[str, "Version"],
) -> str:
if isinstance(model, Model):
owner, name = model.owner, model.name
elif isinstance(model, tuple):
owner, name = model[0], model[1]
elif isinstance(model, str):
owner, name, _ = ModelVersionIdentifier.parse(model)
else:
raise ValueError(
"model must be a Model, a tuple of (owner, name), or a string in the format 'owner/name'"
)
if isinstance(version, Version):
version_id = version.id
else:
version_id = version
return f"/v1/models/{owner}/{name}/versions/{version_id}/trainings"
def _json_to_training(client: "Client", json: Dict[str, Any]) -> Training:
training = Training(**json)
training._client = client
# FIXME: This should be populated by the API
if (
training.output
and isinstance(training.output, dict)
and "version" in training.output
):
id = ModelVersionIdentifier.parse(training.output["version"])
training.destination = f"{id.owner}/{id.name}"
return training

View File

@@ -0,0 +1,158 @@
import datetime
from typing import TYPE_CHECKING, Any, Dict, Tuple, Union
if TYPE_CHECKING:
from replicate.client import Client
from replicate.model import Model
from replicate.pagination import Page
from replicate.resource import Namespace, Resource
class Version(Resource):
"""
A version of a model.
"""
id: str
"""The unique ID of the version."""
created_at: datetime.datetime
"""When the version was created."""
cog_version: str
"""The version of the Cog used to create the version."""
openapi_schema: dict
"""An OpenAPI description of the model inputs and outputs."""
class Versions(Namespace):
"""
Namespace for operations related to model versions.
"""
model: Tuple[str, str]
def __init__(
self, client: "Client", model: Union[str, Tuple[str, str], "Model"]
) -> None:
super().__init__(client=client)
from replicate.model import Model # pylint: disable=import-outside-toplevel
if isinstance(model, Model):
self.model = (model.owner, model.name)
elif isinstance(model, str):
owner, name = model.split("/", 1)
self.model = (owner, name)
else:
self.model = model
def get(self, id: str) -> Version:
"""
Get a specific model version.
Args:
id: The version ID.
Returns:
The model version.
"""
resp = self._client._request(
"GET", f"/v1/models/{self.model[0]}/{self.model[1]}/versions/{id}"
)
return _json_to_version(resp.json())
async def async_get(self, id: str) -> Version:
"""
Get a specific model version.
Args:
id: The version ID.
Returns:
The model version.
"""
resp = await self._client._async_request(
"GET", f"/v1/models/{self.model[0]}/{self.model[1]}/versions/{id}"
)
return _json_to_version(resp.json())
def list(self) -> Page[Version]:
"""
Return a list of all versions for a model.
Returns:
List[Version]: A list of version objects.
"""
resp = self._client._request(
"GET", f"/v1/models/{self.model[0]}/{self.model[1]}/versions"
)
obj = resp.json()
obj["results"] = [_json_to_version(result) for result in obj["results"]]
return Page[Version](**obj)
async def async_list(self) -> Page[Version]:
"""
Return a list of all versions for a model.
Returns:
List[Version]: A list of version objects.
"""
resp = await self._client._async_request(
"GET", f"/v1/models/{self.model[0]}/{self.model[1]}/versions"
)
obj = resp.json()
obj["results"] = [_json_to_version(result) for result in obj["results"]]
return Page[Version](**obj)
def delete(self, id: str) -> bool:
"""
Delete a model version and all associated predictions, including all output files.
Model version deletion has some restrictions:
* You can only delete versions from models you own.
* You can only delete versions from private models.
* You cannot delete a version if someone other than you
has run predictions with it.
Args:
id: The version ID.
"""
resp = self._client._request(
"DELETE", f"/v1/models/{self.model[0]}/{self.model[1]}/versions/{id}"
)
return resp.status_code == 204
async def async_delete(self, id: str) -> bool:
"""
Delete a model version and all associated predictions, including all output files.
Model version deletion has some restrictions:
* You can only delete versions from models you own.
* You can only delete versions from private models.
* You cannot delete a version if someone other than you
has run predictions with it.
Args:
id: The version ID.
"""
resp = await self._client._async_request(
"DELETE", f"/v1/models/{self.model[0]}/{self.model[1]}/versions/{id}"
)
return resp.status_code == 204
def _json_to_version(json: Dict[str, Any]) -> Version:
return Version(**json)

View File

@@ -0,0 +1,203 @@
import base64
import hmac
from hashlib import sha256
from typing import (
TYPE_CHECKING,
Dict,
Optional,
overload,
)
from replicate.resource import Namespace, Resource
if TYPE_CHECKING:
import httpx
class WebhookSigningSecret(Resource):
"""
A webhook signing secret.
"""
key: str
class WebhookValidationError(ValueError):
"""Base exception for webhook validation errors."""
class MissingWebhookHeaderError(WebhookValidationError):
"""Exception raised when a required webhook header is missing."""
class InvalidSecretKeyError(WebhookValidationError):
"""Exception raised when the secret key format is invalid."""
class MissingWebhookBodyError(WebhookValidationError):
"""Exception raised when the webhook body is missing."""
class InvalidTimestampError(WebhookValidationError):
"""Exception raised when the webhook timestamp is invalid or outside the tolerance."""
class InvalidSignatureError(WebhookValidationError):
"""Exception raised when the webhook signature is invalid."""
class Webhooks(Namespace):
"""
Namespace for operations related to webhooks.
"""
@property
def default(self) -> "Webhooks.Default":
"""
Namespace for operations related to the default webhook.
"""
return self.Default(self._client)
class Default(Namespace):
"""
Namespace for operations related to the default webhook.
"""
def secret(self) -> WebhookSigningSecret:
"""
Get the default webhook signing secret.
Returns:
WebhookSigningSecret: The default webhook signing secret.
"""
resp = self._client._request("GET", "/v1/webhooks/default/secret")
return WebhookSigningSecret(**resp.json())
async def async_secret(self) -> WebhookSigningSecret:
"""
Get the default webhook signing secret.
Returns:
WebhookSigningSecret: The default webhook signing secret.
"""
resp = await self._client._async_request(
"GET", "/v1/webhooks/default/secret"
)
return WebhookSigningSecret(**resp.json())
@overload
@staticmethod
def validate(
request: "httpx.Request",
secret: WebhookSigningSecret,
tolerance: Optional[int] = None,
) -> bool: ...
@overload
@staticmethod
def validate(
headers: Dict[str, str],
body: str,
secret: WebhookSigningSecret,
tolerance: Optional[int] = None,
) -> bool: ...
@staticmethod
def validate( # type: ignore # pylint: disable=too-many-branches,too-many-locals
request: Optional["httpx.Request"] = None,
headers: Optional[Dict[str, str]] = None,
body: Optional[str] = None,
secret: Optional[WebhookSigningSecret] = None,
tolerance: Optional[int] = None,
) -> None:
"""
Validate the signature from an incoming webhook request using the provided secret.
Args:
request (httpx.Request): The request object.
headers (Dict[str, str]): The request headers.
body (str): The request body.
secret (WebhookSigningSecret): The webhook signing secret.
tolerance (Optional[int]): Maximum allowed time difference (in seconds) between the current time and the webhook timestamp.
Returns:
None: If the request is valid.
Raises:
MissingWebhookHeaderError: If required webhook headers are missing.
InvalidSecretKeyError: If the secret key format is invalid.
MissingWebhookBodyError: If the webhook body is missing.
InvalidTimestampError: If the webhook timestamp is invalid or outside the tolerance.
InvalidSignatureError: If the webhook signature is invalid.
"""
if not secret:
raise ValueError("Missing webhook signing secret")
if request and any([headers, body]):
raise ValueError("Only one of request or headers/body can be provided")
if request and request.headers:
webhook_id = request.headers.get("webhook-id")
timestamp = request.headers.get("webhook-timestamp")
signature = request.headers.get("webhook-signature")
body = request.content.decode("utf-8")
else:
if not headers:
raise MissingWebhookHeaderError("Missing webhook headers")
# Convert headers to case-insensitive dictionary
headers = {k.lower(): v for k, v in headers.items()}
webhook_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signature = headers.get("webhook-signature")
if not webhook_id:
raise MissingWebhookHeaderError("Missing webhook id")
if not timestamp:
raise MissingWebhookHeaderError("Missing webhook timestamp")
if not signature:
raise MissingWebhookHeaderError("Missing webhook signature")
if not body:
raise MissingWebhookBodyError("Missing webhook body")
if tolerance is not None:
import time # pylint: disable=import-outside-toplevel
current_time = int(time.time())
webhook_time = int(timestamp)
time_difference = abs(current_time - webhook_time)
if time_difference > tolerance:
raise InvalidTimestampError(
f"Webhook timestamp is outside the allowed tolerance of {tolerance} seconds"
)
signed_content = f"{webhook_id}.{timestamp}.{body}"
key_parts = secret.key.split("_")
if len(key_parts) != 2:
raise InvalidSecretKeyError(f"Invalid secret key format: {secret.key}")
secret_bytes = base64.b64decode(key_parts[1])
h = hmac.new(secret_bytes, signed_content.encode(), sha256)
computed_signature = h.digest()
valid = False
for sig in signature.split():
sig_parts = sig.split(",")
if len(sig_parts) < 2:
raise InvalidSignatureError(f"Invalid signature format: {sig}")
sig_bytes = base64.b64decode(sig_parts[1])
if hmac.compare_digest(sig_bytes, computed_signature):
valid = True
break
if not valid:
raise InvalidSignatureError("Webhook signature is invalid")