From b84610757094e214207344e365b807df2e8b8701 Mon Sep 17 00:00:00 2001 From: Adir Amsalem Date: Wed, 6 May 2026 11:41:50 +0000 Subject: [PATCH 1/8] feat: allow custom models --- README.md | 63 ++++++++++++ decart/__init__.py | 3 +- decart/client.py | 73 +++++++------- decart/models.py | 43 +++++++- decart/process/request.py | 2 +- decart/queue/client.py | 63 ++++++------ decart/queue/request.py | 2 +- decart/realtime/client.py | 3 +- tests/test_models.py | 25 ++++- tests/test_process.py | 100 +++++++++++++++++-- tests/test_queue.py | 191 ++++++++++++++++++++++++++++++++++-- tests/test_realtime_unit.py | 47 +++++++++ 12 files changed, 520 insertions(+), 95 deletions(-) diff --git a/README.md b/README.md index c41ac45..03ccf25 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,69 @@ async with DecartClient(api_key=os.getenv("DECART_API_KEY")) as client: f.write(data) ``` +### Custom Models + +For preview, experimental, or private models that are not yet in the SDK registry, +create a custom model definition and pass it directly to the matching API. +`models.realtime(...)`, `models.video(...)`, and `models.image(...)` remain registry-only helpers; +use `models.custom(...)` when you need to send an arbitrary model name. + +```python +from decart import DecartClient, RealtimeClient, RealtimeConnectOptions, models + +# Realtime: default url_path is /v1/stream. +custom_realtime_model = models.custom( + "lucy_2_rt_preview", + fps=20, + width=1280, + height=720, +) + +realtime_client = await RealtimeClient.connect( + base_url=client.realtime_base_url, + api_key=client.api_key, + local_track=track, + options=RealtimeConnectOptions( + model=custom_realtime_model, + on_remote_stream=lambda stream: print("remote stream", stream), + ), +) + +# Process API: use a generation endpoint; the default realtime url_path is +# not valid for client.process(). +custom_image_model = models.custom( + "lucy_image_preview", + url_path="/v1/generate/lucy_image_preview", + fps=25, + width=1280, + height=704, +) + +image = await client.process({ + "model": custom_image_model, + "prompt": "Apply a preview model treatment", + "data": open("input.png", "rb"), +}) + +# Queue API: async jobs always submit to /v1/jobs/{model.name}; url_path is ignored here. +custom_video_model = models.custom( + "lucy_video_preview", + fps=20, + width=1280, + height=720, +) + +job = await client.queue.submit({ + "model": custom_video_model, + "prompt": "Use the preview video model", + "data": open("input.mp4", "rb"), +}) +``` + +If `input_schema` is omitted, custom process and queue inputs are sent through without +client-side schema validation; the backend/bouncer validates whether the model name, +API surface, and inputs are supported. + ## Development ### Setup with UV diff --git a/decart/__init__.py b/decart/__init__.py index b64d7f6..b7ce1d0 100644 --- a/decart/__init__.py +++ b/decart/__init__.py @@ -12,7 +12,7 @@ QueueResultError, TokenCreateError, ) -from .models import models, ModelDefinition, VideoRestyleInput +from .models import models, ModelDefinition, CustomModelDefinition, VideoRestyleInput from .types import FileInput, ModelState, Prompt from .queue import ( QueueClient, @@ -69,6 +69,7 @@ "QueueResultError", "models", "ModelDefinition", + "CustomModelDefinition", "VideoRestyleInput", "FileInput", "ModelState", diff --git a/decart/client.py b/decart/client.py index 6280ebe..2b82c1e 100644 --- a/decart/client.py +++ b/decart/client.py @@ -1,9 +1,10 @@ import os +from types import TracebackType from typing import Any, Optional import aiohttp -from pydantic import ValidationError +from pydantic import BaseModel, ValidationError from .errors import InvalidAPIKeyError, InvalidBaseURLError, InvalidInputError -from .models import ImageModelDefinition, _MODELS +from .models import ModelDefinition from .process.request import send_request from .queue.client import QueueClient from .tokens.client import TokensClient @@ -77,8 +78,8 @@ def __init__( @property def queue(self) -> QueueClient: """ - Queue client for async video editing jobs. - Only video models support the queue API. + Queue client for async jobs. + The SDK accepts any model definition and lets the backend validate support. Example: ```python @@ -128,25 +129,30 @@ async def close(self) -> None: if self._session and not self._session.closed: await self._session.close() - async def __aenter__(self): + async def __aenter__(self) -> "DecartClient": """Async context manager entry.""" return self - async def __aexit__(self, exc_type, exc_val, exc_tb): + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: """Async context manager exit.""" await self.close() async def process(self, options: dict[str, Any]) -> bytes: """ - Process image editing synchronously. - Only image models support the process API. + Process synchronously using the model definition's configured endpoint. + The backend validates whether the model supports this API. For video editing, use the queue API instead: result = await client.queue.submit_and_poll({...}) Args: options: Processing options including model and inputs - - model: ImageModelDefinition from models.image() + - model: ModelDefinition from models.image(), models.video(), models.realtime(), or models.custom() - prompt: Text instructions describing the requested edit - Additional model-specific inputs @@ -154,21 +160,13 @@ async def process(self, options: dict[str, Any]) -> bytes: Generated/transformed image as bytes Raises: - InvalidInputError: If inputs are invalid or model is not an image model + InvalidInputError: If inputs are invalid ProcessingError: If processing fails """ if "model" not in options: raise InvalidInputError("model is required") - model: ImageModelDefinition = options["model"] - - # Validate that this is an image model (check against registry) - if model.name not in _MODELS["image"]: - raise InvalidInputError( - f"Model '{model.name}' is not supported by process(). " - f"Only image models support sync processing. " - f"For video models, use client.queue.submit_and_poll() instead." - ) + model: ModelDefinition[str] = options["model"] cancel_token = options.get("cancel_token") @@ -181,22 +179,27 @@ async def process(self, options: dict[str, Any]) -> bytes: file_inputs = {k: v for k, v in inputs.items() if k in FILE_FIELDS} non_file_inputs = {k: v for k, v in inputs.items() if k not in FILE_FIELDS} - # Validate non-file inputs and create placeholder for file fields - validation_inputs = { - **non_file_inputs, - **{k: b"" for k in file_inputs.keys()}, # Placeholder bytes for validation - } - - try: - validated_inputs = model.input_schema(**validation_inputs) - except ValidationError as e: - raise InvalidInputError(f"Invalid inputs for {model.name}: {str(e)}") from e - - # Build final inputs: validated non-file inputs + original file inputs - processed_inputs = { - **validated_inputs.model_dump(exclude_none=True), - **file_inputs, # Override placeholders with actual file data - } + if model.input_schema is BaseModel: + # Custom models can omit an input schema; in that case we pass + # arbitrary fields through and let the backend validate them. + processed_inputs = {k: v for k, v in inputs.items() if v is not None} + else: + # Validate non-file inputs and create placeholder for file fields + validation_inputs = { + **non_file_inputs, + **{k: b"" for k in file_inputs.keys()}, # Placeholder bytes for validation + } + + try: + validated_inputs = model.input_schema(**validation_inputs) + except ValidationError as e: + raise InvalidInputError(f"Invalid inputs for {model.name}: {str(e)}") from e + + # Build final inputs: validated non-file inputs + original file inputs + processed_inputs = { + **validated_inputs.model_dump(exclude_none=True), + **file_inputs, # Override placeholders with actual file data + } session = await self._get_session() response = await send_request( diff --git a/decart/models.py b/decart/models.py index 93f8932..cb44542 100644 --- a/decart/models.py +++ b/decart/models.py @@ -4,7 +4,6 @@ from .errors import ModelNotFoundError from .types import FileInput, MotionTrajectoryInput - RealTimeModels = Literal[ # Canonical names "lucy", @@ -92,7 +91,7 @@ class ModelDefinition(DecartBaseModel, Generic[ModelT]): fps: int = Field(ge=1) width: int = Field(ge=1) height: int = Field(ge=1) - input_schema: type[BaseModel] + input_schema: type[BaseModel] = BaseModel # Type aliases for model definitions that support specific APIs @@ -105,6 +104,13 @@ class ModelDefinition(DecartBaseModel, Generic[ModelT]): RealTimeModelDefinition = ModelDefinition[RealTimeModels] """Type alias for model definitions that support realtime streaming.""" +CustomModelDefinition = ModelDefinition[str] +"""Type alias for custom model definitions with arbitrary model names. + +Useful for preview, experimental, or private models that are not yet +in the SDK's built-in registry. +""" + class VideoToVideoInput(DecartBaseModel): prompt: str = Field( @@ -428,6 +434,39 @@ class ImageToImageInput(DecartBaseModel): class Models: + @staticmethod + def custom( + name: str, + *, + fps: int, + width: int, + height: int, + url_path: str = "/v1/stream", + input_schema: type[BaseModel] = BaseModel, + ) -> CustomModelDefinition: + """Create a custom model definition with an arbitrary model name. + + This is useful for preview, experimental, or private models that are + not yet in the SDK's built-in registry. Pass the returned definition + directly to the matching client API. + + For realtime models, keep the default ``url_path="/v1/stream"``. + For process/custom image models, pass the generation endpoint as + ``url_path``. Queue jobs always submit to ``/v1/jobs/{model.name}``, + so custom queue models do not need a separate path. If ``input_schema`` + is omitted, process and queue inputs are sent through without + client-side schema validation; the backend/bouncer validates model + availability and API support. + """ + return CustomModelDefinition( + name=name, + url_path=url_path, + fps=fps, + width=width, + height=height, + input_schema=input_schema, + ) + @staticmethod def realtime(model: RealTimeModels) -> RealTimeModelDefinition: """Get a realtime model definition for WebRTC streaming.""" diff --git a/decart/process/request.py b/decart/process/request.py index 49ff7d2..71034fb 100644 --- a/decart/process/request.py +++ b/decart/process/request.py @@ -80,7 +80,7 @@ async def send_request( session: aiohttp.ClientSession, base_url: str, api_key: str, - model: ModelDefinition, + model: ModelDefinition[str], inputs: dict[str, Any], cancel_token: Optional[asyncio.Event] = None, integration: Optional[str] = None, diff --git a/decart/queue/client.py b/decart/queue/client.py index dfb79db..a95b2d6 100644 --- a/decart/queue/client.py +++ b/decart/queue/client.py @@ -2,9 +2,9 @@ from typing import Any, Optional, TYPE_CHECKING import aiohttp -from pydantic import ValidationError +from pydantic import BaseModel, ValidationError -from ..models import VideoModelDefinition, _MODELS +from ..models import ModelDefinition from ..errors import InvalidInputError from .request import submit_job, get_job_status, get_job_content from .types import ( @@ -25,8 +25,8 @@ class QueueClient: """ - Queue client for async job-based video editing. - Only video models support the queue API. + Queue client for async jobs. + The SDK accepts any model definition and lets the backend validate support. Jobs are submitted and processed asynchronously, allowing you to poll for status and retrieve results when ready. @@ -62,13 +62,13 @@ async def _get_session(self) -> aiohttp.ClientSession: async def submit(self, options: dict[str, Any]) -> JobSubmitResponse: """ - Submit a video editing job to the queue for async processing. - Only video models are supported. + Submit an async queue job. + Video models and custom queue model definitions are supported. Returns immediately with job_id and initial status. Args: options: Submit options including model and inputs - - model: VideoModelDefinition from models.video() + - model: VideoModelDefinition from models.video(), or a custom definition from models.custom() - prompt: Text instructions describing the requested edit - Additional model-specific inputs @@ -76,21 +76,13 @@ async def submit(self, options: dict[str, Any]) -> JobSubmitResponse: JobSubmitResponse with job_id and status Raises: - InvalidInputError: If inputs are invalid or model is not a video model + InvalidInputError: If inputs are invalid QueueSubmitError: If submission fails """ if "model" not in options: raise InvalidInputError("model is required") - model: VideoModelDefinition = options["model"] - - # Validate that this is a video model (check against registry) - if model.name not in _MODELS["video"]: - raise InvalidInputError( - f"Model '{model.name}' is not supported by queue API. " - f"Only video models support async queue processing. " - f"For image models, use client.process() instead." - ) + model: ModelDefinition[str] = options["model"] inputs = {k: v for k, v in options.items() if k not in ("model", "cancel_token")} @@ -101,22 +93,27 @@ async def submit(self, options: dict[str, Any]) -> JobSubmitResponse: file_inputs = {k: v for k, v in inputs.items() if k in FILE_FIELDS} non_file_inputs = {k: v for k, v in inputs.items() if k not in FILE_FIELDS} - # Validate non-file inputs - validation_inputs = { - **non_file_inputs, - **{k: b"" for k in file_inputs.keys()}, - } - - try: - validated_inputs = model.input_schema(**validation_inputs) - except ValidationError as e: - raise InvalidInputError(f"Invalid inputs for {model.name}: {str(e)}") from e - - # Build final inputs - processed_inputs = { - **validated_inputs.model_dump(exclude_none=True), - **file_inputs, - } + if model.input_schema is BaseModel: + # Custom models can omit an input schema; in that case we pass + # arbitrary fields through and let the backend validate them. + processed_inputs = {k: v for k, v in inputs.items() if v is not None} + else: + # Validate non-file inputs + validation_inputs = { + **non_file_inputs, + **{k: b"" for k in file_inputs.keys()}, + } + + try: + validated_inputs = model.input_schema(**validation_inputs) + except ValidationError as e: + raise InvalidInputError(f"Invalid inputs for {model.name}: {str(e)}") from e + + # Build final inputs + processed_inputs = { + **validated_inputs.model_dump(exclude_none=True), + **file_inputs, + } session = await self._get_session() return await submit_job( diff --git a/decart/queue/request.py b/decart/queue/request.py index c242b62..05b25db 100644 --- a/decart/queue/request.py +++ b/decart/queue/request.py @@ -12,7 +12,7 @@ async def submit_job( session: aiohttp.ClientSession, base_url: str, api_key: str, - model: ModelDefinition, + model: ModelDefinition[str], inputs: dict[str, Any], integration: Optional[str] = None, ) -> JobSubmitResponse: diff --git a/decart/realtime/client.py b/decart/realtime/client.py index 93f1394..77ffa1d 100644 --- a/decart/realtime/client.py +++ b/decart/realtime/client.py @@ -19,7 +19,6 @@ ) from .types import ConnectionState, RealtimeConnectOptions from ..types import FileInput -from ..models import RealTimeModels from ..errors import DecartSDKError, InvalidInputError, WebRTCError from ..process.request import file_input_to_bytes @@ -111,7 +110,7 @@ async def connect( ws_url = f"{base_url}{options.model.url_path}" ws_url += f"?api_key={quote(api_key)}&model={quote(options.model.name)}" - model_name: RealTimeModels = options.model.name # type: ignore[assignment] + model_name: str = options.model.name is_avatar_live = model_name in ("live_avatar", "live-avatar") audio_stream_manager: Optional[AudioStreamManager] = None diff --git a/tests/test_models.py b/tests/test_models.py index dc040cf..0916206 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,6 +1,7 @@ import warnings import pytest -from decart import models, DecartSDKError +from decart import models, DecartSDKError, CustomModelDefinition +from pydantic import BaseModel, ValidationError from decart.models import _warned_aliases @@ -226,6 +227,28 @@ def test_latest_aliases_no_deprecation_warning() -> None: assert len(w) == 0 +def test_custom_model_definition_allows_arbitrary_model_names() -> None: + model = models.custom( + "lucy_2_rt_preview", + fps=20, + width=1280, + height=720, + ) + + assert isinstance(model, CustomModelDefinition) + assert model.name == "lucy_2_rt_preview" + assert model.url_path == "/v1/stream" + assert model.fps == 20 + assert model.width == 1280 + assert model.height == 720 + assert model.input_schema is BaseModel + + +def test_custom_model_definition_validates_required_shape() -> None: + with pytest.raises(ValidationError): + CustomModelDefinition(name="my_custom_model", url_path="/v1/stream") + + def test_invalid_model() -> None: with pytest.raises(DecartSDKError): models.video("invalid-model") diff --git a/tests/test_process.py b/tests/test_process.py index 5c47a57..98f04ed 100644 --- a/tests/test_process.py +++ b/tests/test_process.py @@ -1,7 +1,6 @@ """ Tests for the process API. -Note: process() only supports image models (i2i). -Video models must use the queue API. +Note: process() accepts any model definition and lets the backend validate support. """ import pytest @@ -74,20 +73,103 @@ async def test_process_image_to_image_with_reference_image() -> None: @pytest.mark.asyncio -async def test_process_rejects_video_models() -> None: - """Test that process() rejects video models with helpful error message.""" +async def test_process_accepts_custom_model_definition_without_schema() -> None: client = DecartClient(api_key="test-key") + custom_model = models.custom( + "lucy_image_preview", + url_path="/v1/generate/lucy_image_preview", + fps=25, + width=1280, + height=704, + ) - with pytest.raises(DecartSDKError) as exc_info: - await client.process( + with patch("decart.client.send_request", new_callable=AsyncMock) as mock_send: + mock_send.return_value = b"fake image data" + + result = await client.process( { - "model": models.video("lucy-clip"), + "model": custom_model, + "prompt": "Apply a preview model treatment", + "data": b"fake image data", + "custom_strength": 0.7, + "optional": None, + } + ) + + assert result == b"fake image data" + mock_send.assert_called_once() + call_kwargs = mock_send.call_args.kwargs + assert call_kwargs["model"] is custom_model + assert call_kwargs["inputs"] == { + "prompt": "Apply a preview model treatment", + "data": b"fake image data", + "custom_strength": 0.7, + } + + +@pytest.mark.asyncio +async def test_process_allows_custom_model_with_default_url_path() -> None: + client = DecartClient(api_key="test-key") + custom_model = models.custom( + "lucy_image_preview", + fps=25, + width=1280, + height=704, + ) + + with patch("decart.client.send_request", new_callable=AsyncMock) as mock_send: + mock_send.return_value = b"fake image data" + + result = await client.process( + { + "model": custom_model, + "prompt": "Apply a preview model treatment", + "data": b"fake image data", + } + ) + + assert result == b"fake image data" + assert mock_send.call_args.kwargs["model"] is custom_model + + +@pytest.mark.asyncio +async def test_process_allows_video_model_definitions() -> None: + client = DecartClient(api_key="test-key") + model = models.video("lucy-clip") + + with patch("decart.client.send_request", new_callable=AsyncMock) as mock_send: + mock_send.return_value = b"fake response" + + result = await client.process( + { + "model": model, "prompt": "Add cinematic teal-and-orange grading", + "data": b"fake video data", + } + ) + + assert result == b"fake response" + assert mock_send.call_args.kwargs["model"] is model + + +@pytest.mark.asyncio +async def test_process_allows_realtime_model_definitions() -> None: + client = DecartClient(api_key="test-key") + model = models.realtime("lucy") + + with patch("decart.client.send_request", new_callable=AsyncMock) as mock_send: + mock_send.return_value = b"fake response" + + result = await client.process( + { + "model": model, + "prompt": "Apply a preview model treatment", + "data": b"fake image data", } ) - assert "not supported by process()" in str(exc_info.value) - assert "queue" in str(exc_info.value).lower() + assert result == b"fake response" + assert mock_send.call_args.kwargs["model"] is model @pytest.mark.asyncio diff --git a/tests/test_queue.py b/tests/test_queue.py index 73280eb..595a648 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -1,12 +1,11 @@ """ Tests for the queue API. -Note: queue API only supports video models. -Image models must use the process API. +Note: queue API accepts any model definition and lets the backend validate support. """ import pytest from unittest.mock import AsyncMock, patch, MagicMock -from decart import DecartClient, models, DecartSDKError +from decart import DecartClient, models, DecartSDKError, QueueSubmitError @pytest.mark.asyncio @@ -53,20 +52,77 @@ async def test_queue_submit_video_to_video() -> None: @pytest.mark.asyncio -async def test_queue_rejects_image_models() -> None: - """Test that queue API rejects image models with helpful error message.""" +async def test_queue_submit_accepts_custom_model_definition_without_schema() -> None: client = DecartClient(api_key="test-key") + custom_model = models.custom( + "lucy_video_preview", + fps=20, + width=1280, + height=720, + ) - with pytest.raises(DecartSDKError) as exc_info: - await client.queue.submit( + with patch("decart.queue.client.submit_job") as mock_submit: + mock_submit.return_value = MagicMock(job_id="job-custom", status="pending") + + job = await client.queue.submit( + { + "model": custom_model, + "prompt": "Use the custom video model", + "data": b"fake video data", + "custom_strength": 0.7, + "optional": None, + } + ) + + assert job.job_id == "job-custom" + mock_submit.assert_called_once() + call_kwargs = mock_submit.call_args.kwargs + assert call_kwargs["model"] is custom_model + assert call_kwargs["inputs"] == { + "prompt": "Use the custom video model", + "data": b"fake video data", + "custom_strength": 0.7, + } + + +@pytest.mark.asyncio +async def test_queue_allows_image_model_definitions() -> None: + client = DecartClient(api_key="test-key") + model = models.image("lucy-image-2") + + with patch("decart.queue.client.submit_job") as mock_submit: + mock_submit.return_value = MagicMock(job_id="job-image", status="pending") + + job = await client.queue.submit( { - "model": models.image("lucy-image-2"), + "model": model, "prompt": "Apply a painterly sunset color grade", + "data": b"fake image data", } ) - assert "not supported by queue" in str(exc_info.value) - assert "process" in str(exc_info.value).lower() + assert job.job_id == "job-image" + assert mock_submit.call_args.kwargs["model"] is model + + +@pytest.mark.asyncio +async def test_queue_allows_realtime_model_definitions_with_overlapping_video_names() -> None: + client = DecartClient(api_key="test-key") + model = models.realtime("lucy-2.1") + + with patch("decart.queue.client.submit_job") as mock_submit: + mock_submit.return_value = MagicMock(job_id="job-realtime", status="pending") + + job = await client.queue.submit( + { + "model": model, + "prompt": "Use the realtime model", + "data": b"fake video data", + } + ) + + assert job.job_id == "job-realtime" + assert mock_submit.call_args.kwargs["model"] is model @pytest.mark.asyncio @@ -267,6 +323,121 @@ async def test_queue_includes_user_agent_header() -> None: assert "User-Agent" in headers assert headers["User-Agent"].startswith("decart-python-sdk/") + assert mock_session.post.call_args.args[0] == "https://api.decart.ai/v1/jobs/lucy-clip" + + +@pytest.mark.asyncio +async def test_queue_custom_model_uses_standard_jobs_url() -> None: + client = DecartClient(api_key="test-key") + custom_model = models.custom( + "lucy_video_preview", + fps=20, + width=1280, + height=720, + ) + + with patch("aiohttp.ClientSession") as mock_session_cls: + mock_response = MagicMock() + mock_response.ok = True + mock_response.json = AsyncMock(return_value={"job_id": "job-123", "status": "pending"}) + + mock_session = MagicMock() + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + mock_session.post = MagicMock() + mock_session.post.return_value.__aenter__ = AsyncMock(return_value=mock_response) + mock_session.post.return_value.__aexit__ = AsyncMock(return_value=None) + + mock_session_cls.return_value = mock_session + + await client.queue.submit( + { + "model": custom_model, + "prompt": "Use the custom video model", + "data": b"fake video data", + } + ) + + assert ( + mock_session.post.call_args.args[0] + == "https://api.decart.ai/v1/jobs/lucy_video_preview" + ) + + +@pytest.mark.asyncio +async def test_queue_custom_model_ignores_url_path_for_jobs_url() -> None: + client = DecartClient(api_key="test-key") + custom_model = models.custom( + "lucy_video_preview", + url_path="/v1/not-the-queue-url", + fps=20, + width=1280, + height=720, + ) + + with patch("aiohttp.ClientSession") as mock_session_cls: + mock_response = MagicMock() + mock_response.ok = True + mock_response.json = AsyncMock(return_value={"job_id": "job-123", "status": "pending"}) + + mock_session = MagicMock() + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + mock_session.post = MagicMock() + mock_session.post.return_value.__aenter__ = AsyncMock(return_value=mock_response) + mock_session.post.return_value.__aexit__ = AsyncMock(return_value=None) + + mock_session_cls.return_value = mock_session + + await client.queue.submit( + { + "model": custom_model, + "prompt": "Use the custom video model", + "data": b"fake video data", + } + ) + + assert ( + mock_session.post.call_args.args[0] + == "https://api.decart.ai/v1/jobs/lucy_video_preview" + ) + + +@pytest.mark.asyncio +async def test_queue_custom_model_raises_bouncer_error() -> None: + client = DecartClient(api_key="test-key") + custom_model = models.custom( + "unknown_model", + fps=20, + width=1280, + height=720, + ) + + with patch("aiohttp.ClientSession") as mock_session_cls: + mock_response = MagicMock() + mock_response.ok = False + mock_response.status = 400 + mock_response.text = AsyncMock(return_value="unsupported model") + + mock_session = MagicMock() + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + mock_session.post = MagicMock() + mock_session.post.return_value.__aenter__ = AsyncMock(return_value=mock_response) + mock_session.post.return_value.__aexit__ = AsyncMock(return_value=None) + + mock_session_cls.return_value = mock_session + + with pytest.raises(QueueSubmitError) as exc_info: + await client.queue.submit( + { + "model": custom_model, + "prompt": "Use the custom video model", + "data": b"fake video data", + } + ) + + assert "unsupported model" in str(exc_info.value) # Tests for lucy-2.1 diff --git a/tests/test_realtime_unit.py b/tests/test_realtime_unit.py index 49dc054..e2856db 100644 --- a/tests/test_realtime_unit.py +++ b/tests/test_realtime_unit.py @@ -116,6 +116,53 @@ async def test_realtime_client_creation_with_mock(): assert realtime_client.subscribe_token is not None +@pytest.mark.asyncio +async def test_realtime_connect_accepts_custom_model_definition(): + """Custom realtime models can use arbitrary model names, matching the JS SDK escape hatch.""" + client = DecartClient(api_key="test-key") + custom_model = models.custom( + "lucy_2_rt_preview", + fps=20, + width=1280, + height=720, + ) + + with ( + patch("decart.realtime.client.WebRTCManager") as mock_manager_class, + patch("decart.realtime.client.aiohttp.ClientSession") as mock_session_cls, + ): + mock_manager = AsyncMock() + mock_manager.connect = AsyncMock(return_value=True) + mock_manager.is_connected = MagicMock(return_value=True) + mock_manager_class.return_value = mock_manager + + mock_session = MagicMock() + mock_session.closed = False + mock_session.close = AsyncMock() + mock_session_cls.return_value = mock_session + + from decart.realtime.types import RealtimeConnectOptions + + realtime_client = await RealtimeClient.connect( + base_url=client.realtime_base_url, + api_key=client.api_key, + local_track=MagicMock(), + options=RealtimeConnectOptions( + model=custom_model, + on_remote_stream=lambda t: None, + ), + ) + + assert realtime_client is not None + call_args = mock_manager_class.call_args + config = call_args[0][0] if call_args[0] else call_args[1]["configuration"] + assert "model=lucy_2_rt_preview" in config.webrtc_url + assert config.model_name == "lucy_2_rt_preview" + assert config.fps == 20 + + await realtime_client.disconnect() + + @pytest.mark.asyncio async def test_realtime_set_prompt_with_mock(): """Test set_prompt with mocked WebRTC and prompt_ack""" From 985db94da010c1a7925c4d7051b2e32547830bf5 Mon Sep 17 00:00:00 2001 From: Adir Amsalem Date: Wed, 6 May 2026 23:18:43 +0300 Subject: [PATCH 2/8] refactor: align custom model API with JS SDK shape Drop the `models.custom(...)` factory in favor of constructing `ModelDefinition(...)` directly, matching how the JS SDK exposes custom models via the `CustomModelDefinition` type. - `input_schema` becomes truly optional (`Optional[type[BaseModel]] = None`) instead of using `BaseModel` as a sentinel. - Add `queue_url_path` to `ModelDefinition`; populate it on the video and image registry entries and use it from `submit_job`. Submitting a model without `queue_url_path` raises `InvalidInputError` (mirrors the JS check in `queue/request.ts`). - Video registry `url_path` flips to `/v1/generate/` to align with the JS SDK; queue submissions now go through `queue_url_path`. --- README.md | 29 +++++++------- decart/client.py | 4 +- decart/models.py | 88 ++++++++++++++--------------------------- decart/queue/client.py | 4 +- decart/queue/request.py | 17 ++++++-- tests/test_models.py | 46 +++++++++++++-------- tests/test_process.py | 13 +++--- tests/test_queue.py | 57 +++++++++++++------------- 8 files changed, 125 insertions(+), 133 deletions(-) diff --git a/README.md b/README.md index 03ccf25..092e451 100644 --- a/README.md +++ b/README.md @@ -92,16 +92,16 @@ async with DecartClient(api_key=os.getenv("DECART_API_KEY")) as client: ### Custom Models For preview, experimental, or private models that are not yet in the SDK registry, -create a custom model definition and pass it directly to the matching API. -`models.realtime(...)`, `models.video(...)`, and `models.image(...)` remain registry-only helpers; -use `models.custom(...)` when you need to send an arbitrary model name. +construct a `ModelDefinition` directly and pass it to the matching API. +`models.realtime(...)`, `models.video(...)`, and `models.image(...)` remain registry-only helpers. ```python -from decart import DecartClient, RealtimeClient, RealtimeConnectOptions, models +from decart import DecartClient, ModelDefinition, RealtimeClient, RealtimeConnectOptions -# Realtime: default url_path is /v1/stream. -custom_realtime_model = models.custom( - "lucy_2_rt_preview", +# Realtime: url_path is /v1/stream. +custom_realtime_model = ModelDefinition( + name="lucy_2_rt_preview", + url_path="/v1/stream", fps=20, width=1280, height=720, @@ -117,10 +117,9 @@ realtime_client = await RealtimeClient.connect( ), ) -# Process API: use a generation endpoint; the default realtime url_path is -# not valid for client.process(). -custom_image_model = models.custom( - "lucy_image_preview", +# Process API: pass the generation endpoint as url_path. +custom_image_model = ModelDefinition( + name="lucy_image_preview", url_path="/v1/generate/lucy_image_preview", fps=25, width=1280, @@ -133,9 +132,11 @@ image = await client.process({ "data": open("input.png", "rb"), }) -# Queue API: async jobs always submit to /v1/jobs/{model.name}; url_path is ignored here. -custom_video_model = models.custom( - "lucy_video_preview", +# Queue API: set queue_url_path. Submitting a model without it raises InvalidInputError. +custom_video_model = ModelDefinition( + name="lucy_video_preview", + url_path="/v1/generate/lucy_video_preview", + queue_url_path="/v1/jobs/lucy_video_preview", fps=20, width=1280, height=720, diff --git a/decart/client.py b/decart/client.py index 2b82c1e..9dd3ed7 100644 --- a/decart/client.py +++ b/decart/client.py @@ -2,7 +2,7 @@ from types import TracebackType from typing import Any, Optional import aiohttp -from pydantic import BaseModel, ValidationError +from pydantic import ValidationError from .errors import InvalidAPIKeyError, InvalidBaseURLError, InvalidInputError from .models import ModelDefinition from .process.request import send_request @@ -179,7 +179,7 @@ async def process(self, options: dict[str, Any]) -> bytes: file_inputs = {k: v for k, v in inputs.items() if k in FILE_FIELDS} non_file_inputs = {k: v for k, v in inputs.items() if k not in FILE_FIELDS} - if model.input_schema is BaseModel: + if model.input_schema is None: # Custom models can omit an input schema; in that case we pass # arbitrary fields through and let the backend validate them. processed_inputs = {k: v for k, v in inputs.items() if v is not None} diff --git a/decart/models.py b/decart/models.py index cb44542..3691f36 100644 --- a/decart/models.py +++ b/decart/models.py @@ -88,10 +88,11 @@ class DecartBaseModel(BaseModel): class ModelDefinition(DecartBaseModel, Generic[ModelT]): name: ModelT url_path: str + queue_url_path: Optional[str] = None fps: int = Field(ge=1) width: int = Field(ge=1) height: int = Field(ge=1) - input_schema: type[BaseModel] = BaseModel + input_schema: Optional[type[BaseModel]] = None # Type aliases for model definitions that support specific APIs @@ -199,7 +200,6 @@ class ImageToImageInput(DecartBaseModel): fps=25, width=1280, height=704, - input_schema=BaseModel, ), "lucy-2.1": ModelDefinition( name="lucy-2.1", @@ -207,7 +207,6 @@ class ImageToImageInput(DecartBaseModel): fps=20, width=1088, height=624, - input_schema=BaseModel, ), "lucy-2.1-vton": ModelDefinition( name="lucy-2.1-vton", @@ -215,7 +214,6 @@ class ImageToImageInput(DecartBaseModel): fps=20, width=1088, height=624, - input_schema=BaseModel, ), "lucy-restyle": ModelDefinition( name="lucy-restyle", @@ -223,7 +221,6 @@ class ImageToImageInput(DecartBaseModel): fps=25, width=1280, height=704, - input_schema=BaseModel, ), "lucy-restyle-2": ModelDefinition( name="lucy-restyle-2", @@ -231,7 +228,6 @@ class ImageToImageInput(DecartBaseModel): fps=22, width=1280, height=704, - input_schema=BaseModel, ), "live-avatar": ModelDefinition( name="live-avatar", @@ -239,7 +235,6 @@ class ImageToImageInput(DecartBaseModel): fps=25, width=1280, height=720, - input_schema=BaseModel, ), # Latest aliases (server-side resolution) "lucy-latest": ModelDefinition( @@ -248,7 +243,6 @@ class ImageToImageInput(DecartBaseModel): fps=20, width=1088, height=624, - input_schema=BaseModel, ), "lucy-vton-latest": ModelDefinition( name="lucy-vton-latest", @@ -256,7 +250,6 @@ class ImageToImageInput(DecartBaseModel): fps=20, width=1088, height=624, - input_schema=BaseModel, ), "lucy-restyle-latest": ModelDefinition( name="lucy-restyle-latest", @@ -264,7 +257,6 @@ class ImageToImageInput(DecartBaseModel): fps=22, width=1280, height=704, - input_schema=BaseModel, ), # Deprecated names "mirage": ModelDefinition( @@ -273,7 +265,6 @@ class ImageToImageInput(DecartBaseModel): fps=25, width=1280, height=704, - input_schema=BaseModel, ), "mirage_v2": ModelDefinition( name="mirage_v2", @@ -281,7 +272,6 @@ class ImageToImageInput(DecartBaseModel): fps=22, width=1280, height=704, - input_schema=BaseModel, ), "lucy_v2v_720p_rt": ModelDefinition( name="lucy_v2v_720p_rt", @@ -289,7 +279,6 @@ class ImageToImageInput(DecartBaseModel): fps=25, width=1280, height=704, - input_schema=BaseModel, ), "live_avatar": ModelDefinition( name="live_avatar", @@ -297,14 +286,14 @@ class ImageToImageInput(DecartBaseModel): fps=25, width=1280, height=720, - input_schema=BaseModel, ), }, "video": { # Canonical names "lucy-clip": ModelDefinition( name="lucy-clip", - url_path="/v1/jobs/lucy-clip", + url_path="/v1/generate/lucy-clip", + queue_url_path="/v1/jobs/lucy-clip", fps=25, width=1280, height=704, @@ -312,7 +301,8 @@ class ImageToImageInput(DecartBaseModel): ), "lucy-2.1": ModelDefinition( name="lucy-2.1", - url_path="/v1/jobs/lucy-2.1", + url_path="/v1/generate/lucy-2.1", + queue_url_path="/v1/jobs/lucy-2.1", fps=20, width=1088, height=624, @@ -320,7 +310,8 @@ class ImageToImageInput(DecartBaseModel): ), "lucy-2.1-vton": ModelDefinition( name="lucy-2.1-vton", - url_path="/v1/jobs/lucy-2.1-vton", + url_path="/v1/generate/lucy-2.1-vton", + queue_url_path="/v1/jobs/lucy-2.1-vton", fps=20, width=1088, height=624, @@ -328,7 +319,8 @@ class ImageToImageInput(DecartBaseModel): ), "lucy-restyle-2": ModelDefinition( name="lucy-restyle-2", - url_path="/v1/jobs/lucy-restyle-2", + url_path="/v1/generate/lucy-restyle-2", + queue_url_path="/v1/jobs/lucy-restyle-2", fps=22, width=1280, height=704, @@ -336,7 +328,8 @@ class ImageToImageInput(DecartBaseModel): ), "lucy-motion": ModelDefinition( name="lucy-motion", - url_path="/v1/jobs/lucy-motion", + url_path="/v1/generate/lucy-motion", + queue_url_path="/v1/jobs/lucy-motion", fps=25, width=1280, height=704, @@ -345,7 +338,8 @@ class ImageToImageInput(DecartBaseModel): # Latest aliases (server-side resolution) "lucy-latest": ModelDefinition( name="lucy-latest", - url_path="/v1/jobs/lucy-latest", + url_path="/v1/generate/lucy-latest", + queue_url_path="/v1/jobs/lucy-latest", fps=20, width=1088, height=624, @@ -353,7 +347,8 @@ class ImageToImageInput(DecartBaseModel): ), "lucy-vton-latest": ModelDefinition( name="lucy-vton-latest", - url_path="/v1/jobs/lucy-vton-latest", + url_path="/v1/generate/lucy-vton-latest", + queue_url_path="/v1/jobs/lucy-vton-latest", fps=20, width=1088, height=624, @@ -361,7 +356,8 @@ class ImageToImageInput(DecartBaseModel): ), "lucy-restyle-latest": ModelDefinition( name="lucy-restyle-latest", - url_path="/v1/jobs/lucy-restyle-latest", + url_path="/v1/generate/lucy-restyle-latest", + queue_url_path="/v1/jobs/lucy-restyle-latest", fps=22, width=1280, height=704, @@ -369,7 +365,8 @@ class ImageToImageInput(DecartBaseModel): ), "lucy-clip-latest": ModelDefinition( name="lucy-clip-latest", - url_path="/v1/jobs/lucy-clip-latest", + url_path="/v1/generate/lucy-clip-latest", + queue_url_path="/v1/jobs/lucy-clip-latest", fps=25, width=1280, height=704, @@ -377,7 +374,8 @@ class ImageToImageInput(DecartBaseModel): ), "lucy-motion-latest": ModelDefinition( name="lucy-motion-latest", - url_path="/v1/jobs/lucy-motion-latest", + url_path="/v1/generate/lucy-motion-latest", + queue_url_path="/v1/jobs/lucy-motion-latest", fps=25, width=1280, height=704, @@ -386,7 +384,8 @@ class ImageToImageInput(DecartBaseModel): # Deprecated names "lucy-pro-v2v": ModelDefinition( name="lucy-pro-v2v", - url_path="/v1/jobs/lucy-pro-v2v", + url_path="/v1/generate/lucy-pro-v2v", + queue_url_path="/v1/jobs/lucy-pro-v2v", fps=25, width=1280, height=704, @@ -394,7 +393,8 @@ class ImageToImageInput(DecartBaseModel): ), "lucy-restyle-v2v": ModelDefinition( name="lucy-restyle-v2v", - url_path="/v1/jobs/lucy-restyle-v2v", + url_path="/v1/generate/lucy-restyle-v2v", + queue_url_path="/v1/jobs/lucy-restyle-v2v", fps=22, width=1280, height=704, @@ -406,6 +406,7 @@ class ImageToImageInput(DecartBaseModel): "lucy-image-2": ModelDefinition( name="lucy-image-2", url_path="/v1/generate/lucy-image-2", + queue_url_path="/v1/jobs/lucy-image-2", fps=25, width=1280, height=704, @@ -415,6 +416,7 @@ class ImageToImageInput(DecartBaseModel): "lucy-image-latest": ModelDefinition( name="lucy-image-latest", url_path="/v1/generate/lucy-image-latest", + queue_url_path="/v1/jobs/lucy-image-latest", fps=25, width=1280, height=704, @@ -424,6 +426,7 @@ class ImageToImageInput(DecartBaseModel): "lucy-pro-i2i": ModelDefinition( name="lucy-pro-i2i", url_path="/v1/generate/lucy-pro-i2i", + queue_url_path="/v1/jobs/lucy-pro-i2i", fps=25, width=1280, height=704, @@ -434,39 +437,6 @@ class ImageToImageInput(DecartBaseModel): class Models: - @staticmethod - def custom( - name: str, - *, - fps: int, - width: int, - height: int, - url_path: str = "/v1/stream", - input_schema: type[BaseModel] = BaseModel, - ) -> CustomModelDefinition: - """Create a custom model definition with an arbitrary model name. - - This is useful for preview, experimental, or private models that are - not yet in the SDK's built-in registry. Pass the returned definition - directly to the matching client API. - - For realtime models, keep the default ``url_path="/v1/stream"``. - For process/custom image models, pass the generation endpoint as - ``url_path``. Queue jobs always submit to ``/v1/jobs/{model.name}``, - so custom queue models do not need a separate path. If ``input_schema`` - is omitted, process and queue inputs are sent through without - client-side schema validation; the backend/bouncer validates model - availability and API support. - """ - return CustomModelDefinition( - name=name, - url_path=url_path, - fps=fps, - width=width, - height=height, - input_schema=input_schema, - ) - @staticmethod def realtime(model: RealTimeModels) -> RealTimeModelDefinition: """Get a realtime model definition for WebRTC streaming.""" diff --git a/decart/queue/client.py b/decart/queue/client.py index a95b2d6..21b83ba 100644 --- a/decart/queue/client.py +++ b/decart/queue/client.py @@ -2,7 +2,7 @@ from typing import Any, Optional, TYPE_CHECKING import aiohttp -from pydantic import BaseModel, ValidationError +from pydantic import ValidationError from ..models import ModelDefinition from ..errors import InvalidInputError @@ -93,7 +93,7 @@ async def submit(self, options: dict[str, Any]) -> JobSubmitResponse: file_inputs = {k: v for k, v in inputs.items() if k in FILE_FIELDS} non_file_inputs = {k: v for k, v in inputs.items() if k not in FILE_FIELDS} - if model.input_schema is BaseModel: + if model.input_schema is None: # Custom models can omit an input schema; in that case we pass # arbitrary fields through and let the backend validate them. processed_inputs = {k: v for k, v in inputs.items() if v is not None} diff --git a/decart/queue/request.py b/decart/queue/request.py index 05b25db..879440c 100644 --- a/decart/queue/request.py +++ b/decart/queue/request.py @@ -2,7 +2,12 @@ from typing import Any, Optional from ..models import ModelDefinition -from ..errors import QueueSubmitError, QueueStatusError, QueueResultError +from ..errors import ( + InvalidInputError, + QueueSubmitError, + QueueStatusError, + QueueResultError, +) from .._user_agent import build_user_agent from ..process.request import file_input_to_bytes from .types import JobSubmitResponse, JobStatusResponse @@ -18,8 +23,14 @@ async def submit_job( ) -> JobSubmitResponse: """Submit a job to the queue. - POST /v1/jobs/{model} + POST {model.queue_url_path} """ + if not model.queue_url_path: + raise InvalidInputError( + f"Model '{model.name}' does not support the queue API. " + f"Set queue_url_path on the model definition (e.g. '/v1/jobs/{model.name}')." + ) + form_data = aiohttp.FormData() for key, value in inputs.items(): @@ -30,7 +41,7 @@ async def submit_job( else: form_data.add_field(key, str(value)) - endpoint = f"{base_url}/v1/jobs/{model.name}" + endpoint = f"{base_url}{model.queue_url_path}" async with session.post( endpoint, diff --git a/tests/test_models.py b/tests/test_models.py index 0916206..bfef4e9 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,7 +1,7 @@ import warnings import pytest -from decart import models, DecartSDKError, CustomModelDefinition -from pydantic import BaseModel, ValidationError +from decart import models, DecartSDKError, ModelDefinition +from pydantic import ValidationError from decart.models import _warned_aliases @@ -77,29 +77,34 @@ def test_deprecated_realtime_models() -> None: def test_canonical_video_models() -> None: model = models.video("lucy-clip") assert model.name == "lucy-clip" - assert model.url_path == "/v1/jobs/lucy-clip" + assert model.url_path == "/v1/generate/lucy-clip" + assert model.queue_url_path == "/v1/jobs/lucy-clip" model = models.video("lucy-2.1") assert model.name == "lucy-2.1" - assert model.url_path == "/v1/jobs/lucy-2.1" + assert model.url_path == "/v1/generate/lucy-2.1" + assert model.queue_url_path == "/v1/jobs/lucy-2.1" assert model.fps == 20 assert model.width == 1088 assert model.height == 624 model = models.video("lucy-2.1-vton") assert model.name == "lucy-2.1-vton" - assert model.url_path == "/v1/jobs/lucy-2.1-vton" + assert model.url_path == "/v1/generate/lucy-2.1-vton" + assert model.queue_url_path == "/v1/jobs/lucy-2.1-vton" assert model.fps == 20 assert model.width == 1088 assert model.height == 624 model = models.video("lucy-restyle-2") assert model.name == "lucy-restyle-2" - assert model.url_path == "/v1/jobs/lucy-restyle-2" + assert model.url_path == "/v1/generate/lucy-restyle-2" + assert model.queue_url_path == "/v1/jobs/lucy-restyle-2" model = models.video("lucy-motion") assert model.name == "lucy-motion" - assert model.url_path == "/v1/jobs/lucy-motion" + assert model.url_path == "/v1/generate/lucy-motion" + assert model.queue_url_path == "/v1/jobs/lucy-motion" def test_deprecated_video_models() -> None: @@ -176,31 +181,36 @@ def test_latest_realtime_models() -> None: def test_latest_video_models() -> None: model = models.video("lucy-latest") assert model.name == "lucy-latest" - assert model.url_path == "/v1/jobs/lucy-latest" + assert model.url_path == "/v1/generate/lucy-latest" + assert model.queue_url_path == "/v1/jobs/lucy-latest" assert model.fps == 20 assert model.width == 1088 assert model.height == 624 model = models.video("lucy-vton-latest") assert model.name == "lucy-vton-latest" - assert model.url_path == "/v1/jobs/lucy-vton-latest" + assert model.url_path == "/v1/generate/lucy-vton-latest" + assert model.queue_url_path == "/v1/jobs/lucy-vton-latest" assert model.fps == 20 assert model.width == 1088 assert model.height == 624 model = models.video("lucy-restyle-latest") assert model.name == "lucy-restyle-latest" - assert model.url_path == "/v1/jobs/lucy-restyle-latest" + assert model.url_path == "/v1/generate/lucy-restyle-latest" + assert model.queue_url_path == "/v1/jobs/lucy-restyle-latest" assert model.fps == 22 model = models.video("lucy-clip-latest") assert model.name == "lucy-clip-latest" - assert model.url_path == "/v1/jobs/lucy-clip-latest" + assert model.url_path == "/v1/generate/lucy-clip-latest" + assert model.queue_url_path == "/v1/jobs/lucy-clip-latest" assert model.fps == 25 model = models.video("lucy-motion-latest") assert model.name == "lucy-motion-latest" - assert model.url_path == "/v1/jobs/lucy-motion-latest" + assert model.url_path == "/v1/generate/lucy-motion-latest" + assert model.queue_url_path == "/v1/jobs/lucy-motion-latest" assert model.fps == 25 @@ -228,25 +238,27 @@ def test_latest_aliases_no_deprecation_warning() -> None: def test_custom_model_definition_allows_arbitrary_model_names() -> None: - model = models.custom( - "lucy_2_rt_preview", + model = ModelDefinition( + name="lucy_2_rt_preview", + url_path="/v1/stream", fps=20, width=1280, height=720, ) - assert isinstance(model, CustomModelDefinition) + assert isinstance(model, ModelDefinition) assert model.name == "lucy_2_rt_preview" assert model.url_path == "/v1/stream" + assert model.queue_url_path is None assert model.fps == 20 assert model.width == 1280 assert model.height == 720 - assert model.input_schema is BaseModel + assert model.input_schema is None def test_custom_model_definition_validates_required_shape() -> None: with pytest.raises(ValidationError): - CustomModelDefinition(name="my_custom_model", url_path="/v1/stream") + ModelDefinition(name="my_custom_model", url_path="/v1/stream") def test_invalid_model() -> None: diff --git a/tests/test_process.py b/tests/test_process.py index 98f04ed..a2de68f 100644 --- a/tests/test_process.py +++ b/tests/test_process.py @@ -6,7 +6,7 @@ import pytest import asyncio from unittest.mock import AsyncMock, patch, MagicMock -from decart import DecartClient, models, DecartSDKError +from decart import DecartClient, ModelDefinition, models, DecartSDKError @pytest.mark.asyncio @@ -75,8 +75,8 @@ async def test_process_image_to_image_with_reference_image() -> None: @pytest.mark.asyncio async def test_process_accepts_custom_model_definition_without_schema() -> None: client = DecartClient(api_key="test-key") - custom_model = models.custom( - "lucy_image_preview", + custom_model = ModelDefinition( + name="lucy_image_preview", url_path="/v1/generate/lucy_image_preview", fps=25, width=1280, @@ -108,10 +108,11 @@ async def test_process_accepts_custom_model_definition_without_schema() -> None: @pytest.mark.asyncio -async def test_process_allows_custom_model_with_default_url_path() -> None: +async def test_process_allows_custom_model_definition_for_realtime_url_path() -> None: client = DecartClient(api_key="test-key") - custom_model = models.custom( - "lucy_image_preview", + custom_model = ModelDefinition( + name="lucy_image_preview", + url_path="/v1/stream", fps=25, width=1280, height=704, diff --git a/tests/test_queue.py b/tests/test_queue.py index 595a648..04573f1 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -5,7 +5,14 @@ import pytest from unittest.mock import AsyncMock, patch, MagicMock -from decart import DecartClient, models, DecartSDKError, QueueSubmitError +from decart import ( + DecartClient, + InvalidInputError, + ModelDefinition, + models, + DecartSDKError, + QueueSubmitError, +) @pytest.mark.asyncio @@ -54,8 +61,10 @@ async def test_queue_submit_video_to_video() -> None: @pytest.mark.asyncio async def test_queue_submit_accepts_custom_model_definition_without_schema() -> None: client = DecartClient(api_key="test-key") - custom_model = models.custom( - "lucy_video_preview", + custom_model = ModelDefinition( + name="lucy_video_preview", + url_path="/v1/generate/lucy_video_preview", + queue_url_path="/v1/jobs/lucy_video_preview", fps=20, width=1280, height=720, @@ -327,10 +336,12 @@ async def test_queue_includes_user_agent_header() -> None: @pytest.mark.asyncio -async def test_queue_custom_model_uses_standard_jobs_url() -> None: +async def test_queue_custom_model_uses_queue_url_path() -> None: client = DecartClient(api_key="test-key") - custom_model = models.custom( - "lucy_video_preview", + custom_model = ModelDefinition( + name="lucy_video_preview", + url_path="/v1/generate/lucy_video_preview", + queue_url_path="/v1/jobs/lucy_video_preview", fps=20, width=1280, height=720, @@ -365,30 +376,17 @@ async def test_queue_custom_model_uses_standard_jobs_url() -> None: @pytest.mark.asyncio -async def test_queue_custom_model_ignores_url_path_for_jobs_url() -> None: +async def test_queue_custom_model_without_queue_url_path_raises() -> None: client = DecartClient(api_key="test-key") - custom_model = models.custom( - "lucy_video_preview", - url_path="/v1/not-the-queue-url", + custom_model = ModelDefinition( + name="lucy_video_preview", + url_path="/v1/stream", fps=20, width=1280, height=720, ) - with patch("aiohttp.ClientSession") as mock_session_cls: - mock_response = MagicMock() - mock_response.ok = True - mock_response.json = AsyncMock(return_value={"job_id": "job-123", "status": "pending"}) - - mock_session = MagicMock() - mock_session.__aenter__ = AsyncMock(return_value=mock_session) - mock_session.__aexit__ = AsyncMock(return_value=None) - mock_session.post = MagicMock() - mock_session.post.return_value.__aenter__ = AsyncMock(return_value=mock_response) - mock_session.post.return_value.__aexit__ = AsyncMock(return_value=None) - - mock_session_cls.return_value = mock_session - + with pytest.raises(InvalidInputError) as exc_info: await client.queue.submit( { "model": custom_model, @@ -397,17 +395,16 @@ async def test_queue_custom_model_ignores_url_path_for_jobs_url() -> None: } ) - assert ( - mock_session.post.call_args.args[0] - == "https://api.decart.ai/v1/jobs/lucy_video_preview" - ) + assert "queue_url_path" in str(exc_info.value) @pytest.mark.asyncio async def test_queue_custom_model_raises_bouncer_error() -> None: client = DecartClient(api_key="test-key") - custom_model = models.custom( - "unknown_model", + custom_model = ModelDefinition( + name="unknown_model", + url_path="/v1/generate/unknown_model", + queue_url_path="/v1/jobs/unknown_model", fps=20, width=1280, height=720, From 0654ae9eb1dab67e01a53e580461d9d73fd9f9f9 Mon Sep 17 00:00:00 2001 From: Adir Amsalem Date: Wed, 6 May 2026 23:26:22 +0300 Subject: [PATCH 3/8] refactor: collapse queue_url_path into url_path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each model now has a single `url_path` field that points at its primary endpoint: - realtime: `/v1/stream` - image: `/v1/generate/` - video: `/v1/jobs/` Queue, process, and realtime clients all submit/connect to `{base_url}{model.url_path}`. This drops the JS-style split between `urlPath` and `queueUrlPath` — JS only carries both because TypeScript forces every model to have a `urlPath`. In Python a single field suffices, since video models are queue-only and image models are process-only via the registry. Custom models continue to work: pass whichever endpoint is appropriate for the API you intend to call (e.g. `url_path="/v1/jobs/"` for a custom queue model). --- README.md | 5 ++--- decart/models.py | 40 ++++++++++++---------------------------- decart/queue/request.py | 17 +++-------------- tests/test_models.py | 31 ++++++++++--------------------- tests/test_queue.py | 35 ++++------------------------------- 5 files changed, 31 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index 092e451..e9c2f59 100644 --- a/README.md +++ b/README.md @@ -132,11 +132,10 @@ image = await client.process({ "data": open("input.png", "rb"), }) -# Queue API: set queue_url_path. Submitting a model without it raises InvalidInputError. +# Queue API: point url_path at the queue endpoint for the model. custom_video_model = ModelDefinition( name="lucy_video_preview", - url_path="/v1/generate/lucy_video_preview", - queue_url_path="/v1/jobs/lucy_video_preview", + url_path="/v1/jobs/lucy_video_preview", fps=20, width=1280, height=720, diff --git a/decart/models.py b/decart/models.py index 3691f36..ae2dff7 100644 --- a/decart/models.py +++ b/decart/models.py @@ -88,7 +88,6 @@ class DecartBaseModel(BaseModel): class ModelDefinition(DecartBaseModel, Generic[ModelT]): name: ModelT url_path: str - queue_url_path: Optional[str] = None fps: int = Field(ge=1) width: int = Field(ge=1) height: int = Field(ge=1) @@ -292,8 +291,7 @@ class ImageToImageInput(DecartBaseModel): # Canonical names "lucy-clip": ModelDefinition( name="lucy-clip", - url_path="/v1/generate/lucy-clip", - queue_url_path="/v1/jobs/lucy-clip", + url_path="/v1/jobs/lucy-clip", fps=25, width=1280, height=704, @@ -301,8 +299,7 @@ class ImageToImageInput(DecartBaseModel): ), "lucy-2.1": ModelDefinition( name="lucy-2.1", - url_path="/v1/generate/lucy-2.1", - queue_url_path="/v1/jobs/lucy-2.1", + url_path="/v1/jobs/lucy-2.1", fps=20, width=1088, height=624, @@ -310,8 +307,7 @@ class ImageToImageInput(DecartBaseModel): ), "lucy-2.1-vton": ModelDefinition( name="lucy-2.1-vton", - url_path="/v1/generate/lucy-2.1-vton", - queue_url_path="/v1/jobs/lucy-2.1-vton", + url_path="/v1/jobs/lucy-2.1-vton", fps=20, width=1088, height=624, @@ -319,8 +315,7 @@ class ImageToImageInput(DecartBaseModel): ), "lucy-restyle-2": ModelDefinition( name="lucy-restyle-2", - url_path="/v1/generate/lucy-restyle-2", - queue_url_path="/v1/jobs/lucy-restyle-2", + url_path="/v1/jobs/lucy-restyle-2", fps=22, width=1280, height=704, @@ -328,8 +323,7 @@ class ImageToImageInput(DecartBaseModel): ), "lucy-motion": ModelDefinition( name="lucy-motion", - url_path="/v1/generate/lucy-motion", - queue_url_path="/v1/jobs/lucy-motion", + url_path="/v1/jobs/lucy-motion", fps=25, width=1280, height=704, @@ -338,8 +332,7 @@ class ImageToImageInput(DecartBaseModel): # Latest aliases (server-side resolution) "lucy-latest": ModelDefinition( name="lucy-latest", - url_path="/v1/generate/lucy-latest", - queue_url_path="/v1/jobs/lucy-latest", + url_path="/v1/jobs/lucy-latest", fps=20, width=1088, height=624, @@ -347,8 +340,7 @@ class ImageToImageInput(DecartBaseModel): ), "lucy-vton-latest": ModelDefinition( name="lucy-vton-latest", - url_path="/v1/generate/lucy-vton-latest", - queue_url_path="/v1/jobs/lucy-vton-latest", + url_path="/v1/jobs/lucy-vton-latest", fps=20, width=1088, height=624, @@ -356,8 +348,7 @@ class ImageToImageInput(DecartBaseModel): ), "lucy-restyle-latest": ModelDefinition( name="lucy-restyle-latest", - url_path="/v1/generate/lucy-restyle-latest", - queue_url_path="/v1/jobs/lucy-restyle-latest", + url_path="/v1/jobs/lucy-restyle-latest", fps=22, width=1280, height=704, @@ -365,8 +356,7 @@ class ImageToImageInput(DecartBaseModel): ), "lucy-clip-latest": ModelDefinition( name="lucy-clip-latest", - url_path="/v1/generate/lucy-clip-latest", - queue_url_path="/v1/jobs/lucy-clip-latest", + url_path="/v1/jobs/lucy-clip-latest", fps=25, width=1280, height=704, @@ -374,8 +364,7 @@ class ImageToImageInput(DecartBaseModel): ), "lucy-motion-latest": ModelDefinition( name="lucy-motion-latest", - url_path="/v1/generate/lucy-motion-latest", - queue_url_path="/v1/jobs/lucy-motion-latest", + url_path="/v1/jobs/lucy-motion-latest", fps=25, width=1280, height=704, @@ -384,8 +373,7 @@ class ImageToImageInput(DecartBaseModel): # Deprecated names "lucy-pro-v2v": ModelDefinition( name="lucy-pro-v2v", - url_path="/v1/generate/lucy-pro-v2v", - queue_url_path="/v1/jobs/lucy-pro-v2v", + url_path="/v1/jobs/lucy-pro-v2v", fps=25, width=1280, height=704, @@ -393,8 +381,7 @@ class ImageToImageInput(DecartBaseModel): ), "lucy-restyle-v2v": ModelDefinition( name="lucy-restyle-v2v", - url_path="/v1/generate/lucy-restyle-v2v", - queue_url_path="/v1/jobs/lucy-restyle-v2v", + url_path="/v1/jobs/lucy-restyle-v2v", fps=22, width=1280, height=704, @@ -406,7 +393,6 @@ class ImageToImageInput(DecartBaseModel): "lucy-image-2": ModelDefinition( name="lucy-image-2", url_path="/v1/generate/lucy-image-2", - queue_url_path="/v1/jobs/lucy-image-2", fps=25, width=1280, height=704, @@ -416,7 +402,6 @@ class ImageToImageInput(DecartBaseModel): "lucy-image-latest": ModelDefinition( name="lucy-image-latest", url_path="/v1/generate/lucy-image-latest", - queue_url_path="/v1/jobs/lucy-image-latest", fps=25, width=1280, height=704, @@ -426,7 +411,6 @@ class ImageToImageInput(DecartBaseModel): "lucy-pro-i2i": ModelDefinition( name="lucy-pro-i2i", url_path="/v1/generate/lucy-pro-i2i", - queue_url_path="/v1/jobs/lucy-pro-i2i", fps=25, width=1280, height=704, diff --git a/decart/queue/request.py b/decart/queue/request.py index 879440c..3f30f70 100644 --- a/decart/queue/request.py +++ b/decart/queue/request.py @@ -2,12 +2,7 @@ from typing import Any, Optional from ..models import ModelDefinition -from ..errors import ( - InvalidInputError, - QueueSubmitError, - QueueStatusError, - QueueResultError, -) +from ..errors import QueueSubmitError, QueueStatusError, QueueResultError from .._user_agent import build_user_agent from ..process.request import file_input_to_bytes from .types import JobSubmitResponse, JobStatusResponse @@ -23,14 +18,8 @@ async def submit_job( ) -> JobSubmitResponse: """Submit a job to the queue. - POST {model.queue_url_path} + POST {model.url_path} """ - if not model.queue_url_path: - raise InvalidInputError( - f"Model '{model.name}' does not support the queue API. " - f"Set queue_url_path on the model definition (e.g. '/v1/jobs/{model.name}')." - ) - form_data = aiohttp.FormData() for key, value in inputs.items(): @@ -41,7 +30,7 @@ async def submit_job( else: form_data.add_field(key, str(value)) - endpoint = f"{base_url}{model.queue_url_path}" + endpoint = f"{base_url}{model.url_path}" async with session.post( endpoint, diff --git a/tests/test_models.py b/tests/test_models.py index bfef4e9..2bb81d0 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -77,34 +77,29 @@ def test_deprecated_realtime_models() -> None: def test_canonical_video_models() -> None: model = models.video("lucy-clip") assert model.name == "lucy-clip" - assert model.url_path == "/v1/generate/lucy-clip" - assert model.queue_url_path == "/v1/jobs/lucy-clip" + assert model.url_path == "/v1/jobs/lucy-clip" model = models.video("lucy-2.1") assert model.name == "lucy-2.1" - assert model.url_path == "/v1/generate/lucy-2.1" - assert model.queue_url_path == "/v1/jobs/lucy-2.1" + assert model.url_path == "/v1/jobs/lucy-2.1" assert model.fps == 20 assert model.width == 1088 assert model.height == 624 model = models.video("lucy-2.1-vton") assert model.name == "lucy-2.1-vton" - assert model.url_path == "/v1/generate/lucy-2.1-vton" - assert model.queue_url_path == "/v1/jobs/lucy-2.1-vton" + assert model.url_path == "/v1/jobs/lucy-2.1-vton" assert model.fps == 20 assert model.width == 1088 assert model.height == 624 model = models.video("lucy-restyle-2") assert model.name == "lucy-restyle-2" - assert model.url_path == "/v1/generate/lucy-restyle-2" - assert model.queue_url_path == "/v1/jobs/lucy-restyle-2" + assert model.url_path == "/v1/jobs/lucy-restyle-2" model = models.video("lucy-motion") assert model.name == "lucy-motion" - assert model.url_path == "/v1/generate/lucy-motion" - assert model.queue_url_path == "/v1/jobs/lucy-motion" + assert model.url_path == "/v1/jobs/lucy-motion" def test_deprecated_video_models() -> None: @@ -181,36 +176,31 @@ def test_latest_realtime_models() -> None: def test_latest_video_models() -> None: model = models.video("lucy-latest") assert model.name == "lucy-latest" - assert model.url_path == "/v1/generate/lucy-latest" - assert model.queue_url_path == "/v1/jobs/lucy-latest" + assert model.url_path == "/v1/jobs/lucy-latest" assert model.fps == 20 assert model.width == 1088 assert model.height == 624 model = models.video("lucy-vton-latest") assert model.name == "lucy-vton-latest" - assert model.url_path == "/v1/generate/lucy-vton-latest" - assert model.queue_url_path == "/v1/jobs/lucy-vton-latest" + assert model.url_path == "/v1/jobs/lucy-vton-latest" assert model.fps == 20 assert model.width == 1088 assert model.height == 624 model = models.video("lucy-restyle-latest") assert model.name == "lucy-restyle-latest" - assert model.url_path == "/v1/generate/lucy-restyle-latest" - assert model.queue_url_path == "/v1/jobs/lucy-restyle-latest" + assert model.url_path == "/v1/jobs/lucy-restyle-latest" assert model.fps == 22 model = models.video("lucy-clip-latest") assert model.name == "lucy-clip-latest" - assert model.url_path == "/v1/generate/lucy-clip-latest" - assert model.queue_url_path == "/v1/jobs/lucy-clip-latest" + assert model.url_path == "/v1/jobs/lucy-clip-latest" assert model.fps == 25 model = models.video("lucy-motion-latest") assert model.name == "lucy-motion-latest" - assert model.url_path == "/v1/generate/lucy-motion-latest" - assert model.queue_url_path == "/v1/jobs/lucy-motion-latest" + assert model.url_path == "/v1/jobs/lucy-motion-latest" assert model.fps == 25 @@ -249,7 +239,6 @@ def test_custom_model_definition_allows_arbitrary_model_names() -> None: assert isinstance(model, ModelDefinition) assert model.name == "lucy_2_rt_preview" assert model.url_path == "/v1/stream" - assert model.queue_url_path is None assert model.fps == 20 assert model.width == 1280 assert model.height == 720 diff --git a/tests/test_queue.py b/tests/test_queue.py index 04573f1..eb808f4 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -7,7 +7,6 @@ from unittest.mock import AsyncMock, patch, MagicMock from decart import ( DecartClient, - InvalidInputError, ModelDefinition, models, DecartSDKError, @@ -63,8 +62,7 @@ async def test_queue_submit_accepts_custom_model_definition_without_schema() -> client = DecartClient(api_key="test-key") custom_model = ModelDefinition( name="lucy_video_preview", - url_path="/v1/generate/lucy_video_preview", - queue_url_path="/v1/jobs/lucy_video_preview", + url_path="/v1/jobs/lucy_video_preview", fps=20, width=1280, height=720, @@ -336,12 +334,11 @@ async def test_queue_includes_user_agent_header() -> None: @pytest.mark.asyncio -async def test_queue_custom_model_uses_queue_url_path() -> None: +async def test_queue_custom_model_uses_url_path() -> None: client = DecartClient(api_key="test-key") custom_model = ModelDefinition( name="lucy_video_preview", - url_path="/v1/generate/lucy_video_preview", - queue_url_path="/v1/jobs/lucy_video_preview", + url_path="/v1/jobs/lucy_video_preview", fps=20, width=1280, height=720, @@ -375,36 +372,12 @@ async def test_queue_custom_model_uses_queue_url_path() -> None: ) -@pytest.mark.asyncio -async def test_queue_custom_model_without_queue_url_path_raises() -> None: - client = DecartClient(api_key="test-key") - custom_model = ModelDefinition( - name="lucy_video_preview", - url_path="/v1/stream", - fps=20, - width=1280, - height=720, - ) - - with pytest.raises(InvalidInputError) as exc_info: - await client.queue.submit( - { - "model": custom_model, - "prompt": "Use the custom video model", - "data": b"fake video data", - } - ) - - assert "queue_url_path" in str(exc_info.value) - - @pytest.mark.asyncio async def test_queue_custom_model_raises_bouncer_error() -> None: client = DecartClient(api_key="test-key") custom_model = ModelDefinition( name="unknown_model", - url_path="/v1/generate/unknown_model", - queue_url_path="/v1/jobs/unknown_model", + url_path="/v1/jobs/unknown_model", fps=20, width=1280, height=720, From 7c8e12837f992ed4fbb139dc03e52ac4e68f4756 Mon Sep 17 00:00:00 2001 From: Adir Amsalem Date: Wed, 6 May 2026 23:30:57 +0300 Subject: [PATCH 4/8] fix: drop remaining models.custom() references The realtime unit test and two docstrings still referenced the removed `models.custom()` factory. Switch the test to constructing `ModelDefinition` directly and update the docstrings to match. Resolves the CI failure on Python 3.11 (and the Cursor Bugbot review). --- decart/client.py | 2 +- decart/queue/client.py | 2 +- tests/test_realtime_unit.py | 7 ++++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/decart/client.py b/decart/client.py index 9dd3ed7..5df0e4e 100644 --- a/decart/client.py +++ b/decart/client.py @@ -152,7 +152,7 @@ async def process(self, options: dict[str, Any]) -> bytes: Args: options: Processing options including model and inputs - - model: ModelDefinition from models.image(), models.video(), models.realtime(), or models.custom() + - model: ModelDefinition from models.image(), models.video(), models.realtime(), or constructed directly - prompt: Text instructions describing the requested edit - Additional model-specific inputs diff --git a/decart/queue/client.py b/decart/queue/client.py index 21b83ba..c787a88 100644 --- a/decart/queue/client.py +++ b/decart/queue/client.py @@ -68,7 +68,7 @@ async def submit(self, options: dict[str, Any]) -> JobSubmitResponse: Args: options: Submit options including model and inputs - - model: VideoModelDefinition from models.video(), or a custom definition from models.custom() + - model: VideoModelDefinition from models.video(), or a custom ModelDefinition - prompt: Text instructions describing the requested edit - Additional model-specific inputs diff --git a/tests/test_realtime_unit.py b/tests/test_realtime_unit.py index e2856db..0486bcc 100644 --- a/tests/test_realtime_unit.py +++ b/tests/test_realtime_unit.py @@ -2,7 +2,7 @@ import pytest from unittest.mock import AsyncMock, MagicMock, patch -from decart import DecartClient, models +from decart import DecartClient, ModelDefinition, models try: from decart.realtime.client import RealtimeClient @@ -120,8 +120,9 @@ async def test_realtime_client_creation_with_mock(): async def test_realtime_connect_accepts_custom_model_definition(): """Custom realtime models can use arbitrary model names, matching the JS SDK escape hatch.""" client = DecartClient(api_key="test-key") - custom_model = models.custom( - "lucy_2_rt_preview", + custom_model = ModelDefinition( + name="lucy_2_rt_preview", + url_path="/v1/stream", fps=20, width=1280, height=720, From 25d7430c00fabf7dcedb279d5ad54cf1fa9addf3 Mon Sep 17 00:00:00 2001 From: Adir Amsalem Date: Wed, 6 May 2026 23:36:02 +0300 Subject: [PATCH 5/8] refactor: trim added comments and docstring chatter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the "Custom models can omit an input schema..." comments — the `if model.input_schema is None:` branch is self-explanatory. Also tighten docstrings the PR over-extended: - `DecartClient.queue` / `QueueClient`: drop the second-line "SDK accepts any model definition..." preamble - `DecartClient.process`: drop the "backend validates whether the model supports this API" line and over-broad model list - `QueueClient.submit`: drop the "Video models and custom queue model definitions are supported" line - `CustomModelDefinition`: collapse the two-paragraph docstring to one line --- decart/client.py | 6 +----- decart/models.py | 6 +----- decart/queue/client.py | 4 ---- 3 files changed, 2 insertions(+), 14 deletions(-) diff --git a/decart/client.py b/decart/client.py index 5df0e4e..20ceab7 100644 --- a/decart/client.py +++ b/decart/client.py @@ -79,7 +79,6 @@ def __init__( def queue(self) -> QueueClient: """ Queue client for async jobs. - The SDK accepts any model definition and lets the backend validate support. Example: ```python @@ -145,14 +144,13 @@ async def __aexit__( async def process(self, options: dict[str, Any]) -> bytes: """ Process synchronously using the model definition's configured endpoint. - The backend validates whether the model supports this API. For video editing, use the queue API instead: result = await client.queue.submit_and_poll({...}) Args: options: Processing options including model and inputs - - model: ModelDefinition from models.image(), models.video(), models.realtime(), or constructed directly + - model: ModelDefinition from models.image() or constructed directly - prompt: Text instructions describing the requested edit - Additional model-specific inputs @@ -180,8 +178,6 @@ async def process(self, options: dict[str, Any]) -> bytes: non_file_inputs = {k: v for k, v in inputs.items() if k not in FILE_FIELDS} if model.input_schema is None: - # Custom models can omit an input schema; in that case we pass - # arbitrary fields through and let the backend validate them. processed_inputs = {k: v for k, v in inputs.items() if v is not None} else: # Validate non-file inputs and create placeholder for file fields diff --git a/decart/models.py b/decart/models.py index ae2dff7..ebb0447 100644 --- a/decart/models.py +++ b/decart/models.py @@ -105,11 +105,7 @@ class ModelDefinition(DecartBaseModel, Generic[ModelT]): """Type alias for model definitions that support realtime streaming.""" CustomModelDefinition = ModelDefinition[str] -"""Type alias for custom model definitions with arbitrary model names. - -Useful for preview, experimental, or private models that are not yet -in the SDK's built-in registry. -""" +"""Type alias for model definitions with arbitrary (non-registry) model names.""" class VideoToVideoInput(DecartBaseModel): diff --git a/decart/queue/client.py b/decart/queue/client.py index c787a88..ef14a77 100644 --- a/decart/queue/client.py +++ b/decart/queue/client.py @@ -26,7 +26,6 @@ class QueueClient: """ Queue client for async jobs. - The SDK accepts any model definition and lets the backend validate support. Jobs are submitted and processed asynchronously, allowing you to poll for status and retrieve results when ready. @@ -63,7 +62,6 @@ async def _get_session(self) -> aiohttp.ClientSession: async def submit(self, options: dict[str, Any]) -> JobSubmitResponse: """ Submit an async queue job. - Video models and custom queue model definitions are supported. Returns immediately with job_id and initial status. Args: @@ -94,8 +92,6 @@ async def submit(self, options: dict[str, Any]) -> JobSubmitResponse: non_file_inputs = {k: v for k, v in inputs.items() if k not in FILE_FIELDS} if model.input_schema is None: - # Custom models can omit an input schema; in that case we pass - # arbitrary fields through and let the backend validate them. processed_inputs = {k: v for k, v in inputs.items() if v is not None} else: # Validate non-file inputs From a0beb5ffcaa8155c7e3a0a1c728fea34dc761c54 Mon Sep 17 00:00:00 2001 From: Adir Amsalem Date: Wed, 6 May 2026 23:37:02 +0300 Subject: [PATCH 6/8] test: drop cross-bucket model passthrough tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove four tests that asserted process() accepts video/realtime ModelDefinitions and queue.submit() accepts image/realtime ModelDefinitions. They mocked the underlying request layer to pin "the SDK does not gate by registry bucket", but they don't reflect real usage — those calls would 404 against the backend because the url_path of one model is wrong for the other API. --- tests/test_process.py | 40 ---------------------------------------- tests/test_queue.py | 40 ---------------------------------------- 2 files changed, 80 deletions(-) diff --git a/tests/test_process.py b/tests/test_process.py index a2de68f..a408c50 100644 --- a/tests/test_process.py +++ b/tests/test_process.py @@ -133,46 +133,6 @@ async def test_process_allows_custom_model_definition_for_realtime_url_path() -> assert mock_send.call_args.kwargs["model"] is custom_model -@pytest.mark.asyncio -async def test_process_allows_video_model_definitions() -> None: - client = DecartClient(api_key="test-key") - model = models.video("lucy-clip") - - with patch("decart.client.send_request", new_callable=AsyncMock) as mock_send: - mock_send.return_value = b"fake response" - - result = await client.process( - { - "model": model, - "prompt": "Add cinematic teal-and-orange grading", - "data": b"fake video data", - } - ) - - assert result == b"fake response" - assert mock_send.call_args.kwargs["model"] is model - - -@pytest.mark.asyncio -async def test_process_allows_realtime_model_definitions() -> None: - client = DecartClient(api_key="test-key") - model = models.realtime("lucy") - - with patch("decart.client.send_request", new_callable=AsyncMock) as mock_send: - mock_send.return_value = b"fake response" - - result = await client.process( - { - "model": model, - "prompt": "Apply a preview model treatment", - "data": b"fake image data", - } - ) - - assert result == b"fake response" - assert mock_send.call_args.kwargs["model"] is model - - @pytest.mark.asyncio async def test_process_missing_model() -> None: client = DecartClient(api_key="test-key") diff --git a/tests/test_queue.py b/tests/test_queue.py index eb808f4..f9267cb 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -92,46 +92,6 @@ async def test_queue_submit_accepts_custom_model_definition_without_schema() -> } -@pytest.mark.asyncio -async def test_queue_allows_image_model_definitions() -> None: - client = DecartClient(api_key="test-key") - model = models.image("lucy-image-2") - - with patch("decart.queue.client.submit_job") as mock_submit: - mock_submit.return_value = MagicMock(job_id="job-image", status="pending") - - job = await client.queue.submit( - { - "model": model, - "prompt": "Apply a painterly sunset color grade", - "data": b"fake image data", - } - ) - - assert job.job_id == "job-image" - assert mock_submit.call_args.kwargs["model"] is model - - -@pytest.mark.asyncio -async def test_queue_allows_realtime_model_definitions_with_overlapping_video_names() -> None: - client = DecartClient(api_key="test-key") - model = models.realtime("lucy-2.1") - - with patch("decart.queue.client.submit_job") as mock_submit: - mock_submit.return_value = MagicMock(job_id="job-realtime", status="pending") - - job = await client.queue.submit( - { - "model": model, - "prompt": "Use the realtime model", - "data": b"fake video data", - } - ) - - assert job.job_id == "job-realtime" - assert mock_submit.call_args.kwargs["model"] is model - - @pytest.mark.asyncio async def test_queue_missing_model() -> None: """Test that missing model raises an error.""" From b33f7406d50300c65d9e446909612358a5913cc8 Mon Sep 17 00:00:00 2001 From: Adir Amsalem Date: Wed, 6 May 2026 23:39:24 +0300 Subject: [PATCH 7/8] test: clean up new tests added by the custom-model PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_models.py: drop the validates-required-shape test (Pydantic's own concern), and trim the over-asserting custom-model test to the meaningful invariants (str name accepted, input_schema defaults None). - test_process.py: drop test_process_allows_custom_model_definition_for_realtime_url_path, same cross-bucket weirdness as the tests just removed. - test_queue.py: drop test_queue_custom_model_uses_url_path (URL construction is identical for registry and custom models, already covered by test_queue_includes_user_agent_header). Refocus the bouncer-error test as test_queue_submit_surfaces_backend_error using a registry video model — the "custom" framing was incidental. - Drop the misleading "accepts any model definition" file-level notes. --- tests/test_models.py | 11 --------- tests/test_process.py | 31 +---------------------- tests/test_queue.py | 57 +++---------------------------------------- 3 files changed, 5 insertions(+), 94 deletions(-) diff --git a/tests/test_models.py b/tests/test_models.py index 2bb81d0..e253d77 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,7 +1,6 @@ import warnings import pytest from decart import models, DecartSDKError, ModelDefinition -from pydantic import ValidationError from decart.models import _warned_aliases @@ -236,20 +235,10 @@ def test_custom_model_definition_allows_arbitrary_model_names() -> None: height=720, ) - assert isinstance(model, ModelDefinition) assert model.name == "lucy_2_rt_preview" - assert model.url_path == "/v1/stream" - assert model.fps == 20 - assert model.width == 1280 - assert model.height == 720 assert model.input_schema is None -def test_custom_model_definition_validates_required_shape() -> None: - with pytest.raises(ValidationError): - ModelDefinition(name="my_custom_model", url_path="/v1/stream") - - def test_invalid_model() -> None: with pytest.raises(DecartSDKError): models.video("invalid-model") diff --git a/tests/test_process.py b/tests/test_process.py index a408c50..a1253a7 100644 --- a/tests/test_process.py +++ b/tests/test_process.py @@ -1,7 +1,4 @@ -""" -Tests for the process API. -Note: process() accepts any model definition and lets the backend validate support. -""" +"""Tests for the process API.""" import pytest import asyncio @@ -107,32 +104,6 @@ async def test_process_accepts_custom_model_definition_without_schema() -> None: } -@pytest.mark.asyncio -async def test_process_allows_custom_model_definition_for_realtime_url_path() -> None: - client = DecartClient(api_key="test-key") - custom_model = ModelDefinition( - name="lucy_image_preview", - url_path="/v1/stream", - fps=25, - width=1280, - height=704, - ) - - with patch("decart.client.send_request", new_callable=AsyncMock) as mock_send: - mock_send.return_value = b"fake image data" - - result = await client.process( - { - "model": custom_model, - "prompt": "Apply a preview model treatment", - "data": b"fake image data", - } - ) - - assert result == b"fake image data" - assert mock_send.call_args.kwargs["model"] is custom_model - - @pytest.mark.asyncio async def test_process_missing_model() -> None: client = DecartClient(api_key="test-key") diff --git a/tests/test_queue.py b/tests/test_queue.py index f9267cb..b45ed0c 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -1,7 +1,4 @@ -""" -Tests for the queue API. -Note: queue API accepts any model definition and lets the backend validate support. -""" +"""Tests for the queue API.""" import pytest from unittest.mock import AsyncMock, patch, MagicMock @@ -294,54 +291,8 @@ async def test_queue_includes_user_agent_header() -> None: @pytest.mark.asyncio -async def test_queue_custom_model_uses_url_path() -> None: +async def test_queue_submit_surfaces_backend_error() -> None: client = DecartClient(api_key="test-key") - custom_model = ModelDefinition( - name="lucy_video_preview", - url_path="/v1/jobs/lucy_video_preview", - fps=20, - width=1280, - height=720, - ) - - with patch("aiohttp.ClientSession") as mock_session_cls: - mock_response = MagicMock() - mock_response.ok = True - mock_response.json = AsyncMock(return_value={"job_id": "job-123", "status": "pending"}) - - mock_session = MagicMock() - mock_session.__aenter__ = AsyncMock(return_value=mock_session) - mock_session.__aexit__ = AsyncMock(return_value=None) - mock_session.post = MagicMock() - mock_session.post.return_value.__aenter__ = AsyncMock(return_value=mock_response) - mock_session.post.return_value.__aexit__ = AsyncMock(return_value=None) - - mock_session_cls.return_value = mock_session - - await client.queue.submit( - { - "model": custom_model, - "prompt": "Use the custom video model", - "data": b"fake video data", - } - ) - - assert ( - mock_session.post.call_args.args[0] - == "https://api.decart.ai/v1/jobs/lucy_video_preview" - ) - - -@pytest.mark.asyncio -async def test_queue_custom_model_raises_bouncer_error() -> None: - client = DecartClient(api_key="test-key") - custom_model = ModelDefinition( - name="unknown_model", - url_path="/v1/jobs/unknown_model", - fps=20, - width=1280, - height=720, - ) with patch("aiohttp.ClientSession") as mock_session_cls: mock_response = MagicMock() @@ -361,8 +312,8 @@ async def test_queue_custom_model_raises_bouncer_error() -> None: with pytest.raises(QueueSubmitError) as exc_info: await client.queue.submit( { - "model": custom_model, - "prompt": "Use the custom video model", + "model": models.video("lucy-clip"), + "prompt": "Apply a cinematic grade", "data": b"fake video data", } ) From de8805b0fcc023951dfdcc7dd16704e628288531 Mon Sep 17 00:00:00 2001 From: Adir Amsalem Date: Thu, 7 May 2026 00:02:17 +0300 Subject: [PATCH 8/8] docs: drop Custom Models README section --- README.md | 63 ------------------------------------------------------- 1 file changed, 63 deletions(-) diff --git a/README.md b/README.md index e9c2f59..c41ac45 100644 --- a/README.md +++ b/README.md @@ -89,69 +89,6 @@ async with DecartClient(api_key=os.getenv("DECART_API_KEY")) as client: f.write(data) ``` -### Custom Models - -For preview, experimental, or private models that are not yet in the SDK registry, -construct a `ModelDefinition` directly and pass it to the matching API. -`models.realtime(...)`, `models.video(...)`, and `models.image(...)` remain registry-only helpers. - -```python -from decart import DecartClient, ModelDefinition, RealtimeClient, RealtimeConnectOptions - -# Realtime: url_path is /v1/stream. -custom_realtime_model = ModelDefinition( - name="lucy_2_rt_preview", - url_path="/v1/stream", - fps=20, - width=1280, - height=720, -) - -realtime_client = await RealtimeClient.connect( - base_url=client.realtime_base_url, - api_key=client.api_key, - local_track=track, - options=RealtimeConnectOptions( - model=custom_realtime_model, - on_remote_stream=lambda stream: print("remote stream", stream), - ), -) - -# Process API: pass the generation endpoint as url_path. -custom_image_model = ModelDefinition( - name="lucy_image_preview", - url_path="/v1/generate/lucy_image_preview", - fps=25, - width=1280, - height=704, -) - -image = await client.process({ - "model": custom_image_model, - "prompt": "Apply a preview model treatment", - "data": open("input.png", "rb"), -}) - -# Queue API: point url_path at the queue endpoint for the model. -custom_video_model = ModelDefinition( - name="lucy_video_preview", - url_path="/v1/jobs/lucy_video_preview", - fps=20, - width=1280, - height=720, -) - -job = await client.queue.submit({ - "model": custom_video_model, - "prompt": "Use the preview video model", - "data": open("input.mp4", "rb"), -}) -``` - -If `input_schema` is omitted, custom process and queue inputs are sent through without -client-side schema validation; the backend/bouncer validates whether the model name, -API surface, and inputs are supported. - ## Development ### Setup with UV