diff --git a/decart/client.py b/decart/client.py index ce44266..e12b392 100644 --- a/decart/client.py +++ b/decart/client.py @@ -2,7 +2,7 @@ import aiohttp from pydantic import ValidationError from .errors import InvalidAPIKeyError, InvalidBaseURLError, InvalidInputError -from .models import ModelDefinition +from .models import ImageModelDefinition, _MODELS from .process.request import send_request from .queue.client import QueueClient @@ -27,7 +27,15 @@ class DecartClient: Example: ```python client = DecartClient(api_key="your-key") - result = await client.process({ + + # Image generation (sync) - use process() + image = await client.process({ + "model": models.image("lucy-pro-t2i"), + "prompt": "A serene lake at sunset", + }) + + # Video generation (async) - use queue + result = await client.queue.submit_and_poll({ "model": models.video("lucy-pro-t2v"), "prompt": "A serene lake at sunset", }) @@ -55,7 +63,8 @@ def __init__( @property def queue(self) -> QueueClient: """ - Queue client for async job-based video and image generation. + Queue client for async job-based video generation. + Only video models support the queue API. Example: ```python @@ -97,22 +106,38 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): async def process(self, options: dict[str, Any]) -> bytes: """ - Process video or image generation/transformation. + Process image generation/transformation synchronously. + Only image models support the process API. + + For video generation, 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() + - prompt: Text prompt for generation + - Additional model-specific inputs Returns: - Generated/transformed media as bytes + Generated/transformed image as bytes Raises: - InvalidInputError: If inputs are invalid + InvalidInputError: If inputs are invalid or model is not an image model ProcessingError: If processing fails """ if "model" not in options: raise InvalidInputError("model is required") - model: ModelDefinition = options["model"] + 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." + ) + cancel_token = options.get("cancel_token") inputs = {k: v for k, v in options.items() if k not in ("model", "cancel_token")} diff --git a/decart/models.py b/decart/models.py index 228529e..841460b 100644 --- a/decart/models.py +++ b/decart/models.py @@ -1,4 +1,4 @@ -from typing import Literal, Optional, List +from typing import Literal, Optional, List, Generic, TypeVar from pydantic import BaseModel, Field, ConfigDict from .errors import ModelNotFoundError from .types import FileInput, MotionTrajectoryInput @@ -17,13 +17,16 @@ ImageModels = Literal["lucy-pro-t2i", "lucy-pro-i2i"] Model = Literal[RealTimeModels, VideoModels, ImageModels] +# Type variable for model name +ModelT = TypeVar("ModelT", bound=str) + class DecartBaseModel(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) -class ModelDefinition(DecartBaseModel): - name: str +class ModelDefinition(DecartBaseModel, Generic[ModelT]): + name: ModelT url_path: str fps: int = Field(ge=1) width: int = Field(ge=1) @@ -31,6 +34,17 @@ class ModelDefinition(DecartBaseModel): input_schema: type[BaseModel] +# Type aliases for model definitions that support specific APIs +ImageModelDefinition = ModelDefinition[ImageModels] +"""Type alias for model definitions that support synchronous processing (process API).""" + +VideoModelDefinition = ModelDefinition[VideoModels] +"""Type alias for model definitions that support queue processing (queue API).""" + +RealTimeModelDefinition = ModelDefinition[RealTimeModels] +"""Type alias for model definitions that support realtime streaming.""" + + class TextToVideoInput(BaseModel): prompt: str = Field(..., min_length=1, max_length=1000) seed: Optional[int] = None @@ -212,23 +226,45 @@ class ImageToImageInput(DecartBaseModel): class Models: @staticmethod - def realtime(model: RealTimeModels) -> ModelDefinition: + def realtime(model: RealTimeModels) -> RealTimeModelDefinition: + """Get a realtime model definition for WebRTC streaming.""" try: - return _MODELS["realtime"][model] + return _MODELS["realtime"][model] # type: ignore[return-value] except KeyError: raise ModelNotFoundError(model) @staticmethod - def video(model: VideoModels) -> ModelDefinition: + def video(model: VideoModels) -> VideoModelDefinition: + """ + Get a video model definition. + Video models only support the queue API. + + Available models: + - "lucy-pro-t2v" - Text-to-video + - "lucy-pro-i2v" - Image-to-video + - "lucy-pro-v2v" - Video-to-video + - "lucy-pro-flf2v" - First-last-frame-to-video + - "lucy-dev-i2v" - Image-to-video (Dev quality) + - "lucy-fast-v2v" - Video-to-video (Fast quality) + - "lucy-motion" - Image-to-motion-video + """ try: - return _MODELS["video"][model] + return _MODELS["video"][model] # type: ignore[return-value] except KeyError: raise ModelNotFoundError(model) @staticmethod - def image(model: ImageModels) -> ModelDefinition: + def image(model: ImageModels) -> ImageModelDefinition: + """ + Get an image model definition. + Image models only support the process (sync) API. + + Available models: + - "lucy-pro-t2i" - Text-to-image + - "lucy-pro-i2i" - Image-to-image + """ try: - return _MODELS["image"][model] + return _MODELS["image"][model] # type: ignore[return-value] except KeyError: raise ModelNotFoundError(model) diff --git a/decart/queue/client.py b/decart/queue/client.py index 80921fc..c06e0ec 100644 --- a/decart/queue/client.py +++ b/decart/queue/client.py @@ -4,7 +4,7 @@ import aiohttp from pydantic import ValidationError -from ..models import ModelDefinition +from ..models import VideoModelDefinition, _MODELS from ..errors import InvalidInputError from .request import submit_job, get_job_status, get_job_content from .types import ( @@ -25,7 +25,8 @@ class QueueClient: """ - Queue client for async job-based video and image generation. + Queue client for async job-based video generation. + Only video models support the queue API. Jobs are submitted and processed asynchronously, allowing you to poll for status and retrieve results when ready. @@ -59,23 +60,35 @@ async def _get_session(self) -> aiohttp.ClientSession: async def submit(self, options: dict[str, Any]) -> JobSubmitResponse: """ - Submit a job to the queue for async processing. + Submit a video generation job to the queue for async processing. + Only video models are supported. Returns immediately with job_id and initial status. Args: options: Submit options including model and inputs + - model: VideoModelDefinition from models.video() + - prompt: Text prompt for generation + - Additional model-specific inputs Returns: JobSubmitResponse with job_id and status Raises: - InvalidInputError: If inputs are invalid + InvalidInputError: If inputs are invalid or model is not a video model QueueSubmitError: If submission fails """ if "model" not in options: raise InvalidInputError("model is required") - model: ModelDefinition = options["model"] + 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." + ) inputs = {k: v for k, v in options.items() if k not in ("model", "cancel_token")} diff --git a/examples/process_url.py b/examples/process_url.py index 521a50a..a93b4e2 100644 --- a/examples/process_url.py +++ b/examples/process_url.py @@ -1,3 +1,8 @@ +""" +Video transformation from URL example using the Queue API. +Video models only support async queue processing. +""" + import asyncio import os from decart import DecartClient, models @@ -6,18 +11,21 @@ async def main() -> None: async with DecartClient(api_key=os.getenv("DECART_API_KEY", "your-api-key-here")) as client: print("Transforming video from URL...") - result = await client.process( + result = await client.queue.submit_and_poll( { "model": models.video("lucy-pro-v2v"), "prompt": "Watercolor painting style", "data": "https://docs.platform.decart.ai/assets/example-video.mp4", + "on_status_change": lambda job: print(f" Status: {job.status}"), } ) - with open("output_url.mp4", "wb") as f: - f.write(result) - - print("Video saved to output_url.mp4") + if result.status == "completed": + with open("output_url.mp4", "wb") as f: + f.write(result.data) + print("Video saved to output_url.mp4") + else: + print(f"Job failed: {result.error}") if __name__ == "__main__": diff --git a/examples/process_video.py b/examples/process_video.py index fbe9670..ec60dbc 100644 --- a/examples/process_video.py +++ b/examples/process_video.py @@ -1,3 +1,8 @@ +""" +Video generation example using the Queue API. +Video models only support async queue processing. +""" + import asyncio import os from decart import DecartClient, models @@ -5,36 +10,45 @@ async def main() -> None: async with DecartClient(api_key=os.getenv("DECART_API_KEY", "your-api-key-here")) as client: + # Text-to-video generation print("Generating video from text...") - result = await client.process( + result = await client.queue.submit_and_poll( { "model": models.video("lucy-pro-t2v"), "prompt": "A serene lake at sunset with mountains in the background", "seed": 42, + "on_status_change": lambda job: print(f" Status: {job.status}"), } ) - with open("output_t2v.mp4", "wb") as f: - f.write(result) - - print("Video saved to output_t2v.mp4") + if result.status == "completed": + with open("output_t2v.mp4", "wb") as f: + f.write(result.data) + print("Video saved to output_t2v.mp4") + else: + print(f"Text-to-video failed: {result.error}") + return - print("Transforming video...") + # Video-to-video transformation + print("\nTransforming video...") with open("output_t2v.mp4", "rb") as video_file: - result = await client.process( + result = await client.queue.submit_and_poll( { "model": models.video("lucy-pro-v2v"), "prompt": "Anime style with vibrant colors", "data": video_file, "enhance_prompt": True, "num_inference_steps": 50, + "on_status_change": lambda job: print(f" Status: {job.status}"), } ) - with open("output_v2v.mp4", "wb") as f: - f.write(result) - - print("Video saved to output_v2v.mp4") + if result.status == "completed": + with open("output_v2v.mp4", "wb") as f: + f.write(result.data) + print("Video saved to output_v2v.mp4") + else: + print(f"Video-to-video failed: {result.error}") if __name__ == "__main__": diff --git a/tests/test_process.py b/tests/test_process.py index 7eb9ed0..e479378 100644 --- a/tests/test_process.py +++ b/tests/test_process.py @@ -1,18 +1,24 @@ +""" +Tests for the process API. +Note: process() only supports image models (t2i, i2i). +Video models must use the queue API. +""" + import pytest import asyncio from unittest.mock import AsyncMock, patch, MagicMock from decart import DecartClient, models, DecartSDKError -from decart.types import MotionTrajectoryInput @pytest.mark.asyncio -async def test_process_text_to_video() -> None: +async def test_process_text_to_image() -> None: + """Test text-to-image generation with process API.""" client = DecartClient(api_key="test-key") with patch("aiohttp.ClientSession") as mock_session_cls: mock_response = MagicMock() mock_response.ok = True - mock_response.read = AsyncMock(return_value=b"fake video data") + mock_response.read = AsyncMock(return_value=b"fake image data") mock_session = MagicMock() mock_session.__aenter__ = AsyncMock(return_value=mock_session) @@ -25,46 +31,23 @@ async def test_process_text_to_video() -> None: result = await client.process( { - "model": models.video("lucy-pro-t2v"), + "model": models.image("lucy-pro-t2i"), "prompt": "A cat walking", } ) - assert result == b"fake video data" + assert result == b"fake image data" @pytest.mark.asyncio -async def test_process_missing_model() -> None: - client = DecartClient(api_key="test-key") - - with pytest.raises(DecartSDKError): - await client.process( - { - "prompt": "A cat walking", - } - ) - - -@pytest.mark.asyncio -async def test_process_missing_required_field() -> None: - client = DecartClient(api_key="test-key") - - with pytest.raises(DecartSDKError): - await client.process( - { - "model": models.video("lucy-pro-i2v"), - } - ) - - -@pytest.mark.asyncio -async def test_process_video_to_video() -> None: +async def test_process_image_to_image() -> None: + """Test image-to-image transformation with process API.""" client = DecartClient(api_key="test-key") with patch("aiohttp.ClientSession") as mock_session_cls: mock_response = MagicMock() mock_response.ok = True - mock_response.read = AsyncMock(return_value=b"fake video data") + mock_response.read = AsyncMock(return_value=b"fake image data") mock_session = MagicMock() mock_session.__aenter__ = AsyncMock(return_value=mock_session) @@ -77,121 +60,78 @@ async def test_process_video_to_video() -> None: result = await client.process( { - "model": models.video("lucy-pro-v2v"), - "prompt": "Anime style", - "data": b"fake input video", + "model": models.image("lucy-pro-i2i"), + "prompt": "Oil painting style", + "data": b"fake input image", "enhance_prompt": True, } ) - assert result == b"fake video data" + assert result == b"fake image data" @pytest.mark.asyncio -async def test_process_video_to_video_fast() -> None: +async def test_process_rejects_video_models() -> None: + """Test that process() rejects video models with helpful error message.""" client = DecartClient(api_key="test-key") - with patch("aiohttp.ClientSession") as mock_session_cls: - mock_response = MagicMock() - mock_response.ok = True - mock_response.read = AsyncMock(return_value=b"fake video data") - - 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 - - result = await client.process( + with pytest.raises(DecartSDKError) as exc_info: + await client.process( { - "model": models.video("lucy-fast-v2v"), - "prompt": "Change the car to a motorcycle", - "data": b"fake input video", - "resolution": "480p", - "enhance_prompt": True, - "num_inference_steps": 50, - "seed": 42, + "model": models.video("lucy-pro-t2v"), + "prompt": "A cat walking", } ) - assert result == b"fake video data" + assert "not supported by process()" in str(exc_info.value) + assert "queue" in str(exc_info.value).lower() @pytest.mark.asyncio -async def test_process_max_prompt_length() -> None: +async def test_process_missing_model() -> None: client = DecartClient(api_key="test-key") - prompt = "a" * 1001 - with pytest.raises(DecartSDKError) as exception: + + with pytest.raises(DecartSDKError): await client.process( { - "model": models.image("lucy-pro-t2i"), - "prompt": prompt, + "prompt": "A cat walking", } ) - assert "Invalid inputs for lucy-pro-t2i: 1 validation error for TextToImageInput" in str( - exception - ) @pytest.mark.asyncio -async def test_process_image_to_motion_video() -> None: +async def test_process_missing_required_field() -> None: + """Test that missing required fields raise an error.""" client = DecartClient(api_key="test-key") - with patch("aiohttp.ClientSession") as mock_session_cls: - mock_response = MagicMock() - mock_response.ok = True - mock_response.read = AsyncMock(return_value=b"fake video data") - - 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 - - result = await client.process( + with pytest.raises(DecartSDKError): + await client.process( { - "model": models.video("lucy-motion"), - "data": b"fake input image", - "trajectory": [ - MotionTrajectoryInput(frame=0, x=0, y=0), - MotionTrajectoryInput(frame=1, x=0.5, y=0.5), - MotionTrajectoryInput(frame=2, x=1, y=1), - MotionTrajectoryInput(frame=3, x=1.5, y=1.5), - MotionTrajectoryInput(frame=4, x=2, y=2), - ], + "model": models.image("lucy-pro-i2i"), + # Missing 'data' field which is required for i2i } ) - assert result == b"fake video data" - @pytest.mark.asyncio -async def test_process_image_to_motion_video_invalid_trajectory() -> None: +async def test_process_max_prompt_length() -> None: client = DecartClient(api_key="test-key") - + prompt = "a" * 1001 with pytest.raises(DecartSDKError) as exception: await client.process( { - "model": models.video("lucy-motion"), - "data": b"fake input image", - "trajectory": [ - MotionTrajectoryInput(frame=0, x=0, y=0), - ], + "model": models.image("lucy-pro-t2i"), + "prompt": prompt, } ) - assert "Invalid inputs for lucy-motion: 1 validation error for ImageToMotionVideoInput" in str( + assert "Invalid inputs for lucy-pro-t2i: 1 validation error for TextToImageInput" in str( exception ) @pytest.mark.asyncio async def test_process_with_cancellation() -> None: + """Test that process() respects cancellation token.""" client = DecartClient(api_key="test-key") cancel_token = asyncio.Event() @@ -200,8 +140,8 @@ async def test_process_with_cancellation() -> None: with pytest.raises(asyncio.CancelledError): await client.process( { - "model": models.video("lucy-pro-t2v"), - "prompt": "A video that will be cancelled", + "model": models.image("lucy-pro-t2i"), + "prompt": "An image that will be cancelled", "cancel_token": cancel_token, } ) @@ -215,7 +155,7 @@ async def test_process_includes_user_agent_header() -> None: with patch("aiohttp.ClientSession") as mock_session_cls: mock_response = MagicMock() mock_response.ok = True - mock_response.read = AsyncMock(return_value=b"fake video data") + mock_response.read = AsyncMock(return_value=b"fake image data") mock_session = MagicMock() mock_session.__aenter__ = AsyncMock(return_value=mock_session) @@ -228,7 +168,7 @@ async def test_process_includes_user_agent_header() -> None: await client.process( { - "model": models.video("lucy-pro-t2v"), + "model": models.image("lucy-pro-t2i"), "prompt": "Test prompt", } ) @@ -251,7 +191,7 @@ async def test_process_includes_integration_in_user_agent() -> None: with patch("aiohttp.ClientSession") as mock_session_cls: mock_response = MagicMock() mock_response.ok = True - mock_response.read = AsyncMock(return_value=b"fake video data") + mock_response.read = AsyncMock(return_value=b"fake image data") mock_session = MagicMock() mock_session.__aenter__ = AsyncMock(return_value=mock_session) @@ -264,7 +204,7 @@ async def test_process_includes_integration_in_user_agent() -> None: await client.process( { - "model": models.video("lucy-pro-t2v"), + "model": models.image("lucy-pro-t2i"), "prompt": "Test prompt", } ) diff --git a/tests/test_queue.py b/tests/test_queue.py new file mode 100644 index 0000000..ad18fc5 --- /dev/null +++ b/tests/test_queue.py @@ -0,0 +1,263 @@ +""" +Tests for the queue API. +Note: queue API only supports video models. +Image models must use the process API. +""" + +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from decart import DecartClient, models, DecartSDKError + + +@pytest.mark.asyncio +async def test_queue_submit_text_to_video() -> None: + """Test text-to-video submission with queue API.""" + client = DecartClient(api_key="test-key") + + with patch("decart.queue.client.submit_job") as mock_submit: + mock_submit.return_value = MagicMock(job_id="job-123", status="pending") + + job = await client.queue.submit( + { + "model": models.video("lucy-pro-t2v"), + "prompt": "A cat walking in a park", + "seed": 42, + } + ) + + assert job.job_id == "job-123" + assert job.status == "pending" + mock_submit.assert_called_once() + + +@pytest.mark.asyncio +async def test_queue_submit_video_to_video() -> None: + """Test video-to-video submission with queue API.""" + client = DecartClient(api_key="test-key") + + with patch("decart.queue.client.submit_job") as mock_submit: + mock_submit.return_value = MagicMock(job_id="job-456", status="pending") + + job = await client.queue.submit( + { + "model": models.video("lucy-pro-v2v"), + "prompt": "Anime style", + "data": b"fake video data", + "enhance_prompt": True, + } + ) + + assert job.job_id == "job-456" + assert job.status == "pending" + + +@pytest.mark.asyncio +async def test_queue_rejects_image_models() -> None: + """Test that queue API rejects image models with helpful error message.""" + client = DecartClient(api_key="test-key") + + with pytest.raises(DecartSDKError) as exc_info: + await client.queue.submit( + { + "model": models.image("lucy-pro-t2i"), + "prompt": "A beautiful sunset", + } + ) + + assert "not supported by queue" in str(exc_info.value) + assert "process" in str(exc_info.value).lower() + + +@pytest.mark.asyncio +async def test_queue_missing_model() -> None: + """Test that missing model raises an error.""" + client = DecartClient(api_key="test-key") + + with pytest.raises(DecartSDKError): + await client.queue.submit( + { + "prompt": "A cat walking", + } + ) + + +@pytest.mark.asyncio +async def test_queue_status() -> None: + """Test getting job status.""" + client = DecartClient(api_key="test-key") + + with patch("decart.queue.client.get_job_status") as mock_status: + mock_status.return_value = MagicMock(job_id="job-123", status="processing") + + status = await client.queue.status("job-123") + + assert status.job_id == "job-123" + assert status.status == "processing" + mock_status.assert_called_once() + + +@pytest.mark.asyncio +async def test_queue_result() -> None: + """Test getting job result.""" + client = DecartClient(api_key="test-key") + + with patch("decart.queue.client.get_job_content") as mock_content: + mock_content.return_value = b"fake video content" + + result = await client.queue.result("job-123") + + assert result == b"fake video content" + mock_content.assert_called_once() + + +@pytest.mark.asyncio +async def test_queue_submit_and_poll_completed() -> None: + """Test submit_and_poll returns completed result.""" + client = DecartClient(api_key="test-key") + + with ( + patch("decart.queue.client.submit_job") as mock_submit, + patch("decart.queue.client.get_job_status") as mock_status, + patch("decart.queue.client.get_job_content") as mock_content, + patch("asyncio.sleep", new_callable=AsyncMock), + ): + + mock_submit.return_value = MagicMock(job_id="job-123", status="pending") + mock_status.return_value = MagicMock(job_id="job-123", status="completed") + mock_content.return_value = b"fake video data" + + result = await client.queue.submit_and_poll( + { + "model": models.video("lucy-pro-t2v"), + "prompt": "A serene lake", + } + ) + + assert result.status == "completed" + assert result.data == b"fake video data" + + +@pytest.mark.asyncio +async def test_queue_submit_and_poll_failed() -> None: + """Test submit_and_poll returns failed result.""" + client = DecartClient(api_key="test-key") + + with ( + patch("decart.queue.client.submit_job") as mock_submit, + patch("decart.queue.client.get_job_status") as mock_status, + patch("asyncio.sleep", new_callable=AsyncMock), + ): + + mock_submit.return_value = MagicMock(job_id="job-123", status="pending") + mock_status.return_value = MagicMock(job_id="job-123", status="failed") + + result = await client.queue.submit_and_poll( + { + "model": models.video("lucy-pro-t2v"), + "prompt": "A serene lake", + } + ) + + assert result.status == "failed" + assert result.error == "Job failed" + + +@pytest.mark.asyncio +async def test_queue_submit_and_poll_with_callback() -> None: + """Test submit_and_poll calls on_status_change callback.""" + client = DecartClient(api_key="test-key") + status_changes: list[str] = [] + + def on_status_change(job): + status_changes.append(job.status) + + with ( + patch("decart.queue.client.submit_job") as mock_submit, + patch("decart.queue.client.get_job_status") as mock_status, + patch("decart.queue.client.get_job_content") as mock_content, + patch("asyncio.sleep", new_callable=AsyncMock), + ): + + mock_submit.return_value = MagicMock(job_id="job-123", status="pending") + mock_status.side_effect = [ + MagicMock(job_id="job-123", status="processing"), + MagicMock(job_id="job-123", status="completed"), + ] + mock_content.return_value = b"fake video data" + + await client.queue.submit_and_poll( + { + "model": models.video("lucy-pro-t2v"), + "prompt": "A serene lake", + "on_status_change": on_status_change, + } + ) + + assert "pending" in status_changes + assert "processing" in status_changes + assert "completed" in status_changes + + +@pytest.mark.asyncio +async def test_queue_submit_missing_required_field() -> None: + """Test that missing required fields raise an error.""" + client = DecartClient(api_key="test-key") + + with pytest.raises(DecartSDKError): + await client.queue.submit( + { + "model": models.video("lucy-pro-v2v"), + # Missing 'prompt' and 'data' which are required for v2v + } + ) + + +@pytest.mark.asyncio +async def test_queue_submit_max_prompt_length() -> None: + """Test that prompt length validation works.""" + client = DecartClient(api_key="test-key") + prompt = "a" * 1001 + + with pytest.raises(DecartSDKError) as exception: + await client.queue.submit( + { + "model": models.video("lucy-pro-t2v"), + "prompt": prompt, + } + ) + + assert "Invalid inputs for lucy-pro-t2v" in str(exception) + + +@pytest.mark.asyncio +async def test_queue_includes_user_agent_header() -> None: + """Test that User-Agent header is included in queue requests.""" + client = DecartClient(api_key="test-key") + + 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": models.video("lucy-pro-t2v"), + "prompt": "Test prompt", + } + ) + + mock_session.post.assert_called_once() + call_kwargs = mock_session.post.call_args[1] + headers = call_kwargs.get("headers", {}) + + assert "User-Agent" in headers + assert headers["User-Agent"].startswith("decart-python-sdk/")