diff --git a/packages/capabilities/image-generation/pyproject.toml b/packages/capabilities/image-generation/pyproject.toml index dfb0c5e8..8bd02504 100644 --- a/packages/capabilities/image-generation/pyproject.toml +++ b/packages/capabilities/image-generation/pyproject.toml @@ -17,7 +17,7 @@ classifiers = [ "Topic :: Scientific/Engineering :: Artificial Intelligence", "Typing :: Typed", ] -keywords = ["ai", "image-generation", "dall-e", "imagen", "openai", "google", "bytedance"] +keywords = ["ai", "image-generation", "dall-e", "imagen", "openai", "google", "byteplus"] [project.urls] Homepage = "https://withceleste.ai" @@ -28,6 +28,7 @@ Issues = "https://github.com/withceleste/celeste-python/issues" [tool.uv.sources] celeste-ai = { workspace = true } celeste-bfl = { workspace = true } +celeste-byteplus = { workspace = true } celeste-google = { workspace = true } celeste-openai = { workspace = true } diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/models.py b/packages/capabilities/image-generation/src/celeste_image_generation/models.py index f25292e5..ba020565 100644 --- a/packages/capabilities/image-generation/src/celeste_image_generation/models.py +++ b/packages/capabilities/image-generation/src/celeste_image_generation/models.py @@ -2,15 +2,15 @@ from celeste import Model from celeste_image_generation.providers.bfl.models import MODELS as BFL_MODELS -from celeste_image_generation.providers.bytedance.models import ( - MODELS as BYTEDANCE_MODELS, +from celeste_image_generation.providers.byteplus.models import ( + MODELS as BYTEPLUS_MODELS, ) from celeste_image_generation.providers.google.models import MODELS as GOOGLE_MODELS from celeste_image_generation.providers.openai.models import MODELS as OPENAI_MODELS MODELS: list[Model] = [ *BFL_MODELS, - *BYTEDANCE_MODELS, + *BYTEPLUS_MODELS, *GOOGLE_MODELS, *OPENAI_MODELS, ] diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/__init__.py b/packages/capabilities/image-generation/src/celeste_image_generation/providers/__init__.py index fe9a8499..7c6549d7 100644 --- a/packages/capabilities/image-generation/src/celeste_image_generation/providers/__init__.py +++ b/packages/capabilities/image-generation/src/celeste_image_generation/providers/__init__.py @@ -11,8 +11,8 @@ def _get_providers() -> list[tuple[Provider, type[Client]]]: from celeste_image_generation.providers.bfl.client import ( BFLImageGenerationClient, ) - from celeste_image_generation.providers.bytedance.client import ( - ByteDanceImageGenerationClient, + from celeste_image_generation.providers.byteplus.client import ( + BytePlusImageGenerationClient, ) from celeste_image_generation.providers.google.client import ( GoogleImageGenerationClient, @@ -23,7 +23,7 @@ def _get_providers() -> list[tuple[Provider, type[Client]]]: return [ (Provider.BFL, BFLImageGenerationClient), - (Provider.BYTEDANCE, ByteDanceImageGenerationClient), + (Provider.BYTEPLUS, BytePlusImageGenerationClient), (Provider.GOOGLE, GoogleImageGenerationClient), (Provider.OPENAI, OpenAIImageGenerationClient), ] diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/bytedance/__init__.py b/packages/capabilities/image-generation/src/celeste_image_generation/providers/bytedance/__init__.py deleted file mode 100644 index 623b1947..00000000 --- a/packages/capabilities/image-generation/src/celeste_image_generation/providers/bytedance/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""ByteDance provider for image generation.""" - -from .client import ByteDanceImageGenerationClient -from .models import MODELS - -__all__ = ["MODELS", "ByteDanceImageGenerationClient"] diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/bytedance/client.py b/packages/capabilities/image-generation/src/celeste_image_generation/providers/bytedance/client.py deleted file mode 100644 index f4cc752e..00000000 --- a/packages/capabilities/image-generation/src/celeste_image_generation/providers/bytedance/client.py +++ /dev/null @@ -1,156 +0,0 @@ -"""ByteDance client implementation for image generation.""" - -import base64 -from collections.abc import AsyncIterator -from typing import Any, Unpack - -import httpx - -from celeste.artifacts import ImageArtifact -from celeste.exceptions import ConstraintViolationError, ValidationError -from celeste.mime_types import ApplicationMimeType, ImageMimeType -from celeste.parameters import ParameterMapper -from celeste_image_generation.client import ImageGenerationClient -from celeste_image_generation.io import ( - ImageGenerationFinishReason, - ImageGenerationInput, - ImageGenerationUsage, -) -from celeste_image_generation.parameters import ImageGenerationParameters - -from . import config -from .parameters import BYTEDANCE_PARAMETER_MAPPERS -from .streaming import ByteDanceImageGenerationStream - - -class ByteDanceImageGenerationClient(ImageGenerationClient): - """ByteDance client for image generation.""" - - @classmethod - def parameter_mappers(cls) -> list[ParameterMapper]: - return BYTEDANCE_PARAMETER_MAPPERS - - def _init_request(self, inputs: ImageGenerationInput) -> dict[str, Any]: - """Initialize request from ByteDance API structure.""" - return { - "model": self.model.id, - "prompt": inputs.prompt, - "response_format": "url", - } - - def _parse_usage(self, response_data: dict[str, Any]) -> ImageGenerationUsage: - """Parse usage from response.""" - usage_data = response_data.get("usage", {}) - - return ImageGenerationUsage( - total_tokens=usage_data.get("total_tokens"), - output_tokens=usage_data.get("output_tokens"), - generated_images=usage_data.get("generated_images"), - ) - - def _parse_content( - self, - response_data: dict[str, Any], - **parameters: Unpack[ImageGenerationParameters], - ) -> ImageArtifact: - """Parse content from response.""" - images = response_data.get("images", []) - if images and images[0].get("url"): - return ImageArtifact( - url=images[0]["url"], - mime_type=ImageMimeType.PNG, - ) - - data = response_data.get("data", []) - if data: - if data[0].get("url"): - return ImageArtifact( - url=data[0]["url"], - mime_type=ImageMimeType.PNG, - ) - if data[0].get("b64_json"): - image_bytes = base64.b64decode(data[0]["b64_json"]) - return ImageArtifact( - data=image_bytes, - mime_type=ImageMimeType.PNG, - ) - - msg = "No image content found in ByteDance response" - raise ValidationError(msg) - - def _parse_finish_reason( - self, response_data: dict[str, Any] - ) -> ImageGenerationFinishReason | None: - """Parse finish reason from response. - - ByteDance doesn't provide finish reasons for image generation. - """ - return None - - def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: - """Build metadata dictionary from response data. - - Extracts seed if present. - """ - metadata = super()._build_metadata(response_data) - # Add provider-specific parsed fields - seed = response_data.get("seed") - if seed is not None: - metadata["seed"] = seed - return metadata - - async def _make_request( - self, - request_body: dict[str, Any], - **parameters: Unpack[ImageGenerationParameters], - ) -> httpx.Response: - """Make HTTP request(s) and return response object.""" - # Validate mutually exclusive parameters - if parameters.get("aspect_ratio") and parameters.get("quality"): - msg = ( - "Cannot use both 'aspect_ratio' and 'quality' parameters. " - "ByteDance's 'size' field supports two methods that cannot be combined:\n" - " • quality: Resolution class ('1K', '2K', '4K')\n" - " • aspect_ratio: Exact dimensions (e.g., '2048x2048', '3840x2160')\n" - "Use one or the other, not both." - ) - raise ConstraintViolationError(msg) - - request_body["stream"] = False - - headers = { - **self.auth.get_headers(), - "Content-Type": ApplicationMimeType.JSON, - } - - return await self.http_client.post( - f"{config.BASE_URL}{config.ENDPOINT}", - headers=headers, - json_body=request_body, - ) - - def _stream_class(self) -> type[ByteDanceImageGenerationStream]: - """Return the Stream class for this client.""" - return ByteDanceImageGenerationStream - - def _make_stream_request( - self, - request_body: dict[str, Any], - **parameters: Unpack[ImageGenerationParameters], - ) -> AsyncIterator[dict[str, Any]]: - """Make HTTP streaming request and return async iterator of events.""" - request_body["stream"] = True - - headers = { - **self.auth.get_headers(), - "Content-Type": ApplicationMimeType.JSON, - } - - return self.http_client.stream_post( - f"{config.BASE_URL}{config.ENDPOINT}", - headers=headers, - json_body=request_body, - ) - - -__all__ = ["ByteDanceImageGenerationClient"] diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/bytedance/config.py b/packages/capabilities/image-generation/src/celeste_image_generation/providers/bytedance/config.py deleted file mode 100644 index f7a40b82..00000000 --- a/packages/capabilities/image-generation/src/celeste_image_generation/providers/bytedance/config.py +++ /dev/null @@ -1,10 +0,0 @@ -"""ByteDance provider configuration for image generation.""" - -# HTTP Configuration -BASE_URL = "https://ark.ap-southeast.bytepluses.com/api/v3" -ENDPOINT = "/images/generations" -STREAM_ENDPOINT = "/responses" - -# Authentication -AUTH_HEADER_NAME = "Authorization" -AUTH_HEADER_PREFIX = "Bearer " diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/bytedance/parameters.py b/packages/capabilities/image-generation/src/celeste_image_generation/providers/bytedance/parameters.py deleted file mode 100644 index 310e24ce..00000000 --- a/packages/capabilities/image-generation/src/celeste_image_generation/providers/bytedance/parameters.py +++ /dev/null @@ -1,101 +0,0 @@ -"""ByteDance parameter mappers for image generation.""" - -from typing import Any - -from celeste import Model -from celeste.parameters import ParameterMapper -from celeste_image_generation.parameters import ImageGenerationParameter - - -class AspectRatioMapper(ParameterMapper): - """Map aspect_ratio to dimension string. - - Accepts freeform dimension strings (e.g., "2048x2048", "3840x2160") - validated by Dimensions constraint against ByteDance's pixel and aspect ratio bounds. - """ - - name = ImageGenerationParameter.ASPECT_RATIO - - def map( - self, - request: dict[str, Any], - value: object, - model: Model, - ) -> dict[str, Any]: - """Transform aspect_ratio into provider request. - - The Dimensions constraint validates: - - Format: "WIDTHxHEIGHT" - - Total pixels: [921,600, 16,777,216] - - Aspect ratio: [1/16, 16] - """ - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - - # Transform to provider-specific request format (top-level field) - request["size"] = validated_value - return request - - -class QualityMapper(ParameterMapper): - """Map quality parameter with validation.""" - - name = ImageGenerationParameter.QUALITY - - def map( - self, - request: dict[str, Any], - value: object, - model: Model, - ) -> dict[str, Any]: - """Transform quality into provider request. - - Maps quality levels ("1K", "2K", "4K") to ByteDance's size parameter. - Skips if size is already set by aspect_ratio (conflict resolution). - """ - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - - # Skip if size already set by aspect_ratio parameter (conflict resolution) - if "size" in request: - return request - - # Transform to provider-specific request format (top-level field) - request["size"] = validated_value - return request - - -class WatermarkMapper(ParameterMapper): - """Map watermark parameter with validation.""" - - name = ImageGenerationParameter.WATERMARK - - def map( - self, - request: dict[str, Any], - value: object, - model: Model, - ) -> dict[str, Any]: - """Transform watermark into provider request. - - Adds "AI generated" watermark to bottom-right corner when true. - Default is true if omitted. - """ - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - - # Transform to provider-specific request format (top-level field) - request["watermark"] = validated_value - return request - - -BYTEDANCE_PARAMETER_MAPPERS: list[ParameterMapper] = [ - AspectRatioMapper(), - QualityMapper(), - WatermarkMapper(), -] - -__all__ = ["BYTEDANCE_PARAMETER_MAPPERS"] diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/bytedance/README.md b/packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/README.md similarity index 70% rename from packages/capabilities/image-generation/src/celeste_image_generation/providers/bytedance/README.md rename to packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/README.md index ffbcc751..211d1323 100644 --- a/packages/capabilities/image-generation/src/celeste_image_generation/providers/bytedance/README.md +++ b/packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/README.md @@ -1,13 +1,13 @@ -# ByteDance Image Generation Provider +# BytePlus Image Generation Provider ## Credentials -**Environment variable:** `BYTEDANCE_API_KEY` +**Environment variable:** `BYTEPLUS_API_KEY` **Setup:** 1. Register at [console.byteplus.com](https://console.byteplus.com) 2. Activate model in ModelArk section 3. Generate API key with image generation permissions -4. Set environment variable: `export BYTEDANCE_API_KEY="your-key"` +4. Set environment variable: `export BYTEPLUS_API_KEY="your-key"` **Note:** Models must be activated in BytePlus console before use. If you get a 404 error, activate the model or use an Endpoint ID (`ep-xxx`) instead of Model ID. diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/__init__.py b/packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/__init__.py new file mode 100644 index 00000000..213be409 --- /dev/null +++ b/packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/__init__.py @@ -0,0 +1,6 @@ +"""BytePlus provider for image generation.""" + +from .client import BytePlusImageGenerationClient +from .models import MODELS + +__all__ = ["MODELS", "BytePlusImageGenerationClient"] diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/client.py b/packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/client.py new file mode 100644 index 00000000..2a887df6 --- /dev/null +++ b/packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/client.py @@ -0,0 +1,107 @@ +"""BytePlus client implementation for image generation.""" + +import base64 +from typing import Any, Unpack + +from celeste_byteplus.images.client import BytePlusImagesClient + +from celeste.artifacts import ImageArtifact +from celeste.exceptions import ConstraintViolationError, ValidationError +from celeste.mime_types import ImageMimeType +from celeste.parameters import ParameterMapper +from celeste_image_generation.client import ImageGenerationClient +from celeste_image_generation.io import ( + ImageGenerationFinishReason, + ImageGenerationInput, + ImageGenerationUsage, +) +from celeste_image_generation.parameters import ImageGenerationParameters + +from .parameters import BYTEPLUS_PARAMETER_MAPPERS +from .streaming import BytePlusImageGenerationStream + + +class BytePlusImageGenerationClient(BytePlusImagesClient, ImageGenerationClient): + """BytePlus client for image generation.""" + + @classmethod + def parameter_mappers(cls) -> list[ParameterMapper]: + return BYTEPLUS_PARAMETER_MAPPERS + + def _init_request(self, inputs: ImageGenerationInput) -> dict[str, Any]: + """Initialize request from BytePlus API structure.""" + return { + "model": self.model.id, + "prompt": inputs.prompt, + "response_format": "url", + } + + def _parse_usage(self, response_data: dict[str, Any]) -> ImageGenerationUsage: + """Parse usage from response.""" + usage = super()._parse_usage(response_data) + return ImageGenerationUsage(**usage) + + def _parse_content( + self, + response_data: dict[str, Any], + **parameters: Unpack[ImageGenerationParameters], + ) -> ImageArtifact: + """Parse content from response.""" + images = response_data.get("images", []) + if images and images[0].get("url"): + return ImageArtifact( + url=images[0]["url"], + mime_type=ImageMimeType.PNG, + ) + + data = response_data.get("data", []) + if data: + if data[0].get("url"): + return ImageArtifact( + url=data[0]["url"], + mime_type=ImageMimeType.PNG, + ) + if data[0].get("b64_json"): + image_bytes = base64.b64decode(data[0]["b64_json"]) + return ImageArtifact( + data=image_bytes, + mime_type=ImageMimeType.PNG, + ) + + msg = "No image content found in BytePlus response" + raise ValidationError(msg) + + def _parse_finish_reason( + self, response_data: dict[str, Any] + ) -> ImageGenerationFinishReason: + """Parse finish reason from response. + + BytePlus doesn't provide finish reasons for image generation. + """ + return ImageGenerationFinishReason(reason=None) + + async def _make_request( + self, + request_body: dict[str, Any], + **parameters: Unpack[ImageGenerationParameters], + ) -> Any: + """Make HTTP request with parameter validation.""" + # Validate mutually exclusive parameters + if parameters.get("aspect_ratio") and parameters.get("quality"): + msg = ( + "Cannot use both 'aspect_ratio' and 'quality' parameters. " + "BytePlus's 'size' field supports two methods that cannot be combined:\n" + " • quality: Resolution class ('1K', '2K', '4K')\n" + " • aspect_ratio: Exact dimensions (e.g., '2048x2048', '3840x2160')\n" + "Use one or the other, not both." + ) + raise ConstraintViolationError(msg) + + return await super()._make_request(request_body, **parameters) + + def _stream_class(self) -> type[BytePlusImageGenerationStream]: + """Return the Stream class for this client.""" + return BytePlusImageGenerationStream + + +__all__ = ["BytePlusImageGenerationClient"] diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/bytedance/models.py b/packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/models.py similarity index 94% rename from packages/capabilities/image-generation/src/celeste_image_generation/providers/bytedance/models.py rename to packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/models.py index 0cde1a95..71e273c0 100644 --- a/packages/capabilities/image-generation/src/celeste_image_generation/providers/bytedance/models.py +++ b/packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/models.py @@ -1,4 +1,4 @@ -"""ByteDance models for image generation.""" +"""BytePlus models for image generation.""" from celeste import Model, Provider from celeste.constraints import Bool, Choice @@ -8,7 +8,7 @@ MODELS: list[Model] = [ Model( id="seedream-4-0-250828", - provider=Provider.BYTEDANCE, + provider=Provider.BYTEPLUS, display_name="Seedream 4.0", parameter_constraints={ ImageGenerationParameter.ASPECT_RATIO: Dimensions( @@ -33,7 +33,7 @@ ), Model( id="seedream-4-5-251128", - provider=Provider.BYTEDANCE, + provider=Provider.BYTEPLUS, display_name="Seedream 4.5", parameter_constraints={ ImageGenerationParameter.ASPECT_RATIO: Dimensions( diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/parameters.py b/packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/parameters.py new file mode 100644 index 00000000..8bc19da7 --- /dev/null +++ b/packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/parameters.py @@ -0,0 +1,57 @@ +"""BytePlus Images parameter mappers for image generation.""" + +from typing import Any + +from celeste_byteplus.images.parameters import ( + SizeMapper as _SizeMapper, +) +from celeste_byteplus.images.parameters import ( + WatermarkMapper as _WatermarkMapper, +) + +from celeste.models import Model +from celeste.parameters import ParameterMapper +from celeste_image_generation.parameters import ImageGenerationParameter + + +class AspectRatioMapper(_SizeMapper): + name = ImageGenerationParameter.ASPECT_RATIO + + +class QualityMapper(_SizeMapper): + """Map quality to BytePlus size field with conflict resolution.""" + + name = ImageGenerationParameter.QUALITY + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform quality into provider request. + + Skips if size is already set by aspect_ratio (conflict resolution). + """ + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + # Skip if size already set by aspect_ratio parameter + if "size" in request: + return request + + return super().map(request, validated_value, model) + + +class WatermarkMapper(_WatermarkMapper): + name = ImageGenerationParameter.WATERMARK + + +BYTEPLUS_PARAMETER_MAPPERS: list[ParameterMapper] = [ + AspectRatioMapper(), + QualityMapper(), + WatermarkMapper(), +] + +__all__ = ["BYTEPLUS_PARAMETER_MAPPERS"] diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/bytedance/streaming.py b/packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/streaming.py similarity index 90% rename from packages/capabilities/image-generation/src/celeste_image_generation/providers/bytedance/streaming.py rename to packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/streaming.py index 28caf487..3c06cea3 100644 --- a/packages/capabilities/image-generation/src/celeste_image_generation/providers/bytedance/streaming.py +++ b/packages/capabilities/image-generation/src/celeste_image_generation/providers/byteplus/streaming.py @@ -1,4 +1,4 @@ -"""ByteDance streaming for image generation.""" +"""BytePlus streaming for image generation.""" import logging from collections.abc import AsyncIterator @@ -12,8 +12,8 @@ logger = logging.getLogger(__name__) -class ByteDanceImageGenerationStream(ImageGenerationStream): - """ByteDance streaming for image generation.""" +class BytePlusImageGenerationStream(ImageGenerationStream): + """BytePlus streaming for image generation.""" def __init__(self, sse_iterator: AsyncIterator[dict[str, Any]]) -> None: """Initialize stream and track completed event usage.""" @@ -61,4 +61,4 @@ def _parse_usage(self, chunks: list[ImageGenerationChunk]) -> ImageGenerationUsa return ImageGenerationUsage() -__all__ = ["ByteDanceImageGenerationStream"] +__all__ = ["BytePlusImageGenerationStream"] diff --git a/packages/capabilities/image-generation/tests/integration_tests/test_image_generation/test_generate.py b/packages/capabilities/image-generation/tests/integration_tests/test_image_generation/test_generate.py index ba3575c8..f869979a 100644 --- a/packages/capabilities/image-generation/tests/integration_tests/test_image_generation/test_generate.py +++ b/packages/capabilities/image-generation/tests/integration_tests/test_image_generation/test_generate.py @@ -11,7 +11,7 @@ (Provider.BFL, "flux-2-pro", {"aspect_ratio": "1024x1024"}), (Provider.OPENAI, "dall-e-2", {}), (Provider.GOOGLE, "imagen-4.0-fast-generate-001", {"num_images": 1}), - (Provider.BYTEDANCE, "seedream-4-0-250828", {}), + (Provider.BYTEPLUS, "seedream-4-0-250828", {}), ], ) @pytest.mark.integration @@ -42,7 +42,6 @@ async def test_generate(provider: Provider, model: str, parameters: dict) -> Non # Act response = await client.generate( prompt=prompt, - model=model, **parameters, ) diff --git a/packages/capabilities/video-generation/pyproject.toml b/packages/capabilities/video-generation/pyproject.toml index 63374299..bee45e6e 100644 --- a/packages/capabilities/video-generation/pyproject.toml +++ b/packages/capabilities/video-generation/pyproject.toml @@ -30,6 +30,7 @@ Issues = "https://github.com/withceleste/celeste-python/issues" [tool.uv.sources] celeste-ai = { workspace = true } +celeste-byteplus = { workspace = true } celeste-google = { workspace = true } celeste-openai = { workspace = true } diff --git a/packages/capabilities/video-generation/src/celeste_video_generation/models.py b/packages/capabilities/video-generation/src/celeste_video_generation/models.py index c3c6a248..07d29ee1 100644 --- a/packages/capabilities/video-generation/src/celeste_video_generation/models.py +++ b/packages/capabilities/video-generation/src/celeste_video_generation/models.py @@ -1,14 +1,14 @@ """Model definitions for video generation.""" from celeste import Model -from celeste_video_generation.providers.bytedance.models import ( - MODELS as BYTEDANCE_MODELS, +from celeste_video_generation.providers.byteplus.models import ( + MODELS as BYTEPLUS_MODELS, ) from celeste_video_generation.providers.google.models import MODELS as GOOGLE_MODELS from celeste_video_generation.providers.openai.models import MODELS as OPENAI_MODELS MODELS: list[Model] = [ - *BYTEDANCE_MODELS, + *BYTEPLUS_MODELS, *GOOGLE_MODELS, *OPENAI_MODELS, ] diff --git a/packages/capabilities/video-generation/src/celeste_video_generation/providers/__init__.py b/packages/capabilities/video-generation/src/celeste_video_generation/providers/__init__.py index 84006e15..2339f556 100644 --- a/packages/capabilities/video-generation/src/celeste_video_generation/providers/__init__.py +++ b/packages/capabilities/video-generation/src/celeste_video_generation/providers/__init__.py @@ -8,8 +8,8 @@ def _get_providers() -> list[tuple[Provider, type[Client]]]: """Lazy-load providers.""" # Import clients directly from .client modules to avoid __init__.py imports - from celeste_video_generation.providers.bytedance.client import ( - ByteDanceVideoGenerationClient, + from celeste_video_generation.providers.byteplus.client import ( + BytePlusVideoGenerationClient, ) from celeste_video_generation.providers.google.client import ( GoogleVideoGenerationClient, @@ -19,7 +19,7 @@ def _get_providers() -> list[tuple[Provider, type[Client]]]: ) return [ - (Provider.BYTEDANCE, ByteDanceVideoGenerationClient), + (Provider.BYTEPLUS, BytePlusVideoGenerationClient), (Provider.GOOGLE, GoogleVideoGenerationClient), (Provider.OPENAI, OpenAIVideoGenerationClient), ] diff --git a/packages/capabilities/video-generation/src/celeste_video_generation/providers/bytedance/__init__.py b/packages/capabilities/video-generation/src/celeste_video_generation/providers/bytedance/__init__.py deleted file mode 100644 index 31a9eb8e..00000000 --- a/packages/capabilities/video-generation/src/celeste_video_generation/providers/bytedance/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""ByteDance provider for video generation.""" - -from celeste.core import Provider - -from .client import ByteDanceVideoGenerationClient - -__all__ = ["PROVIDERS", "ByteDanceVideoGenerationClient"] - -PROVIDERS: list[tuple[Provider, type[ByteDanceVideoGenerationClient]]] = [ - (Provider.BYTEDANCE, ByteDanceVideoGenerationClient), -] diff --git a/packages/capabilities/video-generation/src/celeste_video_generation/providers/bytedance/config.py b/packages/capabilities/video-generation/src/celeste_video_generation/providers/bytedance/config.py deleted file mode 100644 index 099d7e00..00000000 --- a/packages/capabilities/video-generation/src/celeste_video_generation/providers/bytedance/config.py +++ /dev/null @@ -1,19 +0,0 @@ -"""ByteDance provider configuration for video generation.""" - -# HTTP Configuration -BASE_URL = "https://ark.ap-southeast.bytepluses.com/api/v3" -ENDPOINT = "/contents/generations/tasks" -STATUS_ENDPOINT_TEMPLATE = "/contents/generations/tasks/{task_id}" - -# Authentication -AUTH_HEADER_NAME = "Authorization" -AUTH_HEADER_PREFIX = "Bearer " - -# Polling Configuration -DEFAULT_POLLING_INTERVAL = 5 # seconds -MAX_POLLING_TIMEOUT = 300 # 5 minutes - -# Status Constants -STATUS_SUCCEEDED = "succeeded" -STATUS_FAILED = "failed" -STATUS_CANCELED = "canceled" diff --git a/packages/capabilities/video-generation/src/celeste_video_generation/providers/bytedance/parameters.py b/packages/capabilities/video-generation/src/celeste_video_generation/providers/bytedance/parameters.py deleted file mode 100644 index e84e3b07..00000000 --- a/packages/capabilities/video-generation/src/celeste_video_generation/providers/bytedance/parameters.py +++ /dev/null @@ -1,192 +0,0 @@ -"""ByteDance parameter mappers for video generation.""" - -from typing import Any - -from celeste import Model -from celeste.exceptions import ValidationError -from celeste.parameters import ParameterMapper -from celeste_video_generation.parameters import VideoGenerationParameter - - -class DurationMapper(ParameterMapper): - """Map duration parameter to BytePlus ModelArk text prompt format. - - BytePlus ModelArk API expects parameters embedded in the text prompt: - "prompt text --duration 5" instead of {"duration": 5}. - - All Seedance models support duration: 2-12 seconds. - """ - - name = VideoGenerationParameter.DURATION - - def map( - self, request: dict[str, Any], value: object, model: Model - ) -> dict[str, Any]: - """Append --duration parameter to text prompt in content array. - - Args: - request: The request dictionary with content array. - value: The duration value in seconds (integer). - model: The model being used (provides constraints). - - Returns: - Modified request with duration appended to text prompt. - """ - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - - # BytePlus ModelArk uses content array with text type - # Parameters must be embedded in the text field: "prompt --duration 5" - content = request.get("content", []) - for item in content: - if item.get("type") == "text": - text = item.get("text", "") - # Append duration parameter to text prompt - item["text"] = f"{text} --duration {validated_value}" - break - - return request - - -class ResolutionMapper(ParameterMapper): - """Map resolution parameter to BytePlus ModelArk text prompt format. - - BytePlus ModelArk API expects parameters embedded in the text prompt: - "prompt text --resolution 720p" instead of {"resolution": "720p"}. - - Supported values: - - All models: 480p, 720p, 1080p - """ - - name = VideoGenerationParameter.RESOLUTION - - def map( - self, request: dict[str, Any], value: object, model: Model - ) -> dict[str, Any]: - """Append --resolution parameter to text prompt in content array. - - Args: - request: The request dictionary with content array. - value: The resolution value (e.g., "720p"). - model: The model being used (provides constraints). - - Returns: - Modified request with resolution appended to text prompt. - """ - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - - # BytePlus ModelArk uses content array with text type - # Parameters must be embedded in the text field: "prompt --resolution 720p" - content = request.get("content", []) - for item in content: - if item.get("type") == "text": - text = item.get("text", "") - # Append resolution parameter to text prompt - item["text"] = f"{text} --resolution {validated_value}" - break - - return request - - -class ReferenceImagesMapper(ParameterMapper): - """Map reference_images parameter to BytePlus ModelArk content array format.""" - - name = VideoGenerationParameter.REFERENCE_IMAGES - - def map( - self, request: dict[str, Any], value: object, model: Model - ) -> dict[str, Any]: - """Add reference images to content array with reference_image role.""" - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - - content = request.setdefault("content", []) - for img in validated_value: - content.append( - { - "type": "image_url", - "image_url": { - "url": img.url, - }, - "role": "reference_image", - } - ) - - return request - - -class FirstFrameMapper(ParameterMapper): - """Map first_frame parameter to BytePlus ModelArk content array format.""" - - name = VideoGenerationParameter.FIRST_FRAME - - def map( - self, request: dict[str, Any], value: object, model: Model - ) -> dict[str, Any]: - """Add first frame image to content array with first_frame role.""" - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - - if not validated_value.url: - msg = "ByteDance requires image URL (including data URIs) for first_frame. ImageArtifact must have url, data, or path" - raise ValidationError(msg) - - content = request.setdefault("content", []) - content.append( - { - "type": "image_url", - "image_url": { - "url": validated_value.url, - }, - "role": "first_frame", - } - ) - - return request - - -class LastFrameMapper(ParameterMapper): - """Map last_frame parameter to BytePlus ModelArk content array format.""" - - name = VideoGenerationParameter.LAST_FRAME - - def map( - self, request: dict[str, Any], value: object, model: Model - ) -> dict[str, Any]: - """Add last frame image to content array with last_frame role.""" - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - - if not validated_value.url: - msg = "ByteDance requires image URL (including data URIs) for last_frame. ImageArtifact must have url, data, or path" - raise ValidationError(msg) - - content = request.setdefault("content", []) - content.append( - { - "type": "image_url", - "image_url": { - "url": validated_value.url, - }, - "role": "last_frame", - } - ) - - return request - - -BYTEDANCE_PARAMETER_MAPPERS: list[ParameterMapper] = [ - DurationMapper(), - ResolutionMapper(), - ReferenceImagesMapper(), - FirstFrameMapper(), - LastFrameMapper(), -] - -__all__ = ["BYTEDANCE_PARAMETER_MAPPERS"] diff --git a/packages/capabilities/video-generation/src/celeste_video_generation/providers/bytedance/README.md b/packages/capabilities/video-generation/src/celeste_video_generation/providers/byteplus/README.md similarity index 70% rename from packages/capabilities/video-generation/src/celeste_video_generation/providers/bytedance/README.md rename to packages/capabilities/video-generation/src/celeste_video_generation/providers/byteplus/README.md index cdeb1beb..a9143804 100644 --- a/packages/capabilities/video-generation/src/celeste_video_generation/providers/bytedance/README.md +++ b/packages/capabilities/video-generation/src/celeste_video_generation/providers/byteplus/README.md @@ -1,13 +1,13 @@ -# ByteDance Video Generation Provider +# BytePlus Video Generation Provider ## Credentials -**Environment variable:** `BYTEDANCE_API_KEY` +**Environment variable:** `BYTEPLUS_API_KEY` **Setup:** 1. Register at [console.byteplus.com](https://console.byteplus.com) 2. Activate model in ModelArk section 3. Generate API key with video generation permissions -4. Set environment variable: `export BYTEDANCE_API_KEY="your-key"` +4. Set environment variable: `export BYTEPLUS_API_KEY="your-key"` **Note:** Models must be activated in BytePlus console before use. If you get a 404 error, activate the model or use an Endpoint ID (`ep-xxx`) instead of Model ID. diff --git a/packages/capabilities/video-generation/src/celeste_video_generation/providers/byteplus/__init__.py b/packages/capabilities/video-generation/src/celeste_video_generation/providers/byteplus/__init__.py new file mode 100644 index 00000000..136cf8a3 --- /dev/null +++ b/packages/capabilities/video-generation/src/celeste_video_generation/providers/byteplus/__init__.py @@ -0,0 +1,11 @@ +"""BytePlus provider for video generation.""" + +from celeste.core import Provider + +from .client import BytePlusVideoGenerationClient + +__all__ = ["PROVIDERS", "BytePlusVideoGenerationClient"] + +PROVIDERS: list[tuple[Provider, type[BytePlusVideoGenerationClient]]] = [ + (Provider.BYTEPLUS, BytePlusVideoGenerationClient), +] diff --git a/packages/capabilities/video-generation/src/celeste_video_generation/providers/bytedance/client.py b/packages/capabilities/video-generation/src/celeste_video_generation/providers/byteplus/client.py similarity index 56% rename from packages/capabilities/video-generation/src/celeste_video_generation/providers/bytedance/client.py rename to packages/capabilities/video-generation/src/celeste_video_generation/providers/byteplus/client.py index 198c149f..c23401a8 100644 --- a/packages/capabilities/video-generation/src/celeste_video_generation/providers/bytedance/client.py +++ b/packages/capabilities/video-generation/src/celeste_video_generation/providers/byteplus/client.py @@ -1,16 +1,13 @@ -"""ByteDance client implementation for video generation.""" +"""BytePlus client implementation for video generation.""" -import asyncio import base64 -import json import logging -import time from typing import Any, Unpack -import httpx +from celeste_byteplus.videos.client import BytePlusVideosClient from celeste.artifacts import ImageArtifact, VideoArtifact -from celeste.mime_types import ApplicationMimeType, VideoMimeType +from celeste.mime_types import VideoMimeType from celeste.parameters import ParameterMapper from celeste_video_generation.client import VideoGenerationClient from celeste_video_generation.io import ( @@ -19,27 +16,25 @@ ) from celeste_video_generation.parameters import VideoGenerationParameters -from . import config -from .parameters import BYTEDANCE_PARAMETER_MAPPERS +from .parameters import BYTEPLUS_PARAMETER_MAPPERS logger = logging.getLogger(__name__) -class ByteDanceVideoGenerationClient(VideoGenerationClient): - """ByteDance client for video generation.""" +class BytePlusVideoGenerationClient(BytePlusVideosClient, VideoGenerationClient): + """BytePlus client for video generation.""" @classmethod def parameter_mappers(cls) -> list[ParameterMapper]: - return BYTEDANCE_PARAMETER_MAPPERS + return BYTEPLUS_PARAMETER_MAPPERS def _validate_artifacts( self, inputs: VideoGenerationInput, **parameters: Unpack[VideoGenerationParameters], ) -> tuple[VideoGenerationInput, dict[str, Any]]: - """Validate and prepare artifacts for ByteDance API.""" + """Validate and prepare artifacts for BytePlus API.""" - # Helper function to convert ImageArtifact to base64 data URI def convert_to_data_url(img: ImageArtifact) -> ImageArtifact: if img.url: return img @@ -96,11 +91,11 @@ def _add_image_content_item( ) elif artifact.data: logger.warning( - f"ByteDance requires {artifact_type} URL, not base64 data. Upload {artifact_type} first." + f"BytePlus requires {artifact_type} URL, not base64 data. Upload {artifact_type} first." ) elif artifact.path: logger.warning( - f"ByteDance requires {artifact_type} URL, not file path. Upload {artifact_type} first." + f"BytePlus requires {artifact_type} URL, not file path. Upload {artifact_type} first." ) def _init_request(self, inputs: VideoGenerationInput) -> dict[str, Any]: @@ -121,12 +116,8 @@ def _init_request(self, inputs: VideoGenerationInput) -> dict[str, Any]: def _parse_usage(self, response_data: dict[str, Any]) -> VideoGenerationUsage: """Parse usage from response.""" - usage_data = response_data.get("usage", {}) - total_tokens = usage_data.get("total_tokens") - - return VideoGenerationUsage( - total_tokens=total_tokens, - ) + usage = super()._parse_usage(response_data) + return VideoGenerationUsage(**usage) def _parse_content( self, @@ -136,7 +127,7 @@ def _parse_content( """Parse content from response.""" content = response_data.get("content") if not isinstance(content, dict): - msg = f"No content field in ByteDance response. Available keys: {list(response_data.keys())}" + msg = f"No content field in BytePlus response. Available keys: {list(response_data.keys())}" raise ValueError(msg) video_url = content.get("video_url") @@ -173,73 +164,5 @@ def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: return metadata - async def _make_request( - self, - request_body: dict[str, Any], - **parameters: Unpack[VideoGenerationParameters], - ) -> httpx.Response: - """Make HTTP request with async polling.""" - headers = { - **self.auth.get_headers(), - "Content-Type": ApplicationMimeType.JSON, - } - - logger.debug("Submitting video generation task to ByteDance") - submit_response = await self.http_client.post( - f"{config.BASE_URL}{config.ENDPOINT}", - headers=headers, - json_body=request_body, - ) - self._handle_error_response(submit_response) - submit_data = submit_response.json() - - task_id = submit_data["id"] - logger.info(f"ByteDance task submitted: {task_id}") - - start_time = time.time() - polling_interval = config.DEFAULT_POLLING_INTERVAL - - # Wait before first poll (consistent with Google pattern) - await asyncio.sleep(polling_interval) - - while True: - elapsed = time.time() - start_time - if elapsed > config.MAX_POLLING_TIMEOUT: - msg = f"ByteDance task {task_id} timed out after {elapsed:.0f}s" - raise TimeoutError(msg) - - status_url = f"{config.BASE_URL}{config.STATUS_ENDPOINT_TEMPLATE.format(task_id=task_id)}" - logger.debug(f"Polling ByteDance task status: {task_id}") - - status_response = await self.http_client.get( - status_url, - headers=headers, - ) - self._handle_error_response(status_response) - status_data = status_response.json() - - status = status_data.get("status") - logger.debug(f"ByteDance task {task_id} status: {status}") - - if status == config.STATUS_SUCCEEDED: - logger.info(f"ByteDance task {task_id} completed in {elapsed:.0f}s") - return httpx.Response( - 200, - content=json.dumps(status_data).encode(), - headers={"Content-Type": ApplicationMimeType.JSON}, - ) - - if status in [config.STATUS_FAILED, config.STATUS_CANCELED]: - error = status_data.get("error", {}) - error_msg = ( - error.get("message", "Unknown error") - if isinstance(error, dict) - else "Unknown error" - ) - msg = f"ByteDance task {task_id} failed: {error_msg}" - raise ValueError(msg) - - await asyncio.sleep(polling_interval) - -__all__ = ["ByteDanceVideoGenerationClient"] +__all__ = ["BytePlusVideoGenerationClient"] diff --git a/packages/capabilities/video-generation/src/celeste_video_generation/providers/bytedance/models.py b/packages/capabilities/video-generation/src/celeste_video_generation/providers/byteplus/models.py similarity index 76% rename from packages/capabilities/video-generation/src/celeste_video_generation/providers/bytedance/models.py rename to packages/capabilities/video-generation/src/celeste_video_generation/providers/byteplus/models.py index e5c7eb7c..1a529d0a 100644 --- a/packages/capabilities/video-generation/src/celeste_video_generation/providers/bytedance/models.py +++ b/packages/capabilities/video-generation/src/celeste_video_generation/providers/byteplus/models.py @@ -1,4 +1,4 @@ -"""ByteDance models for video generation. +"""BytePlus models for video generation. Model IDs use lowercase format with version suffixes (e.g., seedance-1-0-pro-250528). Console display names differ from API model IDs. @@ -9,8 +9,8 @@ from celeste.mime_types import ImageMimeType from celeste_video_generation.parameters import VideoGenerationParameter -# Supported MIME types for ByteDance image parameters -BYTEDANCE_SUPPORTED_MIME_TYPES = [ +# Supported MIME types for BytePlus image parameters +BYTEPLUS_SUPPORTED_MIME_TYPES = [ ImageMimeType.JPEG, ImageMimeType.PNG, ImageMimeType.WEBP, @@ -22,7 +22,7 @@ MODELS: list[Model] = [ Model( id="seedance-1-0-lite-t2v-250428", - provider=Provider.BYTEDANCE, + provider=Provider.BYTEPLUS, capabilities={Capability.VIDEO_GENERATION}, display_name="Seedance 1.0 Lite (Text-to-Video)", parameter_constraints={ @@ -31,16 +31,16 @@ options=["480p", "720p", "1080p"] ), VideoGenerationParameter.FIRST_FRAME: ImageConstraint( - supported_mime_types=BYTEDANCE_SUPPORTED_MIME_TYPES, + supported_mime_types=BYTEPLUS_SUPPORTED_MIME_TYPES, ), VideoGenerationParameter.LAST_FRAME: ImageConstraint( - supported_mime_types=BYTEDANCE_SUPPORTED_MIME_TYPES, + supported_mime_types=BYTEPLUS_SUPPORTED_MIME_TYPES, ), }, ), Model( id="seedance-1-0-lite-i2v-250428", - provider=Provider.BYTEDANCE, + provider=Provider.BYTEPLUS, capabilities={Capability.VIDEO_GENERATION}, display_name="Seedance 1.0 Lite (Image-to-Video)", parameter_constraints={ @@ -49,20 +49,20 @@ options=["480p", "720p", "1080p"] ), VideoGenerationParameter.REFERENCE_IMAGES: ImagesConstraint( - supported_mime_types=BYTEDANCE_SUPPORTED_MIME_TYPES, + supported_mime_types=BYTEPLUS_SUPPORTED_MIME_TYPES, max_count=4, ), VideoGenerationParameter.FIRST_FRAME: ImageConstraint( - supported_mime_types=BYTEDANCE_SUPPORTED_MIME_TYPES, + supported_mime_types=BYTEPLUS_SUPPORTED_MIME_TYPES, ), VideoGenerationParameter.LAST_FRAME: ImageConstraint( - supported_mime_types=BYTEDANCE_SUPPORTED_MIME_TYPES, + supported_mime_types=BYTEPLUS_SUPPORTED_MIME_TYPES, ), }, ), Model( id="seedance-1-0-pro-250528", - provider=Provider.BYTEDANCE, + provider=Provider.BYTEPLUS, capabilities={Capability.VIDEO_GENERATION}, display_name="Seedance 1.0 Pro", parameter_constraints={ @@ -71,16 +71,16 @@ options=["480p", "720p", "1080p"] ), VideoGenerationParameter.FIRST_FRAME: ImageConstraint( - supported_mime_types=BYTEDANCE_SUPPORTED_MIME_TYPES, + supported_mime_types=BYTEPLUS_SUPPORTED_MIME_TYPES, ), VideoGenerationParameter.LAST_FRAME: ImageConstraint( - supported_mime_types=BYTEDANCE_SUPPORTED_MIME_TYPES, + supported_mime_types=BYTEPLUS_SUPPORTED_MIME_TYPES, ), }, ), Model( id="seedance-1-0-pro-fast-251015", - provider=Provider.BYTEDANCE, + provider=Provider.BYTEPLUS, capabilities={Capability.VIDEO_GENERATION}, display_name="Seedance 1.0 Pro Fast", parameter_constraints={ @@ -89,10 +89,10 @@ options=["480p", "720p", "1080p"] ), VideoGenerationParameter.FIRST_FRAME: ImageConstraint( - supported_mime_types=BYTEDANCE_SUPPORTED_MIME_TYPES, + supported_mime_types=BYTEPLUS_SUPPORTED_MIME_TYPES, ), VideoGenerationParameter.LAST_FRAME: ImageConstraint( - supported_mime_types=BYTEDANCE_SUPPORTED_MIME_TYPES, + supported_mime_types=BYTEPLUS_SUPPORTED_MIME_TYPES, ), }, ), diff --git a/packages/capabilities/video-generation/src/celeste_video_generation/providers/byteplus/parameters.py b/packages/capabilities/video-generation/src/celeste_video_generation/providers/byteplus/parameters.py new file mode 100644 index 00000000..3270606d --- /dev/null +++ b/packages/capabilities/video-generation/src/celeste_video_generation/providers/byteplus/parameters.py @@ -0,0 +1,51 @@ +"""BytePlus parameter mappers for video generation.""" + +from celeste_byteplus.videos.parameters import ( + DurationMapper as _DurationMapper, +) +from celeste_byteplus.videos.parameters import ( + FirstFrameMapper as _FirstFrameMapper, +) +from celeste_byteplus.videos.parameters import ( + LastFrameMapper as _LastFrameMapper, +) +from celeste_byteplus.videos.parameters import ( + ReferenceImagesMapper as _ReferenceImagesMapper, +) +from celeste_byteplus.videos.parameters import ( + ResolutionMapper as _ResolutionMapper, +) + +from celeste.parameters import ParameterMapper +from celeste_video_generation.parameters import VideoGenerationParameter + + +class DurationMapper(_DurationMapper): + name = VideoGenerationParameter.DURATION + + +class ResolutionMapper(_ResolutionMapper): + name = VideoGenerationParameter.RESOLUTION + + +class ReferenceImagesMapper(_ReferenceImagesMapper): + name = VideoGenerationParameter.REFERENCE_IMAGES + + +class FirstFrameMapper(_FirstFrameMapper): + name = VideoGenerationParameter.FIRST_FRAME + + +class LastFrameMapper(_LastFrameMapper): + name = VideoGenerationParameter.LAST_FRAME + + +BYTEPLUS_PARAMETER_MAPPERS: list[ParameterMapper] = [ + DurationMapper(), + ResolutionMapper(), + ReferenceImagesMapper(), + FirstFrameMapper(), + LastFrameMapper(), +] + +__all__ = ["BYTEPLUS_PARAMETER_MAPPERS"] diff --git a/packages/capabilities/video-generation/tests/integration_tests/test_video_generation/test_generate.py b/packages/capabilities/video-generation/tests/integration_tests/test_video_generation/test_generate.py index 455d92fb..3c9c0ff2 100644 --- a/packages/capabilities/video-generation/tests/integration_tests/test_video_generation/test_generate.py +++ b/packages/capabilities/video-generation/tests/integration_tests/test_video_generation/test_generate.py @@ -19,7 +19,7 @@ {"duration": 4, "resolution": "720p"}, ), ( - Provider.BYTEDANCE, + Provider.BYTEPLUS, "seedance-1-0-lite-t2v-250428", {"duration": 2, "resolution": "480p"}, ), diff --git a/packages/providers/byteplus/pyproject.toml b/packages/providers/byteplus/pyproject.toml new file mode 100644 index 00000000..d23ac2fd --- /dev/null +++ b/packages/providers/byteplus/pyproject.toml @@ -0,0 +1,17 @@ +[project] +name = "celeste-byteplus" +version = "0.3.0" +description = "BytePlus provider package for Celeste AI" +authors = [{name = "Kamilbenkirane", email = "kamil@withceleste.ai"}] +license = {text = "Apache-2.0"} +requires-python = ">=3.12" + +[tool.uv.sources] +celeste-ai = { workspace = true } + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/celeste_byteplus"] diff --git a/packages/providers/byteplus/src/celeste_byteplus/__init__.py b/packages/providers/byteplus/src/celeste_byteplus/__init__.py new file mode 100644 index 00000000..ba7e53de --- /dev/null +++ b/packages/providers/byteplus/src/celeste_byteplus/__init__.py @@ -0,0 +1 @@ +"""BytePlus provider package for Celeste AI.""" diff --git a/packages/providers/byteplus/src/celeste_byteplus/images/__init__.py b/packages/providers/byteplus/src/celeste_byteplus/images/__init__.py new file mode 100644 index 00000000..7ddc2aa4 --- /dev/null +++ b/packages/providers/byteplus/src/celeste_byteplus/images/__init__.py @@ -0,0 +1 @@ +"""BytePlus Images API provider package.""" diff --git a/packages/providers/byteplus/src/celeste_byteplus/images/client.py b/packages/providers/byteplus/src/celeste_byteplus/images/client.py new file mode 100644 index 00000000..18875f85 --- /dev/null +++ b/packages/providers/byteplus/src/celeste_byteplus/images/client.py @@ -0,0 +1,120 @@ +"""BytePlus Images API client with shared implementation.""" + +from collections.abc import AsyncIterator +from typing import Any + +import httpx + +from celeste.core import UsageField +from celeste.io import FinishReason +from celeste.mime_types import ApplicationMimeType + +from . import config + + +class BytePlusImagesClient: + """Mixin for BytePlus Images API capabilities. + + Provides shared implementation for all capabilities using the Images API: + - _make_request() - HTTP POST to /api/v3/images/generations + - _make_stream_request() - HTTP streaming to /api/v3/images/generations + - _parse_usage() - Extract usage dict from response + - _parse_content() - Extract images/data array from response + - _parse_finish_reason() - Extract finish reason from response + - _build_metadata() - Filter content fields and extract seed + + Usage: + class BytePlusImageGenerationClient(BytePlusImagesClient, ImageGenerationClient): + def _parse_content(self, response_data, **parameters): + images = response_data.get("images", []) + # Extract image from images[0] or data[0]... + """ + + async def _make_request( + self, + request_body: dict[str, Any], + **parameters: Any, + ) -> httpx.Response: + """Make HTTP request to BytePlus Images API endpoint.""" + request_body["stream"] = False + + headers = { + **self.auth.get_headers(), # type: ignore[attr-defined] + "Content-Type": ApplicationMimeType.JSON, + } + + return await self.http_client.post( # type: ignore[attr-defined,no-any-return] + f"{config.BASE_URL}{config.BytePlusImagesEndpoint.CREATE_IMAGE}", + headers=headers, + json_body=request_body, + ) + + def _make_stream_request( + self, + request_body: dict[str, Any], + **parameters: Any, + ) -> AsyncIterator[dict[str, Any]]: + """Make streaming request to BytePlus Images API endpoint.""" + request_body["stream"] = True + + headers = { + **self.auth.get_headers(), # type: ignore[attr-defined] + "Content-Type": ApplicationMimeType.JSON, + } + + return self.http_client.stream_post( # type: ignore[attr-defined,no-any-return] + f"{config.BASE_URL}{config.BytePlusImagesEndpoint.CREATE_IMAGE}", + headers=headers, + json_body=request_body, + ) + + def _parse_usage(self, response_data: dict[str, Any]) -> dict[str, int | None]: + """Extract usage data from Images API response. + + Returns dict that capability clients wrap in their specific Usage type. + """ + usage_data = response_data.get("usage", {}) + + return { + UsageField.TOTAL_TOKENS: usage_data.get("total_tokens"), + UsageField.OUTPUT_TOKENS: usage_data.get("output_tokens"), + UsageField.NUM_IMAGES: usage_data.get("generated_images"), + } + + def _parse_content(self, response_data: dict[str, Any]) -> Any: + """Parse images/data array from Images API response. + + Returns raw images/data array that capability clients extract from. + """ + images = response_data.get("images", []) + if images: + return images + + data = response_data.get("data", []) + if data: + return data + + return None + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> FinishReason: + """BytePlus doesn't provide finish reasons for image generation.""" + return FinishReason(reason=None) + + def _build_metadata(self, response_data: dict[str, Any]) -> Any: + """Build metadata dictionary, extracting seed if present.""" + # Filter content fields before calling super + content_fields = {"images", "data"} + filtered_data = { + k: v for k, v in response_data.items() if k not in content_fields + } + metadata = super()._build_metadata(filtered_data) # type: ignore[misc] + + # Add provider-specific parsed fields + seed = response_data.get("seed") + if seed is not None: + metadata["seed"] = seed + + return metadata + + +__all__ = ["BytePlusImagesClient"] diff --git a/packages/providers/byteplus/src/celeste_byteplus/images/config.py b/packages/providers/byteplus/src/celeste_byteplus/images/config.py new file mode 100644 index 00000000..62411e9f --- /dev/null +++ b/packages/providers/byteplus/src/celeste_byteplus/images/config.py @@ -0,0 +1,12 @@ +"""Configuration for BytePlus Images API.""" + +from enum import StrEnum + + +class BytePlusImagesEndpoint(StrEnum): + """Endpoints for Images API.""" + + CREATE_IMAGE = "/api/v3/images/generations" + + +BASE_URL = "https://ark.ap-southeast.bytepluses.com" diff --git a/packages/providers/byteplus/src/celeste_byteplus/images/parameters.py b/packages/providers/byteplus/src/celeste_byteplus/images/parameters.py new file mode 100644 index 00000000..866c84e5 --- /dev/null +++ b/packages/providers/byteplus/src/celeste_byteplus/images/parameters.py @@ -0,0 +1,45 @@ +"""BytePlus Images API parameter mappers.""" + +from typing import Any + +from celeste.models import Model +from celeste.parameters import ParameterMapper + + +class SizeMapper(ParameterMapper): + """Map size to BytePlus size field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform size into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["size"] = validated_value + return request + + +class WatermarkMapper(ParameterMapper): + """Map watermark to BytePlus watermark field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform watermark into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["watermark"] = validated_value + return request + + +__all__ = ["SizeMapper", "WatermarkMapper"] diff --git a/packages/providers/byteplus/src/celeste_byteplus/py.typed b/packages/providers/byteplus/src/celeste_byteplus/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/packages/providers/byteplus/src/celeste_byteplus/videos/__init__.py b/packages/providers/byteplus/src/celeste_byteplus/videos/__init__.py new file mode 100644 index 00000000..d728b47f --- /dev/null +++ b/packages/providers/byteplus/src/celeste_byteplus/videos/__init__.py @@ -0,0 +1 @@ +"""BytePlus Videos API provider package.""" diff --git a/packages/providers/byteplus/src/celeste_byteplus/videos/client.py b/packages/providers/byteplus/src/celeste_byteplus/videos/client.py new file mode 100644 index 00000000..f04fcef8 --- /dev/null +++ b/packages/providers/byteplus/src/celeste_byteplus/videos/client.py @@ -0,0 +1,132 @@ +"""BytePlus Videos API client with shared implementation.""" + +import asyncio +import logging +import time +from typing import Any + +import httpx + +from celeste.core import UsageField +from celeste.io import FinishReason +from celeste.mime_types import ApplicationMimeType + +from . import config + +logger = logging.getLogger(__name__) + + +class BytePlusVideosClient: + """Mixin for BytePlus ModelArk Videos API with async polling. + + Provides shared implementation: + - _make_request() - HTTP POST with async polling pattern + + The BytePlus Videos API uses async polling: + 1. POST to /api/v3/contents/generations/tasks to submit job + 2. Poll GET /api/v3/contents/generations/tasks/{task_id} until succeeded/failed + 3. Return final response + + Usage: + class BytePlusVideoGenerationClient(BytePlusVideosClient, VideoGenerationClient): + def _parse_content(self, response_data, **parameters): + content = response_data.get("content", {}) + # Extract video from content["video_url"]... + """ + + async def _make_request( + self, + request_body: dict[str, Any], + **parameters: Any, + ) -> httpx.Response: + """Make HTTP request with async polling for BytePlus video generation. + + Handles the complete async polling workflow: + 1. Submit job to CONTENT_GENERATIONS endpoint + 2. Poll CONTENT_STATUS endpoint until succeeded/failed/canceled + 3. Return response with final status data + """ + auth_headers = self.auth.get_headers() # type: ignore[attr-defined] + headers = { + **auth_headers, + "Content-Type": ApplicationMimeType.JSON, + } + + # Phase 1: Submit job + logger.debug("Submitting video generation task to BytePlus") + submit_response = await self.http_client.post( # type: ignore[attr-defined] + f"{config.BASE_URL}{config.BytePlusVideosEndpoint.CREATE_VIDEO}", + headers=headers, + json_body=request_body, + ) + + if submit_response.status_code != 200: + return submit_response # type: ignore[no-any-return] + + submit_data = submit_response.json() + task_id = submit_data["id"] + logger.info(f"BytePlus task submitted: {task_id}") + + # Phase 2: Poll for completion + start_time = time.monotonic() + + # Wait before first poll + await asyncio.sleep(config.POLLING_INTERVAL) + + while True: + elapsed = time.monotonic() - start_time + if elapsed >= config.POLLING_TIMEOUT: + msg = f"BytePlus task {task_id} timed out after {config.POLLING_TIMEOUT} seconds" + raise TimeoutError(msg) + + status_url = f"{config.BASE_URL}{config.BytePlusVideosEndpoint.GET_VIDEO_STATUS.format(task_id=task_id)}" + logger.debug(f"Polling BytePlus task status: {task_id}") + + status_response = await self.http_client.get( # type: ignore[attr-defined] + status_url, + headers=headers, + ) + + if status_response.status_code != 200: + return status_response # type: ignore[no-any-return] + + status_data = status_response.json() + status = status_data.get("status") + logger.debug(f"BytePlus task {task_id} status: {status}") + + if status == config.STATUS_SUCCEEDED: + logger.info(f"BytePlus task {task_id} completed in {elapsed:.0f}s") + return httpx.Response( + status_code=200, + json=status_data, + request=httpx.Request("GET", status_url), + ) + + if status in (config.STATUS_FAILED, config.STATUS_CANCELED): + error = status_data.get("error", {}) + error_msg = ( + error.get("message", "Unknown error") + if isinstance(error, dict) + else "Unknown error" + ) + msg = f"BytePlus task {task_id} failed: {error_msg}" + raise ValueError(msg) + + await asyncio.sleep(config.POLLING_INTERVAL) + + def _parse_usage(self, response_data: dict[str, Any]) -> dict[str, Any]: + """Extract usage data from BytePlus API response. + + Returns dict with usage fields for capability clients to wrap in Usage type. + """ + usage_data = response_data.get("usage", {}) + return { + UsageField.TOTAL_TOKENS: usage_data.get("total_tokens"), + } + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> FinishReason: + """BytePlus provides status but not structured finish reasons.""" + return FinishReason(reason=None) + + +__all__ = ["BytePlusVideosClient"] diff --git a/packages/providers/byteplus/src/celeste_byteplus/videos/config.py b/packages/providers/byteplus/src/celeste_byteplus/videos/config.py new file mode 100644 index 00000000..607acd05 --- /dev/null +++ b/packages/providers/byteplus/src/celeste_byteplus/videos/config.py @@ -0,0 +1,22 @@ +"""Configuration for BytePlus Videos API.""" + +from enum import StrEnum + + +class BytePlusVideosEndpoint(StrEnum): + """Endpoints for Videos API.""" + + CREATE_VIDEO = "/api/v3/contents/generations/tasks" + GET_VIDEO_STATUS = "/api/v3/contents/generations/tasks/{task_id}" + + +BASE_URL = "https://ark.ap-southeast.bytepluses.com" + +# Polling Configuration +POLLING_INTERVAL = 5 # seconds +POLLING_TIMEOUT = 300 # 5 minutes + +# Status Constants +STATUS_SUCCEEDED = "succeeded" +STATUS_FAILED = "failed" +STATUS_CANCELED = "canceled" diff --git a/packages/providers/byteplus/src/celeste_byteplus/videos/parameters.py b/packages/providers/byteplus/src/celeste_byteplus/videos/parameters.py new file mode 100644 index 00000000..b0ea3f1e --- /dev/null +++ b/packages/providers/byteplus/src/celeste_byteplus/videos/parameters.py @@ -0,0 +1,179 @@ +"""BytePlus Videos API parameter mappers.""" + +from typing import Any + +from celeste.exceptions import ValidationError +from celeste.models import Model +from celeste.parameters import ParameterMapper + + +class DurationMapper(ParameterMapper): + """Map duration parameter to BytePlus text prompt format. + + Appends --duration to the text prompt. + Supported values: 2-12 seconds. + """ + + def map( + self, request: dict[str, Any], value: object, model: Model + ) -> dict[str, Any]: + """Transform duration into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + content = request.get("content", []) + for item in content: + if item.get("type") == "text": + text = item.get("text", "") + item["text"] = f"{text} --duration {validated_value}" + break + + return request + + +class ResolutionMapper(ParameterMapper): + """Map resolution parameter to BytePlus text prompt format. + + Appends --resolution to the text prompt. + Supported values: 480p, 720p, 1080p. + """ + + def map( + self, request: dict[str, Any], value: object, model: Model + ) -> dict[str, Any]: + """Transform resolution into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + content = request.get("content", []) + for item in content: + if item.get("type") == "text": + text = item.get("text", "") + item["text"] = f"{text} --resolution {validated_value}" + break + + return request + + +class AspectRatioMapper(ParameterMapper): + """Map aspect_ratio parameter to BytePlus text prompt format. + + Appends --ratio to the text prompt. + Supported values: 16:9, 4:3, 1:1, 3:4, 9:16, 21:9, adaptive. + """ + + def map( + self, request: dict[str, Any], value: object, model: Model + ) -> dict[str, Any]: + """Transform aspect_ratio into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + content = request.get("content", []) + for item in content: + if item.get("type") == "text": + text = item.get("text", "") + item["text"] = f"{text} --ratio {validated_value}" + break + + return request + + +class ReferenceImagesMapper(ParameterMapper): + """Map reference_images to BytePlus content array format. + + Adds images with role="reference_image" to the content array. + """ + + def map( + self, request: dict[str, Any], value: object, model: Model + ) -> dict[str, Any]: + """Transform reference_images into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + content = request.setdefault("content", []) + for img in validated_value: + content.append( + { + "type": "image_url", + "image_url": {"url": img.url}, + "role": "reference_image", + } + ) + + return request + + +class FirstFrameMapper(ParameterMapper): + """Map first_frame to BytePlus content array format. + + Adds image with role="first_frame" to the content array. + """ + + def map( + self, request: dict[str, Any], value: object, model: Model + ) -> dict[str, Any]: + """Transform first_frame into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + if not validated_value.url: + msg = "BytePlus requires image URL for first_frame." + raise ValidationError(msg) from None + + content = request.setdefault("content", []) + content.append( + { + "type": "image_url", + "image_url": {"url": validated_value.url}, + "role": "first_frame", + } + ) + + return request + + +class LastFrameMapper(ParameterMapper): + """Map last_frame to BytePlus content array format. + + Adds image with role="last_frame" to the content array. + """ + + def map( + self, request: dict[str, Any], value: object, model: Model + ) -> dict[str, Any]: + """Transform last_frame into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + if not validated_value.url: + msg = "BytePlus requires image URL for last_frame." + raise ValidationError(msg) from None + + content = request.setdefault("content", []) + content.append( + { + "type": "image_url", + "image_url": {"url": validated_value.url}, + "role": "last_frame", + } + ) + + return request + + +__all__ = [ + "AspectRatioMapper", + "DurationMapper", + "FirstFrameMapper", + "LastFrameMapper", + "ReferenceImagesMapper", + "ResolutionMapper", +] diff --git a/src/celeste/__init__.py b/src/celeste/__init__.py index d13f6558..aa2a5a81 100644 --- a/src/celeste/__init__.py +++ b/src/celeste/__init__.py @@ -4,7 +4,7 @@ from celeste.auth import APIKey, Authentication from celeste.client import Client, get_client_class, register_client -from celeste.core import Capability, Parameter, Provider +from celeste.core import Capability, Parameter, Provider, UsageField from celeste.credentials import credentials from celeste.exceptions import ( ClientNotFoundError, @@ -25,6 +25,12 @@ from celeste.models import Model, get_model, list_models, register_models from celeste.parameters import Parameters from celeste.registry import _load_from_entry_points +from celeste.structured_outputs import ( + RefResolvingJsonSchemaGenerator, + StrictJsonSchemaGenerator, + StrictRefResolvingJsonSchemaGenerator, +) +from celeste.types import JsonValue from celeste.utils import image_to_data_uri logger = logging.getLogger(__name__) @@ -117,6 +123,7 @@ def create_client( "Error", "HTTPClient", "Input", + "JsonValue", "MissingCredentialsError", "Model", "ModelNotFoundError", @@ -124,13 +131,17 @@ def create_client( "Parameter", "Parameters", "Provider", + "RefResolvingJsonSchemaGenerator", "StreamEmptyError", "StreamNotExhaustedError", "StreamingNotSupportedError", + "StrictJsonSchemaGenerator", + "StrictRefResolvingJsonSchemaGenerator", "UnsupportedCapabilityError", "UnsupportedParameterError", "UnsupportedProviderError", "Usage", + "UsageField", "ValidationError", "close_all_http_clients", "create_client", diff --git a/src/celeste/core.py b/src/celeste/core.py index fadd94be..92c1d1bf 100644 --- a/src/celeste/core.py +++ b/src/celeste/core.py @@ -19,7 +19,7 @@ class Provider(StrEnum): LUMA = "luma" TOPAZLABS = "topazlabs" PERPLEXITY = "perplexity" - BYTEDANCE = "bytedance" + BYTEPLUS = "byteplus" ELEVENLABS = "elevenlabs" diff --git a/src/celeste/credentials.py b/src/celeste/credentials.py index 382ca97f..5a3fe24e 100644 --- a/src/celeste/credentials.py +++ b/src/celeste/credentials.py @@ -23,7 +23,7 @@ Provider.LUMA: ("celeste_luma", "Authorization", "Bearer "), Provider.TOPAZLABS: ("celeste_topazlabs", "X-API-Key", ""), Provider.PERPLEXITY: ("celeste_perplexity", "Authorization", "Bearer "), - Provider.BYTEDANCE: ("celeste_bytedance", "Authorization", "Bearer "), + Provider.BYTEPLUS: ("celeste_byteplus", "Authorization", "Bearer "), Provider.ELEVENLABS: ("celeste_elevenlabs", "xi-api-key", ""), Provider.BFL: ("celeste_bfl", "x-key", ""), } @@ -42,7 +42,7 @@ Provider.LUMA: "luma_api_key", Provider.TOPAZLABS: "topazlabs_api_key", Provider.PERPLEXITY: "perplexity_api_key", - Provider.BYTEDANCE: "bytedance_api_key", + Provider.BYTEPLUS: "byteplus_api_key", Provider.ELEVENLABS: "elevenlabs_api_key", Provider.BFL: "bfl_api_key", } @@ -63,7 +63,7 @@ class Credentials(BaseSettings): luma_api_key: SecretStr | None = Field(None, alias="LUMA_API_KEY") topazlabs_api_key: SecretStr | None = Field(None, alias="TOPAZLABS_API_KEY") perplexity_api_key: SecretStr | None = Field(None, alias="PERPLEXITY_API_KEY") - bytedance_api_key: SecretStr | None = Field(None, alias="BYTEDANCE_API_KEY") + byteplus_api_key: SecretStr | None = Field(None, alias="BYTEPLUS_API_KEY") elevenlabs_api_key: SecretStr | None = Field(None, alias="ELEVENLABS_API_KEY") bfl_api_key: SecretStr | None = Field(None, alias="BFL_API_KEY")