diff --git a/packages/capabilities/image-generation/pyproject.toml b/packages/capabilities/image-generation/pyproject.toml index e0b5fa35..dfb0c5e8 100644 --- a/packages/capabilities/image-generation/pyproject.toml +++ b/packages/capabilities/image-generation/pyproject.toml @@ -29,6 +29,7 @@ Issues = "https://github.com/withceleste/celeste-python/issues" celeste-ai = { workspace = true } celeste-bfl = { workspace = true } celeste-google = { workspace = true } +celeste-openai = { workspace = true } [project.entry-points."celeste.packages"] image-generation = "celeste_image_generation:register_package" diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/google/imagen.py b/packages/capabilities/image-generation/src/celeste_image_generation/providers/google/imagen.py index 3fd3f74c..63a68b2f 100644 --- a/packages/capabilities/image-generation/src/celeste_image_generation/providers/google/imagen.py +++ b/packages/capabilities/image-generation/src/celeste_image_generation/providers/google/imagen.py @@ -39,8 +39,8 @@ def _init_request(self, inputs: ImageGenerationInput) -> dict[str, Any]: def _parse_usage(self, response_data: dict[str, Any]) -> ImageGenerationUsage: """Parse usage from response.""" - predictions = response_data.get("predictions", []) - return ImageGenerationUsage(num_images=len(predictions)) + usage = super()._parse_usage(response_data) + return ImageGenerationUsage(**usage) def _parse_content( self, diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/openai/client.py b/packages/capabilities/image-generation/src/celeste_image_generation/providers/openai/client.py index 69a8bd87..bd980b99 100644 --- a/packages/capabilities/image-generation/src/celeste_image_generation/providers/openai/client.py +++ b/packages/capabilities/image-generation/src/celeste_image_generation/providers/openai/client.py @@ -1,13 +1,11 @@ """OpenAI client implementation for image generation.""" import base64 -from collections.abc import AsyncIterator from typing import Any, Unpack -import httpx +from celeste_openai.images.client import OpenAIImagesClient from celeste.artifacts import ImageArtifact -from celeste.mime_types import ApplicationMimeType from celeste.parameters import ParameterMapper from celeste_image_generation.client import ImageGenerationClient from celeste_image_generation.io import ( @@ -17,12 +15,11 @@ ) from celeste_image_generation.parameters import ImageGenerationParameters -from . import config from .parameters import OPENAI_PARAMETER_MAPPERS from .streaming import OpenAIImageGenerationStream -class OpenAIImageGenerationClient(ImageGenerationClient): +class OpenAIImageGenerationClient(OpenAIImagesClient, ImageGenerationClient): """OpenAI client for image generation.""" @classmethod @@ -44,7 +41,8 @@ def _init_request(self, inputs: ImageGenerationInput) -> dict[str, Any]: def _parse_usage(self, response_data: dict[str, Any]) -> ImageGenerationUsage: """Parse usage from response.""" - return ImageGenerationUsage() + usage = super()._parse_usage(response_data) + return ImageGenerationUsage(**usage) def _parse_content( self, @@ -52,11 +50,8 @@ def _parse_content( **parameters: Unpack[ImageGenerationParameters], ) -> ImageArtifact: """Parse content from response.""" - data = response_data.get("data", []) - if not data: - msg = "No image data in response" - raise ValueError(msg) - + # Use mixin's _parse_content to get data array + data = super()._parse_content(response_data) image_data = data[0] b64_json = image_data.get("b64_json") @@ -73,62 +68,13 @@ def _parse_content( def _parse_finish_reason( self, response_data: dict[str, Any] - ) -> ImageGenerationFinishReason | None: - """Parse finish reason from response.""" - return None - - def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: - """Build metadata dictionary from response data.""" - metadata = super()._build_metadata(response_data) - # Add provider-specific parsed fields - if response_data.get("data") and response_data["data"]: - revised_prompt = response_data["data"][0].get("revised_prompt") - if revised_prompt: - metadata["revised_prompt"] = revised_prompt - 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.""" - 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, - ) + ) -> ImageGenerationFinishReason: + """OpenAI Images API doesn't provide finish reasons.""" + return ImageGenerationFinishReason(reason=None) def _stream_class(self) -> type[OpenAIImageGenerationStream]: """Return the Stream class for this client.""" return OpenAIImageGenerationStream - 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 - - if "partial_images" not in request_body: - request_body["partial_images"] = 1 - - headers = { - **self.auth.get_headers(), - "Content-Type": ApplicationMimeType.JSON, - } - - return self.http_client.stream_post( - f"{config.BASE_URL}{config.STREAM_ENDPOINT}", - headers=headers, - json_body=request_body, - ) - __all__ = ["OpenAIImageGenerationClient"] diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/openai/config.py b/packages/capabilities/image-generation/src/celeste_image_generation/providers/openai/config.py deleted file mode 100644 index 195012eb..00000000 --- a/packages/capabilities/image-generation/src/celeste_image_generation/providers/openai/config.py +++ /dev/null @@ -1,10 +0,0 @@ -"""OpenAI provider configuration for image generation.""" - -# HTTP Configuration -BASE_URL = "https://api.openai.com" -ENDPOINT = "/v1/images/generations" -STREAM_ENDPOINT = ENDPOINT # Same endpoint, streaming enabled via request parameter - -# Authentication -AUTH_HEADER_NAME = "Authorization" -AUTH_HEADER_PREFIX = "Bearer " diff --git a/packages/capabilities/image-generation/src/celeste_image_generation/providers/openai/parameters.py b/packages/capabilities/image-generation/src/celeste_image_generation/providers/openai/parameters.py index b09521fb..e8ba1d48 100644 --- a/packages/capabilities/image-generation/src/celeste_image_generation/providers/openai/parameters.py +++ b/packages/capabilities/image-generation/src/celeste_image_generation/providers/openai/parameters.py @@ -1,113 +1,30 @@ -"""OpenAI parameter mappers for image generation.""" +"""OpenAI Images parameter mappers for image generation.""" + +from celeste_openai.images.parameters import ( + PartialImagesMapper as _PartialImagesMapper, +) +from celeste_openai.images.parameters import ( + QualityMapper as _QualityMapper, +) +from celeste_openai.images.parameters import ( + SizeMapper as _SizeMapper, +) -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 parameter to OpenAI's size parameter.""" - +class AspectRatioMapper(_SizeMapper): name = ImageGenerationParameter.ASPECT_RATIO - def map( - self, - request: dict[str, Any], - value: object, - model: Model, - ) -> dict[str, Any]: - """Transform aspect_ratio into provider request. - - Maps unified aspect_ratio parameter to OpenAI's size format. - Values are OpenAI's native size strings (e.g., "1024x1024", "1792x1024"). - Coercion from ratio format ("16:9") to size format can be added later. - - Args: - request: Provider request dictionary to modify. - value: The aspect_ratio value (OpenAI size string). - model: Model instance with parameter constraints. - - Returns: - Modified request dictionary with size parameter. - """ - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - - # Transform to provider-specific request format (size parameter) - request["size"] = validated_value - return request - - -class PartialImagesMapper(ParameterMapper): - """Map partial_images parameter for streaming.""" +class PartialImagesMapper(_PartialImagesMapper): name = ImageGenerationParameter.PARTIAL_IMAGES - def map( - self, - request: dict[str, Any], - value: object, - model: Model, - ) -> dict[str, Any]: - """Transform partial_images into provider request. - - Controls number of partial images during streaming (0-3). - - Args: - request: Provider request dictionary to modify. - value: The partial_images value (0-3). - model: Model instance with parameter constraints. - - Returns: - Modified request dictionary with partial_images parameter. - """ - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - - # Transform to provider-specific request format (top-level field) - request["partial_images"] = validated_value - return request - - -class QualityMapper(ParameterMapper): - """Map quality parameter""" +class QualityMapper(_QualityMapper): name = ImageGenerationParameter.QUALITY - def map( - self, - request: dict[str, Any], - value: object, - model: Model, - ) -> dict[str, Any]: - """Transform quality into provider request. - - Controls image quality/detail level. - - DALL-E 3: "standard" or "hd" - - gpt-image-1: "low", "medium", "high", or "auto" - - gpt-image-1-mini: "low", "medium", "high", or "auto" - - DALL-E 2: Not supported (no constraint in model) - - Args: - request: Provider request dictionary to modify. - value: The quality value. - model: Model instance with parameter constraints. - - Returns: - Modified request dictionary with quality parameter. - """ - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - - # Transform to provider-specific request format (top-level field) - request["quality"] = validated_value - return request - OPENAI_PARAMETER_MAPPERS: list[ParameterMapper] = [ AspectRatioMapper(), diff --git a/packages/capabilities/image-generation/tests/integration_tests/test_image_generation/__init__.py b/packages/capabilities/image-generation/tests/integration_tests/test_image_generation/__init__.py index 6b6119e1..e1754940 100644 --- a/packages/capabilities/image-generation/tests/integration_tests/test_image_generation/__init__.py +++ b/packages/capabilities/image-generation/tests/integration_tests/test_image_generation/__init__.py @@ -1 +1 @@ -"""Integration tests for image generation capability.""" +"""Image generation integration test module.""" diff --git a/packages/capabilities/speech-generation/pyproject.toml b/packages/capabilities/speech-generation/pyproject.toml index 93a47f2e..e31e8811 100644 --- a/packages/capabilities/speech-generation/pyproject.toml +++ b/packages/capabilities/speech-generation/pyproject.toml @@ -27,6 +27,7 @@ Issues = "https://github.com/withceleste/celeste-python/issues" [tool.uv.sources] celeste-ai = { workspace = true } +celeste-openai = { workspace = true } [project.entry-points."celeste.packages"] speech-generation = "celeste_speech_generation:register_package" diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/client.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/client.py index 1401cb53..9e8f0890 100644 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/client.py +++ b/packages/capabilities/speech-generation/src/celeste_speech_generation/client.py @@ -38,14 +38,12 @@ def _parse_content( """Parse content from provider response.""" def _create_inputs( - self, - *args: str, - text: str | None = None, - **parameters: Unpack[SpeechGenerationParameters], + self, *args: str, **parameters: Unpack[SpeechGenerationParameters] ) -> SpeechGenerationInput: """Map positional arguments to Input type.""" if args: return SpeechGenerationInput(text=args[0]) + text: str | None = parameters.get("text") if text is None: msg = "text is required (either as positional argument or keyword argument)" raise ValidationError(msg) diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/constraints.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/constraints.py index 5d6b7aa4..f7106960 100644 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/constraints.py +++ b/packages/capabilities/speech-generation/src/celeste_speech_generation/constraints.py @@ -8,10 +8,7 @@ class VoiceConstraint(Constraint): - """Voice constraint - value must be a valid voice ID or name from the provided voices. - - Accepts both voice IDs and names. If a name is provided, returns the corresponding ID. - """ + """Voice constraint - value must be a valid voice ID from the provided voices.""" voices: list[Voice] = Field(min_length=1) diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/io.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/io.py index f6b0a191..ff67dccf 100644 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/io.py +++ b/packages/capabilities/speech-generation/src/celeste_speech_generation/io.py @@ -1,7 +1,7 @@ """Input and output types for speech generation.""" from celeste.artifacts import AudioArtifact -from celeste.io import Chunk, Input, Output, Usage +from celeste.io import Chunk, FinishReason, Input, Output, Usage class SpeechGenerationInput(Input): @@ -17,6 +17,10 @@ class SpeechGenerationUsage(Usage): """ +class SpeechGenerationFinishReason(FinishReason): + """Finish reason for speech generation.""" + + class SpeechGenerationOutput(Output[AudioArtifact]): """Output with audio artifact content.""" @@ -24,9 +28,7 @@ class SpeechGenerationOutput(Output[AudioArtifact]): class SpeechGenerationChunk(Chunk[bytes]): """Typed chunk for speech generation streaming. - Note: Unlike TextGenerationChunk, this class intentionally omits a finish_reason - field. TTS providers stream raw audio bytes without completion signals - the - stream simply ends when audio generation is complete. + Speech streaming sends raw bytes without finish_reason. """ usage: SpeechGenerationUsage | None = None @@ -34,6 +36,7 @@ class SpeechGenerationChunk(Chunk[bytes]): __all__ = [ "SpeechGenerationChunk", + "SpeechGenerationFinishReason", "SpeechGenerationInput", "SpeechGenerationOutput", "SpeechGenerationUsage", diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/parameters.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/parameters.py index af54865d..784da3e9 100644 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/parameters.py +++ b/packages/capabilities/speech-generation/src/celeste_speech_generation/parameters.py @@ -10,8 +10,9 @@ class SpeechGenerationParameter(StrEnum): VOICE = "voice" SPEED = "speed" - RESPONSE_FORMAT = "response_format" + OUTPUT_FORMAT = "output_format" PROMPT = "prompt" + LANGUAGE = "language" class SpeechGenerationParameters(Parameters): @@ -19,5 +20,5 @@ class SpeechGenerationParameters(Parameters): voice: str | None speed: float | None - response_format: str | None + output_format: str | None prompt: str | None diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/models.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/models.py index c039e83c..1e0b8c5e 100644 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/models.py +++ b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/models.py @@ -16,7 +16,7 @@ parameter_constraints={ SpeechGenerationParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES), SpeechGenerationParameter.SPEED: Range(min=0.7, max=1.2), - SpeechGenerationParameter.RESPONSE_FORMAT: Choice( + SpeechGenerationParameter.OUTPUT_FORMAT: Choice( options=[ "mp3_44100_128", "pcm_22050_16", @@ -34,7 +34,7 @@ parameter_constraints={ SpeechGenerationParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES), SpeechGenerationParameter.SPEED: Range(min=0.7, max=1.2), - SpeechGenerationParameter.RESPONSE_FORMAT: Choice( + SpeechGenerationParameter.OUTPUT_FORMAT: Choice( options=[ "mp3_44100_128", "pcm_22050_16", @@ -52,7 +52,7 @@ parameter_constraints={ SpeechGenerationParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES), SpeechGenerationParameter.SPEED: Range(min=0.7, max=1.2), - SpeechGenerationParameter.RESPONSE_FORMAT: Choice( + SpeechGenerationParameter.OUTPUT_FORMAT: Choice( options=[ "mp3_44100_128", "pcm_22050_16", @@ -70,7 +70,7 @@ parameter_constraints={ SpeechGenerationParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES), SpeechGenerationParameter.SPEED: Range(min=0.7, max=1.2), - SpeechGenerationParameter.RESPONSE_FORMAT: Choice( + SpeechGenerationParameter.OUTPUT_FORMAT: Choice( options=[ "mp3_44100_128", "pcm_22050_16", @@ -88,7 +88,7 @@ parameter_constraints={ SpeechGenerationParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES), SpeechGenerationParameter.SPEED: Range(min=0.7, max=1.2), - SpeechGenerationParameter.RESPONSE_FORMAT: Choice( + SpeechGenerationParameter.OUTPUT_FORMAT: Choice( options=[ "mp3_44100_128", "pcm_22050_16", @@ -106,7 +106,7 @@ parameter_constraints={ SpeechGenerationParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES), SpeechGenerationParameter.SPEED: Range(min=0.7, max=1.2), - SpeechGenerationParameter.RESPONSE_FORMAT: Choice( + SpeechGenerationParameter.OUTPUT_FORMAT: Choice( options=[ "mp3_44100_128", "pcm_22050_16", @@ -124,7 +124,7 @@ parameter_constraints={ SpeechGenerationParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES), SpeechGenerationParameter.SPEED: Range(min=0.7, max=1.2), - SpeechGenerationParameter.RESPONSE_FORMAT: Choice( + SpeechGenerationParameter.OUTPUT_FORMAT: Choice( options=[ "mp3_44100_128", "pcm_22050_16", @@ -142,7 +142,7 @@ parameter_constraints={ SpeechGenerationParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES), SpeechGenerationParameter.SPEED: Range(min=0.7, max=1.2), - SpeechGenerationParameter.RESPONSE_FORMAT: Choice( + SpeechGenerationParameter.OUTPUT_FORMAT: Choice( options=[ "mp3_44100_128", "pcm_22050_16", diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/parameters.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/parameters.py index 12e7271e..543353bc 100644 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/parameters.py +++ b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/parameters.py @@ -48,7 +48,7 @@ def map( class OutputFormatMapper(ParameterMapper): """Map response_format parameter to ElevenLabs output_format field.""" - name = SpeechGenerationParameter.RESPONSE_FORMAT + name = SpeechGenerationParameter.OUTPUT_FORMAT def map( self, diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/openai/client.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/openai/client.py index f9685802..a21fec62 100644 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/openai/client.py +++ b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/openai/client.py @@ -2,10 +2,9 @@ from typing import Any, Unpack -import httpx +from celeste_openai.audio.client import OpenAIAudioClient from celeste.artifacts import AudioArtifact -from celeste.mime_types import ApplicationMimeType, AudioMimeType from celeste.parameters import ParameterMapper from celeste_speech_generation.client import SpeechGenerationClient from celeste_speech_generation.io import ( @@ -13,13 +12,15 @@ SpeechGenerationOutput, SpeechGenerationUsage, ) -from celeste_speech_generation.parameters import SpeechGenerationParameters +from celeste_speech_generation.parameters import ( + SpeechGenerationParameter, + SpeechGenerationParameters, +) -from . import config from .parameters import OPENAI_PARAMETER_MAPPERS -class OpenAISpeechGenerationClient(SpeechGenerationClient): +class OpenAISpeechGenerationClient(OpenAIAudioClient, SpeechGenerationClient): """OpenAI client for speech generation.""" @classmethod @@ -31,11 +32,9 @@ def _init_request(self, inputs: SpeechGenerationInput) -> dict[str, Any]: return {"input": inputs.text} def _parse_usage(self, response_data: dict[str, Any]) -> SpeechGenerationUsage: - """Parse usage from response. - - OpenAI TTS doesn't return usage metrics in response. - """ - return SpeechGenerationUsage() + """Parse usage from response.""" + usage = super()._parse_usage(response_data) + return SpeechGenerationUsage(**usage) def _parse_content( self, @@ -51,39 +50,6 @@ def _parse_content( msg = "OpenAI TTS returns binary responses, use generate() override" raise NotImplementedError(msg) - def _map_response_format_to_mime_type( - self, response_format: str | None - ) -> AudioMimeType: - """Map OpenAI response_format to AudioMimeType.""" - format_map: dict[str, AudioMimeType] = { - "mp3": AudioMimeType.MP3, - "opus": AudioMimeType.OGG, # OGG is closest match for Opus - "aac": AudioMimeType.AAC, - "flac": AudioMimeType.FLAC, - } - return format_map.get( - response_format or "", AudioMimeType.MP3 - ) # Default to MP3 - - async def _make_request( - self, - request_body: dict[str, Any], - **parameters: Unpack[SpeechGenerationParameters], - ) -> httpx.Response: - """Make HTTP request(s) and return response object.""" - request_body["model"] = self.model.id - - 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, - ) - async def generate( self, *args: str, @@ -105,9 +71,9 @@ async def generate( msg = "No audio data in response" raise ValueError(msg) - # Determine MIME type from response_format parameter (default to mp3) - response_format = parameters.get("response_format") or "mp3" - mime_type = self._map_response_format_to_mime_type(response_format) + # Determine MIME type from output_format parameter (default to mp3) + output_format = parameters.get(SpeechGenerationParameter.OUTPUT_FORMAT) or "mp3" + mime_type = self._map_response_format_to_mime_type(output_format) # Extract headers from response (OpenAI may return metadata in headers) headers_dict = dict(response.headers) diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/openai/config.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/openai/config.py deleted file mode 100644 index 226ded5a..00000000 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/openai/config.py +++ /dev/null @@ -1,9 +0,0 @@ -"""OpenAI provider configuration for speech generation.""" - -# HTTP Configuration -BASE_URL = "https://api.openai.com" -ENDPOINT = "/v1/audio/speech" - -# Authentication -AUTH_HEADER_NAME = "Authorization" -AUTH_HEADER_PREFIX = "Bearer " diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/openai/models.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/openai/models.py index f4771222..d8dd2cf5 100644 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/openai/models.py +++ b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/openai/models.py @@ -25,7 +25,7 @@ parameter_constraints={ SpeechGenerationParameter.VOICE: VoiceConstraint(voices=TTS1_VOICES), SpeechGenerationParameter.SPEED: Range(min=0.25, max=4.0), - SpeechGenerationParameter.RESPONSE_FORMAT: Choice( + SpeechGenerationParameter.OUTPUT_FORMAT: Choice( options=_RESPONSE_FORMAT_OPTIONS ), }, @@ -38,7 +38,7 @@ parameter_constraints={ SpeechGenerationParameter.VOICE: VoiceConstraint(voices=TTS1_HD_VOICES), SpeechGenerationParameter.SPEED: Range(min=0.25, max=4.0), - SpeechGenerationParameter.RESPONSE_FORMAT: Choice( + SpeechGenerationParameter.OUTPUT_FORMAT: Choice( options=_RESPONSE_FORMAT_OPTIONS ), }, @@ -53,7 +53,7 @@ voices=GPT4O_MINI_TTS_VOICES ), SpeechGenerationParameter.SPEED: Range(min=0.25, max=4.0), - SpeechGenerationParameter.RESPONSE_FORMAT: Choice( + SpeechGenerationParameter.OUTPUT_FORMAT: Choice( options=_RESPONSE_FORMAT_OPTIONS ), }, diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/openai/parameters.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/openai/parameters.py index f824f253..287c1deb 100644 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/openai/parameters.py +++ b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/openai/parameters.py @@ -1,132 +1,35 @@ -"""OpenAI parameter mappers for speech generation.""" +"""OpenAI Audio parameter mappers for speech generation.""" + +from celeste_openai.audio.parameters import ( + ResponseFormatMapper as _ResponseFormatMapper, +) +from celeste_openai.audio.parameters import ( + SpeedMapper as _SpeedMapper, +) +from celeste_openai.audio.parameters import ( + VoiceMapper as _VoiceMapper, +) -from typing import Any - -from celeste.mime_types import AudioMimeType -from celeste.models import Model from celeste.parameters import ParameterMapper from celeste_speech_generation.parameters import SpeechGenerationParameter -class VoiceMapper(ParameterMapper): - """Map voice parameter to OpenAI voice field.""" - +class VoiceMapper(_VoiceMapper): name = SpeechGenerationParameter.VOICE - def map( - self, - request: dict[str, Any], - value: object, - model: Model, - ) -> dict[str, Any]: - """Transform voice into provider request. - - Maps the unified voice parameter to the OpenAI API voice field. - - Args: - request: Provider request dictionary to modify. - value: The voice ID or name (e.g., 'alloy', 'echo', 'nova'). - model: Model instance with parameter constraints. - - Returns: - Modified request dictionary with voice parameter. - """ - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - - request["voice"] = validated_value - return request - - -class SpeedMapper(ParameterMapper): - """Map speed parameter to OpenAI speed field.""" +class SpeedMapper(_SpeedMapper): name = SpeechGenerationParameter.SPEED - def map( - self, - request: dict[str, Any], - value: object, - model: Model, - ) -> dict[str, Any]: - """Transform speed into provider request. - - Maps the unified speed parameter to the OpenAI API speed field. - Valid range is 0.25 to 4.0. - - Args: - request: Provider request dictionary to modify. - value: The playback speed multiplier (0.25 to 4.0). - model: Model instance with parameter constraints. - - Returns: - Modified request dictionary with speed parameter. - """ - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - - request["speed"] = validated_value - return request - - -class ResponseFormatMapper(ParameterMapper): - """Map response_format parameter to OpenAI response_format field.""" - - name = SpeechGenerationParameter.RESPONSE_FORMAT - - def map( - self, - request: dict[str, Any], - value: object, - model: Model, - ) -> dict[str, Any]: - """Transform response_format into provider request. - - Maps the unified response_format parameter to the OpenAI API format. - Accepts both string values ('mp3', 'opus') and AudioMimeType enums. - - Args: - request: Provider request dictionary to modify. - value: Output format as string or AudioMimeType (mp3, opus, aac, flac). - model: Model instance with parameter constraints. - - Returns: - Modified request dictionary with response_format parameter. - """ - # Convert string values to AudioMimeType enum before validation - if isinstance(value, str) and not isinstance(value, AudioMimeType): - string_to_mime_type: dict[str, AudioMimeType] = { - "mp3": AudioMimeType.MP3, - "opus": AudioMimeType.OGG, # OpenAI uses "opus" for OGG format - "aac": AudioMimeType.AAC, - "flac": AudioMimeType.FLAC, - } - value = string_to_mime_type.get(value.lower(), value) - - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - - # Convert AudioMimeType enum to OpenAI string format - mime_type_to_openai_format: dict[AudioMimeType, str] = { - AudioMimeType.MP3: "mp3", - AudioMimeType.OGG: "opus", # OpenAI uses "opus" for OGG format - AudioMimeType.AAC: "aac", - AudioMimeType.FLAC: "flac", - } - # validated_value is now guaranteed to be AudioMimeType after constraint validation - response_format = mime_type_to_openai_format.get(validated_value, "mp3") - request["response_format"] = response_format - return request +class OutputFormatMapper(_ResponseFormatMapper): + name = SpeechGenerationParameter.OUTPUT_FORMAT OPENAI_PARAMETER_MAPPERS: list[ParameterMapper] = [ VoiceMapper(), SpeedMapper(), - ResponseFormatMapper(), + OutputFormatMapper(), ] __all__ = ["OPENAI_PARAMETER_MAPPERS"] diff --git a/packages/capabilities/speech-generation/tests/integration_tests/test_speech_generation/__init__.py b/packages/capabilities/speech-generation/tests/integration_tests/test_speech_generation/__init__.py index e69de29b..23470cb4 100644 --- a/packages/capabilities/speech-generation/tests/integration_tests/test_speech_generation/__init__.py +++ b/packages/capabilities/speech-generation/tests/integration_tests/test_speech_generation/__init__.py @@ -0,0 +1 @@ +"""Speech generation integration test module.""" diff --git a/packages/capabilities/speech-generation/tests/integration_tests/test_speech_generation/test_generate.py b/packages/capabilities/speech-generation/tests/integration_tests/test_speech_generation/test_generate.py index 4a82f36b..1b356930 100644 --- a/packages/capabilities/speech-generation/tests/integration_tests/test_speech_generation/test_generate.py +++ b/packages/capabilities/speech-generation/tests/integration_tests/test_speech_generation/test_generate.py @@ -8,16 +8,16 @@ @pytest.mark.parametrize( ("provider", "model", "parameters"), [ - (Provider.OPENAI, "tts-1", {"voice": "alloy", "response_format": "mp3"}), + (Provider.OPENAI, "tts-1", {"voice": "alloy", "output_format": "mp3"}), ( Provider.GOOGLE, - "gemini-2.5-flash-preview-tts", + "gemini-2.5-flash-tts", {"voice": "Zephyr", "speed": 1.0}, ), ( Provider.ELEVENLABS, "eleven_flash_v2_5", - {"voice": "Laura", "response_format": "mp3_44100_128"}, + {"voice": "Rachel", "output_format": "mp3_44100_128"}, ), ], ) @@ -42,13 +42,13 @@ async def test_generate(provider: Provider, model: str, parameters: dict) -> Non client = create_client( capability=Capability.SPEECH_GENERATION, provider=provider, + model=model, ) text = "Hello, this is a test of the Celeste speech generation capability." # Act response = await client.generate( text=text, - model=model, **parameters, ) @@ -62,12 +62,10 @@ async def test_generate(provider: Provider, model: str, parameters: dict) -> Non assert response.content.has_content, ( f"AudioArtifact has no content (data/path): {response.content}" ) - assert response.content.data is not None, "AudioArtifact data is None" + assert response.content.data is not None, "Audio data is None" assert len(response.content.data) > 0, "Audio data is empty" # Validate usage metrics assert isinstance(response.usage, SpeechGenerationUsage), ( f"Expected SpeechGenerationUsage, got {type(response.usage)}" ) - # Note: Speech generation providers typically don't return usage metrics. - # Usage object exists for API consistency but fields are empty. diff --git a/packages/capabilities/speech-generation/tests/integration_tests/test_speech_generation/test_stream.py b/packages/capabilities/speech-generation/tests/integration_tests/test_speech_generation/test_stream.py index 437a295e..999a554f 100644 --- a/packages/capabilities/speech-generation/tests/integration_tests/test_speech_generation/test_stream.py +++ b/packages/capabilities/speech-generation/tests/integration_tests/test_speech_generation/test_stream.py @@ -13,7 +13,7 @@ ( Provider.ELEVENLABS, "eleven_flash_v2_5", - {"voice": "Laura", "response_format": "mp3_44100_128"}, + {"voice": "Rachel", "output_format": "mp3_44100_128"}, ), ], ) @@ -34,6 +34,7 @@ async def test_stream(provider: Provider, model: str, parameters: dict) -> None: client = create_client( capability=Capability.SPEECH_GENERATION, provider=provider, + model=model, ) text = "Hello, this is a streaming test." @@ -41,7 +42,6 @@ async def test_stream(provider: Provider, model: str, parameters: dict) -> None: chunks = [] async for chunk in client.stream( text=text, - model=model, **parameters, ): assert isinstance(chunk, SpeechGenerationChunk) diff --git a/packages/capabilities/text-generation/pyproject.toml b/packages/capabilities/text-generation/pyproject.toml index cec99f42..6fa4b4ac 100644 --- a/packages/capabilities/text-generation/pyproject.toml +++ b/packages/capabilities/text-generation/pyproject.toml @@ -29,6 +29,7 @@ Issues = "https://github.com/withceleste/celeste-python/issues" celeste-ai = { workspace = true } celeste-anthropic = { workspace = true } celeste-google = { workspace = true } +celeste-openai = { workspace = true } [project.entry-points."celeste.packages"] text-generation = "celeste_text_generation:register_package" diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/google/config.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/google/config.py deleted file mode 100644 index cd20a29e..00000000 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/google/config.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Google provider configuration for text generation.""" - -# HTTP Configuration -BASE_URL = "https://generativelanguage.googleapis.com" -ENDPOINT = "/v1beta/models/{model_id}:generateContent" -STREAM_ENDPOINT = "/v1beta/models/{model_id}:streamGenerateContent?alt=sse" - -# Authentication -AUTH_HEADER_NAME = "x-goog-api-key" -AUTH_HEADER_PREFIX = "" # Empty string for plain key diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/openai/client.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/openai/client.py index ea9fedcb..e66272da 100644 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/openai/client.py +++ b/packages/capabilities/text-generation/src/celeste_text_generation/providers/openai/client.py @@ -1,13 +1,11 @@ """OpenAI client implementation for text generation.""" -from collections.abc import AsyncIterator from typing import Any, Unpack -import httpx -from pydantic import BaseModel +from celeste_openai.responses.client import OpenAIResponsesClient -from celeste.mime_types import ApplicationMimeType from celeste.parameters import ParameterMapper +from celeste.types import StructuredOutput from celeste_text_generation.client import TextGenerationClient from celeste_text_generation.io import ( TextGenerationFinishReason, @@ -16,12 +14,11 @@ ) from celeste_text_generation.parameters import TextGenerationParameters -from . import config from .parameters import OPENAI_PARAMETER_MAPPERS from .streaming import OpenAITextGenerationStream -class OpenAITextGenerationClient(TextGenerationClient): +class OpenAITextGenerationClient(OpenAIResponsesClient, TextGenerationClient): """OpenAI client for text generation.""" @classmethod @@ -34,120 +31,35 @@ def _init_request(self, inputs: TextGenerationInput) -> dict[str, Any]: def _parse_usage(self, response_data: dict[str, Any]) -> TextGenerationUsage: """Parse usage from response.""" - usage_data = response_data.get("usage", {}) - input_tokens_details = usage_data.get("input_tokens_details", {}) - output_tokens_details = usage_data.get("output_tokens_details", {}) - - return TextGenerationUsage( - input_tokens=usage_data.get("input_tokens"), - output_tokens=usage_data.get("output_tokens"), - total_tokens=usage_data.get("total_tokens"), - cached_tokens=input_tokens_details.get("cached_tokens"), - reasoning_tokens=output_tokens_details.get("reasoning_tokens"), - ) + usage = super()._parse_usage(response_data) + return TextGenerationUsage(**usage) def _parse_content( self, response_data: dict[str, Any], **parameters: Unpack[TextGenerationParameters], - ) -> str | BaseModel: + ) -> StructuredOutput: """Parse content from response.""" - output_items = response_data.get("output", []) - if not output_items: - msg = "No output items in response" - raise ValueError(msg) - - message_item = None - for item in output_items: + output = super()._parse_content(response_data) # Raw output array + # Find message item and extract text + for item in output: if item.get("type") == "message": - message_item = item - break - - if not message_item: - msg = "No message item found in output array" - raise ValueError(msg) - - content_parts = message_item.get("content", []) - if not content_parts: - msg = "No content parts in message item" - raise ValueError(msg) - - text_content = "" - for content_part in content_parts: - if content_part.get("type") == "output_text": - text_content = content_part.get("text") or "" - break - - return self._transform_output(text_content, **parameters) + for part in item.get("content", []): + if part.get("type") == "output_text": + text = part.get("text") or "" + return self._transform_output(text, **parameters) + return "" def _parse_finish_reason( self, response_data: dict[str, Any] - ) -> TextGenerationFinishReason | None: + ) -> TextGenerationFinishReason: """Parse finish reason from response.""" - status = response_data.get("status") - if status != "completed": - return None - - output_items = response_data.get("output", []) - for item in output_items: - if item.get("type") == "message": - item_status = item.get("status") - if item_status == "completed": - return TextGenerationFinishReason(reason="completed") - - return None - - def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: - """Build metadata dictionary from response data.""" - # Filter content field before calling super - content_fields = {"output"} - filtered_data = { - k: v for k, v in response_data.items() if k not in content_fields - } - return super()._build_metadata(filtered_data) - - async def _make_request( - self, - request_body: dict[str, Any], - **parameters: Unpack[TextGenerationParameters], - ) -> httpx.Response: - """Make HTTP request(s) and return response object.""" - request_body["model"] = self.model.id - - 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, - ) + finish_reason = super()._parse_finish_reason(response_data) + return TextGenerationFinishReason(reason=finish_reason.reason) def _stream_class(self) -> type[OpenAITextGenerationStream]: """Return the Stream class for this client.""" return OpenAITextGenerationStream - def _make_stream_request( - self, - request_body: dict[str, Any], - **parameters: Unpack[TextGenerationParameters], - ) -> AsyncIterator[dict[str, Any]]: - """Make HTTP streaming request and return async iterator of events.""" - request_body["model"] = self.model.id - request_body["stream"] = True - - headers = { - **self.auth.get_headers(), - "Content-Type": ApplicationMimeType.JSON, - } - - return self.http_client.stream_post( - f"{config.BASE_URL}{config.STREAM_ENDPOINT}", - headers=headers, - json_body=request_body, - ) - __all__ = ["OpenAITextGenerationClient"] diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/openai/config.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/openai/config.py deleted file mode 100644 index 8c72f1d4..00000000 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/openai/config.py +++ /dev/null @@ -1,10 +0,0 @@ -"""OpenAI provider configuration for text generation.""" - -# HTTP Configuration -BASE_URL = "https://api.openai.com" -ENDPOINT = "/v1/responses" -STREAM_ENDPOINT = ENDPOINT - -# Authentication -AUTH_HEADER_NAME = "Authorization" -AUTH_HEADER_PREFIX = "Bearer " diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/openai/parameters.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/openai/parameters.py index 06a32549..b86a0068 100644 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/openai/parameters.py +++ b/packages/capabilities/text-generation/src/celeste_text_generation/providers/openai/parameters.py @@ -1,225 +1,44 @@ -"""OpenAI parameter mappers for text generation.""" - -import json -from typing import Any, get_args, get_origin - -from pydantic import BaseModel, TypeAdapter +"""OpenAI Responses parameter mappers for text generation.""" + +from celeste_openai.responses.parameters import ( + MaxTokensMapper as _MaxTokensMapper, +) +from celeste_openai.responses.parameters import ( + OutputSchemaMapper as _OutputSchemaMapper, +) +from celeste_openai.responses.parameters import ( + ReasoningEffortMapper as _ReasoningEffortMapper, +) +from celeste_openai.responses.parameters import ( + TemperatureMapper as _TemperatureMapper, +) +from celeste_openai.responses.parameters import ( + VerbosityMapper as _VerbosityMapper, +) from celeste.core import Parameter -from celeste.models import Model from celeste.parameters import ParameterMapper from celeste_text_generation.parameters import TextGenerationParameter -class OutputSchemaMapper(ParameterMapper): - """Map output_schema parameter to OpenAI text.format.""" - - name = TextGenerationParameter.OUTPUT_SCHEMA - - def map( - self, - request: dict[str, Any], - value: object, - model: Model, - ) -> dict[str, Any]: - """Transform output_schema into provider request.""" - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - - schema = self._convert_to_openai_schema(validated_value) - schema_name = self._get_schema_name(validated_value) - - request.setdefault("text", {})["format"] = { - "type": "json_schema", - "name": schema_name, - "strict": True, - "schema": schema, - } - - return request - - def parse_output(self, content: str, value: object | None) -> str | BaseModel: - """Parse JSON string to BaseModel instance if output_schema provided.""" - if value is None: - return content - - parsed_json = json.loads(content) - origin = get_origin(value) - if origin is list and isinstance(parsed_json, dict) and "items" in parsed_json: - parsed_json = parsed_json["items"] - - return TypeAdapter(value).validate_json(json.dumps(parsed_json)) - - def _convert_to_openai_schema(self, output_schema: Any) -> dict[str, Any]: # noqa: ANN401 - """Convert Pydantic BaseModel or list[BaseModel] to OpenAI JSON Schema format.""" - origin = get_origin(output_schema) - if origin is list: - inner_type = get_args(output_schema)[0] - items_schema = inner_type.model_json_schema() - json_schema = { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": items_schema, - } - }, - "required": ["items"], - } - else: - json_schema = output_schema.model_json_schema() - - json_schema = self._transform_schema_for_openai(json_schema) - return json_schema - - def _transform_schema_for_openai( - self, schema: dict[str, Any], defs: dict[str, Any] | None = None - ) -> dict[str, Any]: - """Recursively transform schema for OpenAI Responses API.""" - if not isinstance(schema, dict): - return schema - - if defs is None: - defs = self._collect_all_defs(schema) - - if "$ref" in schema: - ref_path = schema["$ref"] - if ref_path.startswith("#/$defs/"): - def_name = ref_path.split("/")[-1] - if def_name in defs: - expanded = defs[def_name].copy() - expanded.pop("description", None) - return self._transform_schema_for_openai(expanded, defs) - return schema - - result: dict[str, Any] = {} - for key, value in schema.items(): - if key == "$defs": - continue - elif isinstance(value, dict): - result[key] = self._transform_schema_for_openai(value, defs) - elif isinstance(value, list): - result[key] = [ - self._transform_schema_for_openai(item, defs) - if isinstance(item, dict) - else item - for item in value - ] - else: - result[key] = value - - if result.get("type") == "object": - result["additionalProperties"] = False - - return result - - def _collect_all_defs(self, schema: Any) -> dict[str, Any]: # noqa: ANN401 - """Recursively collect all $defs dictionaries from schema tree.""" - defs: dict[str, Any] = {} - - def collect(value: Any) -> None: # noqa: ANN401 - if isinstance(value, dict): - if "$defs" in value: - defs.update(value["$defs"]) - for v in value.values(): - collect(v) - elif isinstance(value, list): - for item in value: - collect(item) - - collect(schema) - return defs - - def _get_schema_name(self, output_schema: Any) -> str: # noqa: ANN401 - """Derive schema name from model class name.""" - origin = get_origin(output_schema) - if origin is list: - inner_type = get_args(output_schema)[0] - class_name = inner_type.__name__ - return f"{class_name.lower()}_list" - else: - return output_schema.__name__.lower() - - -class TemperatureMapper(ParameterMapper): - """Map temperature parameter to OpenAI temperature field.""" - +class TemperatureMapper(_TemperatureMapper): name = Parameter.TEMPERATURE - def map( - self, - request: dict[str, Any], - value: object, - model: Model, - ) -> dict[str, Any]: - """Transform temperature into provider request.""" - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - - request["temperature"] = validated_value - return request - - -class MaxTokensMapper(ParameterMapper): - """Map max_tokens parameter to OpenAI max_output_tokens field.""" +class MaxTokensMapper(_MaxTokensMapper): name = Parameter.MAX_TOKENS - def map( - self, - request: dict[str, Any], - value: object, - model: Model, - ) -> dict[str, Any]: - """Transform max_tokens into provider request.""" - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - - request["max_output_tokens"] = validated_value - return request - - -class ThinkingBudgetMapper(ParameterMapper): - """Map thinking_budget parameter to OpenAI reasoning.effort field.""" +class ThinkingBudgetMapper(_ReasoningEffortMapper): name = TextGenerationParameter.THINKING_BUDGET - def map( - self, - request: dict[str, Any], - value: object, - model: Model, - ) -> dict[str, Any]: - """Transform thinking_budget into provider request.""" - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - - request.setdefault("reasoning", {})["effort"] = validated_value - return request - - -class VerbosityMapper(ParameterMapper): - """Map verbosity parameter to OpenAI text.verbosity field.""" +class VerbosityMapper(_VerbosityMapper): name = TextGenerationParameter.VERBOSITY - def map( - self, - request: dict[str, Any], - value: object, - model: Model, - ) -> dict[str, Any]: - """Transform verbosity into provider request.""" - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - request.setdefault("text", {})["verbosity"] = validated_value - return request +class OutputSchemaMapper(_OutputSchemaMapper): + name = TextGenerationParameter.OUTPUT_SCHEMA OPENAI_PARAMETER_MAPPERS: list[ParameterMapper] = [ diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/openai/streaming.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/openai/streaming.py index 8bf708d4..58952e82 100644 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/openai/streaming.py +++ b/packages/capabilities/text-generation/src/celeste_text_generation/providers/openai/streaming.py @@ -3,6 +3,9 @@ from collections.abc import Callable from typing import Any, Unpack +from celeste_openai.responses.streaming import OpenAIResponsesStream + +from celeste.types import StructuredOutput from celeste_text_generation.io import ( TextGenerationChunk, TextGenerationFinishReason, @@ -13,13 +16,13 @@ from celeste_text_generation.streaming import TextGenerationStream -class OpenAITextGenerationStream(TextGenerationStream): +class OpenAITextGenerationStream(OpenAIResponsesStream, TextGenerationStream): """OpenAI streaming for text generation.""" def __init__( self, sse_iterator: Any, # noqa: ANN401 - transform_output: Callable[..., object], + transform_output: Callable[..., StructuredOutput], **parameters: Unpack[TextGenerationParameters], ) -> None: """Initialize stream.""" @@ -27,62 +30,30 @@ def __init__( self._transform_output = transform_output def _parse_chunk(self, event: dict[str, Any]) -> TextGenerationChunk | None: - """Parse SSE event into Chunk.""" - event_type = event.get("type") - if not event_type: - return None - - if event_type == "response.output_text.delta": - delta = event.get("delta") - if delta is None: - return None - return TextGenerationChunk( - content=delta, - finish_reason=None, - usage=None, - ) - - if event_type == "response.output_text.done": + """Parse SSE event into typed Chunk.""" + raw = super()._parse_chunk(event) + if not raw: return None - if event_type == "response.completed": - response_data = event.get("response", {}) - usage_data = response_data.get("usage") - - usage: TextGenerationUsage | None = None - if usage_data: - input_tokens_details = usage_data.get("input_tokens_details", {}) - output_tokens_details = usage_data.get("output_tokens_details", {}) - usage = TextGenerationUsage( - input_tokens=usage_data.get("input_tokens"), - output_tokens=usage_data.get("output_tokens"), - total_tokens=usage_data.get("total_tokens"), - cached_tokens=input_tokens_details.get("cached_tokens"), - reasoning_tokens=output_tokens_details.get("reasoning_tokens"), - ) - - finish_reason: TextGenerationFinishReason | None = None - status = response_data.get("status") - if status == "completed": - finish_reason = TextGenerationFinishReason(reason="completed") - - return TextGenerationChunk( - content="", - finish_reason=finish_reason, - usage=usage, - ) + usage = TextGenerationUsage(**raw["usage"]) if raw["usage"] else None + finish_reason = ( + TextGenerationFinishReason(reason=raw["finish_reason"]) + if raw["finish_reason"] + else None + ) - return None + return TextGenerationChunk( + content=raw["content"], + finish_reason=finish_reason, + usage=usage, + metadata={"raw_event": raw["raw_event"]}, + ) def _parse_usage(self, chunks: list[TextGenerationChunk]) -> TextGenerationUsage: - """Parse usage from chunks.""" - if not chunks: - return TextGenerationUsage() - + """Extract usage from final chunk.""" for chunk in reversed(chunks): if chunk.usage: return chunk.usage - return TextGenerationUsage() def _parse_output( @@ -96,11 +67,18 @@ def _parse_output( usage = self._parse_usage(chunks) finish_reason = chunks[-1].finish_reason if chunks else None + raw_response = None + for chunk in reversed(chunks): + raw_event = chunk.metadata.get("raw_event", {}) + if raw_event.get("type") == "response.completed": + raw_response = raw_event.get("response") + break + return TextGenerationOutput( content=content, usage=usage, finish_reason=finish_reason, - metadata={}, + metadata={"raw_response": raw_response}, ) diff --git a/packages/capabilities/video-generation/pyproject.toml b/packages/capabilities/video-generation/pyproject.toml index 6e6dcf96..63374299 100644 --- a/packages/capabilities/video-generation/pyproject.toml +++ b/packages/capabilities/video-generation/pyproject.toml @@ -31,6 +31,7 @@ Issues = "https://github.com/withceleste/celeste-python/issues" [tool.uv.sources] celeste-ai = { workspace = true } celeste-google = { workspace = true } +celeste-openai = { workspace = true } [project.entry-points."celeste.packages"] video-generation = "celeste_video_generation:register_package" diff --git a/packages/capabilities/video-generation/src/celeste_video_generation/providers/google/client.py b/packages/capabilities/video-generation/src/celeste_video_generation/providers/google/client.py index 51dd1403..4eed0f19 100644 --- a/packages/capabilities/video-generation/src/celeste_video_generation/providers/google/client.py +++ b/packages/capabilities/video-generation/src/celeste_video_generation/providers/google/client.py @@ -35,7 +35,8 @@ def _init_request(self, inputs: VideoGenerationInput) -> dict[str, Any]: def _parse_usage(self, response_data: dict[str, Any]) -> VideoGenerationUsage: """Parse usage from response.""" - return VideoGenerationUsage() + usage = super()._parse_usage(response_data) + return VideoGenerationUsage(**usage) def _parse_content( self, diff --git a/packages/capabilities/video-generation/src/celeste_video_generation/providers/openai/client.py b/packages/capabilities/video-generation/src/celeste_video_generation/providers/openai/client.py index e69b2700..01bedeb8 100644 --- a/packages/capabilities/video-generation/src/celeste_video_generation/providers/openai/client.py +++ b/packages/capabilities/video-generation/src/celeste_video_generation/providers/openai/client.py @@ -1,18 +1,16 @@ """OpenAI client implementation for video generation.""" -import asyncio import base64 import io import json -import logging from typing import Any, Unpack -import httpx +from celeste_openai.videos.client import OpenAIVideosClient from PIL import Image from celeste.artifacts import ImageArtifact, VideoArtifact from celeste.exceptions import ValidationError -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 ( @@ -21,13 +19,10 @@ ) from celeste_video_generation.parameters import VideoGenerationParameters -from . import config from .parameters import OPENAI_PARAMETER_MAPPERS -logger = logging.getLogger(__name__) - -class OpenAIVideoGenerationClient(VideoGenerationClient): +class OpenAIVideoGenerationClient(OpenAIVideosClient, VideoGenerationClient): """OpenAI client for video generation.""" @classmethod @@ -75,10 +70,8 @@ def _build_request( def _parse_usage(self, response_data: dict[str, Any]) -> VideoGenerationUsage: """Parse usage from response.""" - seconds = response_data.get("seconds") - return VideoGenerationUsage( - billing_units=float(seconds) if seconds else None, - ) + usage = super()._parse_usage(response_data) + return VideoGenerationUsage(**usage) def _parse_content( self, @@ -143,102 +136,5 @@ async def _prepare_multipart_request( return files, data - async def _make_request( - self, - request_body: dict[str, Any], - **parameters: Unpack[VideoGenerationParameters], - ) -> httpx.Response: - """Make HTTP request with async polling for OpenAI video generation.""" - headers = self.auth.get_headers() - - files, data = await self._prepare_multipart_request(request_body.copy()) - - if files: - logger.info("Sending multipart request to OpenAI with input_reference") - response = await self.http_client.post_multipart( - f"{config.BASE_URL}{config.ENDPOINT}", - headers=headers, - files=files, - data=data, - ) - else: - logger.info(f"Sending request to OpenAI: {request_body}") - response = await self.http_client.post( - f"{config.BASE_URL}{config.ENDPOINT}", - headers=headers, - json_body=request_body, - ) - self._handle_error_response(response) - video_obj = response.json() - - video_id = video_obj["id"] - logger.info(f"Created video job: {video_id}") - - for _ in range(config.MAX_POLLS): - status_response = await self.http_client.get( - f"{config.BASE_URL}{config.ENDPOINT}/{video_id}", - headers=headers, - ) - self._handle_error_response(status_response) - video_obj = status_response.json() - - status = video_obj["status"] - progress = video_obj.get("progress", 0) - - logger.info(f"Video {video_id}: {status} ({progress}%)") - - if status == config.STATUS_COMPLETED: - break - elif status == config.STATUS_FAILED: - error = video_obj.get("error", {}) - msg = ( - f"Video generation failed: {error.get('message', 'Unknown error')}" - ) - raise RuntimeError(msg) - - await asyncio.sleep(config.POLL_INTERVAL) - else: - msg = f"Video generation timeout after {config.MAX_POLLS * config.POLL_INTERVAL} seconds" - raise TimeoutError(msg) - - content_response = await self.http_client.get( - f"{config.BASE_URL}{config.ENDPOINT}/{video_id}{config.CONTENT_ENDPOINT_SUFFIX}", - headers=headers, - ) - self._handle_error_response(content_response) - video_data = content_response.content - - response_data = { - "video_data": base64.b64encode(video_data).decode("utf-8"), - "model": video_obj.get("model", self.model.id), - "video_id": video_id, - "seconds": video_obj.get("seconds"), - "size": video_obj.get("size"), - "created_at": video_obj.get("created_at"), - "completed_at": video_obj.get("completed_at"), - "expires_at": video_obj.get("expires_at"), - } - - return httpx.Response( - 200, - content=json.dumps(response_data).encode(), - headers={"Content-Type": ApplicationMimeType.JSON}, - ) - - def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: - """Build metadata from response data.""" - content_fields = {"video_data"} - filtered_data = { - k: v for k, v in response_data.items() if k not in content_fields - } - metadata = super()._build_metadata(filtered_data) - metadata["video_id"] = response_data.get("video_id") - metadata["seconds"] = response_data.get("seconds") - metadata["size"] = response_data.get("size") - metadata["created_at"] = response_data.get("created_at") - metadata["completed_at"] = response_data.get("completed_at") - metadata["expires_at"] = response_data.get("expires_at") - return metadata - __all__ = ["OpenAIVideoGenerationClient"] diff --git a/packages/capabilities/video-generation/src/celeste_video_generation/providers/openai/parameters.py b/packages/capabilities/video-generation/src/celeste_video_generation/providers/openai/parameters.py index 83a8a678..19fd2c3c 100644 --- a/packages/capabilities/video-generation/src/celeste_video_generation/providers/openai/parameters.py +++ b/packages/capabilities/video-generation/src/celeste_video_generation/providers/openai/parameters.py @@ -1,7 +1,14 @@ -"""OpenAI parameter mappers for video generation.""" +"""OpenAI Videos parameter mappers for video generation.""" from typing import Any +from celeste_openai.videos.parameters import ( + InputReferenceMapper as _InputReferenceMapper, +) +from celeste_openai.videos.parameters import ( + SecondsMapper as _SecondsMapper, +) + from celeste.models import Model from celeste.parameters import ParameterMapper from celeste_video_generation.parameters import VideoGenerationParameter @@ -25,8 +32,6 @@ def map( validated_value = self._validate_value(value, model) if validated_value is None: return request - - # Validate but don't transform (size derivation happens in client) return request @@ -48,68 +53,25 @@ def map( validated_value = self._validate_value(value, model) if validated_value is None: return request - - # Validate but don't transform (size derivation happens in client) return request -class DurationSecondsMapper(ParameterMapper): - """Map duration parameter to OpenAI API format. - - Converts user-facing int to API-required string. - """ +class DurationMapper(_SecondsMapper): + """Map duration parameter to OpenAI API format.""" name = VideoGenerationParameter.DURATION - def map( - self, - request: dict[str, Any], - value: object, - model: Model, - ) -> dict[str, Any]: - """Transform duration into provider request.""" - # Coerce int to string (user provides int, API expects string) - if isinstance(value, int): - value = str(value) - - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - - # Transform to provider-specific request format (top-level field) - request["seconds"] = validated_value - return request - -class FirstFrameMapper(ParameterMapper): - """Map first_frame parameter to OpenAI API format. - - OpenAI Sora's input_reference acts as the first frame of the video. - Image must match target video resolution. - Note: OpenAI uses multipart/form-data for file uploads. - """ +class FirstFrameMapper(_InputReferenceMapper): + """Map first_frame parameter to OpenAI API format.""" name = VideoGenerationParameter.FIRST_FRAME - 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 - - request["input_reference"] = validated_value - return request - OPENAI_PARAMETER_MAPPERS: list[ParameterMapper] = [ AspectRatioMapper(), ResolutionMapper(), - DurationSecondsMapper(), + DurationMapper(), FirstFrameMapper(), ] diff --git a/packages/providers/google/src/celeste_google/veo/client.py b/packages/providers/google/src/celeste_google/veo/client.py index 1f84a627..32e07c20 100644 --- a/packages/providers/google/src/celeste_google/veo/client.py +++ b/packages/providers/google/src/celeste_google/veo/client.py @@ -110,6 +110,14 @@ def _parse_content(self, response_data: dict[str, Any]) -> Any: raise ValueError(msg) return generated_samples[0].get("video", {}) + def _parse_usage(self, response_data: dict[str, Any]) -> dict[str, Any]: + """Parse usage from Veo API response. + + Google Veo API doesn't return usage data in the response. + Returns empty dict that capability clients can wrap in their Usage type. + """ + return {} + async def download_content(self, url: str) -> bytes: """Download video content from GCS URL. diff --git a/packages/providers/openai/pyproject.toml b/packages/providers/openai/pyproject.toml new file mode 100644 index 00000000..869f20dc --- /dev/null +++ b/packages/providers/openai/pyproject.toml @@ -0,0 +1,35 @@ +[project] +name = "celeste-openai" +version = "0.3.0" +description = "OpenAI provider package for Celeste AI" +authors = [{name = "Kamilbenkirane", email = "kamil@withceleste.ai"}] +license = {text = "Apache-2.0"} +requires-python = ">=3.12" +dependencies = ["celeste-ai", "httpx"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Operating System :: OS Independent", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Typing :: Typed", +] +keywords = ["ai", "openai", "gpt", "provider"] + +[project.urls] +Homepage = "https://withceleste.ai" +Documentation = "https://withceleste.ai/docs" +Repository = "https://github.com/withceleste/celeste-python" + +[tool.uv.sources] +celeste-ai = { workspace = true } + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/celeste_openai"] diff --git a/packages/providers/openai/src/celeste_openai/__init__.py b/packages/providers/openai/src/celeste_openai/__init__.py new file mode 100644 index 00000000..ca08362a --- /dev/null +++ b/packages/providers/openai/src/celeste_openai/__init__.py @@ -0,0 +1 @@ +"""OpenAI provider package for Celeste AI.""" diff --git a/packages/providers/openai/src/celeste_openai/audio/__init__.py b/packages/providers/openai/src/celeste_openai/audio/__init__.py new file mode 100644 index 00000000..915be71d --- /dev/null +++ b/packages/providers/openai/src/celeste_openai/audio/__init__.py @@ -0,0 +1 @@ +"""OpenAI Audio API provider package.""" diff --git a/packages/providers/openai/src/celeste_openai/audio/client.py b/packages/providers/openai/src/celeste_openai/audio/client.py new file mode 100644 index 00000000..a7bd8afd --- /dev/null +++ b/packages/providers/openai/src/celeste_openai/audio/client.py @@ -0,0 +1,76 @@ +"""OpenAI Audio API client with shared implementation.""" + +from typing import Any + +import httpx + +from celeste.mime_types import ApplicationMimeType, AudioMimeType + +from . import config + + +class OpenAIAudioClient: + """Mixin for OpenAI Audio API speech generation. + + Provides shared implementation for speech generation: + - _make_request() - HTTP POST to /v1/audio/speech + - _parse_usage() - Returns empty dict (Audio API doesn't return usage in body) + - _map_response_format_to_mime_type() - Map format string to AudioMimeType + + The Audio API speech endpoint returns binary audio data, not JSON. + Capability clients must handle the binary response in their generate() override. + + Usage: + class OpenAISpeechGenerationClient(OpenAIAudioClient, SpeechGenerationClient): + async def generate(self, *args, **parameters): + # Handle binary response... + """ + + async def _make_request( + self, + request_body: dict[str, Any], + **parameters: Any, + ) -> httpx.Response: + """Make HTTP request to OpenAI Audio API speech endpoint. + + Returns the raw response with binary audio content. + """ + request_body["model"] = self.model.id # type: ignore[attr-defined] + + 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.OpenAIAudioEndpoint.CREATE_SPEECH}", + headers=headers, + json_body=request_body, + ) + + def _parse_usage(self, response_data: dict[str, Any]) -> dict[str, int | None]: + """Audio API speech endpoint doesn't return usage in response body. + + Usage may be available in response headers or streaming events. + """ + return {} + + def _map_response_format_to_mime_type( + self, response_format: str | None + ) -> AudioMimeType: + """Map OpenAI response_format to AudioMimeType. + + Supported formats: mp3, opus, aac, flac, wav, pcm. + """ + format_map: dict[str, AudioMimeType] = { + "mp3": AudioMimeType.MP3, + "opus": AudioMimeType.OGG, # Opus is typically in OGG container + "aac": AudioMimeType.AAC, + "flac": AudioMimeType.FLAC, + "wav": AudioMimeType.WAV, + "pcm": AudioMimeType.WAV, # PCM is raw, closest match is WAV + } + return format_map.get(response_format or "", AudioMimeType.MP3) + + +__all__ = ["OpenAIAudioClient"] diff --git a/packages/providers/openai/src/celeste_openai/audio/config.py b/packages/providers/openai/src/celeste_openai/audio/config.py new file mode 100644 index 00000000..79369497 --- /dev/null +++ b/packages/providers/openai/src/celeste_openai/audio/config.py @@ -0,0 +1,14 @@ +"""Configuration for OpenAI Audio API.""" + +from enum import StrEnum + + +class OpenAIAudioEndpoint(StrEnum): + """Endpoints for Audio API.""" + + CREATE_SPEECH = "/v1/audio/speech" + CREATE_TRANSCRIPTION = "/v1/audio/transcriptions" + CREATE_TRANSLATION = "/v1/audio/translations" + + +BASE_URL = "https://api.openai.com" diff --git a/packages/providers/openai/src/celeste_openai/audio/parameters.py b/packages/providers/openai/src/celeste_openai/audio/parameters.py new file mode 100644 index 00000000..e762b96c --- /dev/null +++ b/packages/providers/openai/src/celeste_openai/audio/parameters.py @@ -0,0 +1,106 @@ +"""OpenAI Audio API parameter mappers.""" + +from typing import Any + +from celeste.mime_types import AudioMimeType +from celeste.models import Model +from celeste.parameters import ParameterMapper + + +class VoiceMapper(ParameterMapper): + """Map voice to OpenAI voice field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform voice into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["voice"] = validated_value + return request + + +class SpeedMapper(ParameterMapper): + """Map speed to OpenAI speed field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform speed into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["speed"] = validated_value + return request + + +class ResponseFormatMapper(ParameterMapper): + """Map response_format to OpenAI response_format field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform response_format into provider request.""" + # Convert string values to AudioMimeType enum before validation + if isinstance(value, str) and not isinstance(value, AudioMimeType): + string_to_mime_type: dict[str, AudioMimeType] = { + "mp3": AudioMimeType.MP3, + "opus": AudioMimeType.OGG, # OpenAI uses "opus" for OGG format + "aac": AudioMimeType.AAC, + "flac": AudioMimeType.FLAC, + } + value = string_to_mime_type.get(value.lower(), value) + + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + # Convert AudioMimeType enum to OpenAI string format + mime_type_to_openai_format: dict[AudioMimeType, str] = { + AudioMimeType.MP3: "mp3", + AudioMimeType.OGG: "opus", # OpenAI uses "opus" for OGG format + AudioMimeType.AAC: "aac", + AudioMimeType.FLAC: "flac", + } + + response_format = mime_type_to_openai_format.get(validated_value, "mp3") + request["response_format"] = response_format + return request + + +class InstructionsMapper(ParameterMapper): + """Map instructions to OpenAI instructions field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform instructions into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["instructions"] = validated_value + return request + + +__all__ = [ + "InstructionsMapper", + "ResponseFormatMapper", + "SpeedMapper", + "VoiceMapper", +] diff --git a/packages/providers/openai/src/celeste_openai/images/__init__.py b/packages/providers/openai/src/celeste_openai/images/__init__.py new file mode 100644 index 00000000..ec69a3d0 --- /dev/null +++ b/packages/providers/openai/src/celeste_openai/images/__init__.py @@ -0,0 +1 @@ +"""OpenAI Images API provider package.""" diff --git a/packages/providers/openai/src/celeste_openai/images/client.py b/packages/providers/openai/src/celeste_openai/images/client.py new file mode 100644 index 00000000..6c176663 --- /dev/null +++ b/packages/providers/openai/src/celeste_openai/images/client.py @@ -0,0 +1,126 @@ +"""OpenAI Images API client mixin. + +Provides shared implementation for capabilities using the OpenAI Images API: +- image-generation (generations endpoint) +""" + +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 OpenAIImagesClient: + """Mixin for OpenAI Images API image generation. + + Provides shared implementation for image generation: + - _make_request() - HTTP POST to /v1/images/generations + - _make_stream_request() - HTTP streaming to /v1/images/generations + - _parse_usage() - Extract usage dict from response + - _parse_content() - Extract data array from response + - _parse_finish_reason() - Returns None (Images API doesn't provide finish reasons) + - _build_metadata() - Filter content fields and include revised_prompt + + Usage: + class OpenAIImageGenerationClient(OpenAIImagesClient, ImageGenerationClient): + def _parse_content(self, response_data, **parameters): + data = super()._parse_content(response_data) + # Extract image from data[0]... + """ + + async def _make_request( + self, + request_body: dict[str, Any], + **parameters: Any, + ) -> httpx.Response: + """Make HTTP request to OpenAI Images API generations endpoint.""" + request_body["model"] = self.model.id # type: ignore[attr-defined] + + # DALL-E 2/3 need b64_json response format + if self.model.id in ("dall-e-2", "dall-e-3"): # type: ignore[attr-defined] + request_body.setdefault("response_format", "b64_json") + + 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.OpenAIImagesEndpoint.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 OpenAI Images API generations endpoint. + + Streaming is only supported for gpt-image-1. + """ + request_body["model"] = self.model.id # type: ignore[attr-defined] + request_body["stream"] = True + + if "partial_images" not in request_body: + request_body["partial_images"] = 1 + + 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.OpenAIImagesEndpoint.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. + gpt-image-1 returns usage, DALL-E models don't. + """ + usage_data = response_data.get("usage", {}) + return { + UsageField.INPUT_TOKENS: usage_data.get("input_tokens"), + UsageField.OUTPUT_TOKENS: usage_data.get("output_tokens"), + UsageField.TOTAL_TOKENS: usage_data.get("total_tokens"), + } + + def _parse_content(self, response_data: dict[str, Any]) -> Any: + """Parse data array from Images API response. + + Returns data array that capability clients extract images from. + """ + data = response_data.get("data", []) + if not data: + msg = "No image data in response" + raise ValueError(msg) + return data + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> FinishReason: + """Images API doesn't provide finish reasons.""" + return FinishReason(reason=None) + + def _build_metadata(self, response_data: dict[str, Any]) -> Any: + """Build metadata dictionary, including revised_prompt if present.""" + content_fields = {"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] + + return metadata + + +__all__ = ["OpenAIImagesClient"] diff --git a/packages/providers/openai/src/celeste_openai/images/config.py b/packages/providers/openai/src/celeste_openai/images/config.py new file mode 100644 index 00000000..93f558b6 --- /dev/null +++ b/packages/providers/openai/src/celeste_openai/images/config.py @@ -0,0 +1,14 @@ +"""Configuration for OpenAI Images API.""" + +from enum import StrEnum + + +class OpenAIImagesEndpoint(StrEnum): + """Endpoints for Images API.""" + + CREATE_IMAGE = "/v1/images/generations" + CREATE_EDIT = "/v1/images/edits" + CREATE_VARIATION = "/v1/images/variations" + + +BASE_URL = "https://api.openai.com" diff --git a/packages/providers/openai/src/celeste_openai/images/parameters.py b/packages/providers/openai/src/celeste_openai/images/parameters.py new file mode 100644 index 00000000..ef82323d --- /dev/null +++ b/packages/providers/openai/src/celeste_openai/images/parameters.py @@ -0,0 +1,162 @@ +"""OpenAI Images API parameter mappers.""" + +from typing import Any + +from celeste.models import Model +from celeste.parameters import ParameterMapper + + +class SizeMapper(ParameterMapper): + """Map size to OpenAI 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 PartialImagesMapper(ParameterMapper): + """Map partial_images to OpenAI partial_images field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform partial_images into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["partial_images"] = validated_value + return request + + +class QualityMapper(ParameterMapper): + """Map quality to OpenAI quality field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform quality into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["quality"] = validated_value + return request + + +class BackgroundMapper(ParameterMapper): + """Map background to OpenAI background field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform background into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["background"] = validated_value + return request + + +class OutputFormatMapper(ParameterMapper): + """Map output_format to OpenAI output_format field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform output_format into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["output_format"] = validated_value + return request + + +class StyleMapper(ParameterMapper): + """Map style to OpenAI style field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform style into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["style"] = validated_value + return request + + +class ModerationMapper(ParameterMapper): + """Map moderation to OpenAI moderation field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform moderation into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["moderation"] = validated_value + return request + + +class OutputCompressionMapper(ParameterMapper): + """Map output_compression to OpenAI output_compression field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform output_compression into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["output_compression"] = validated_value + return request + + +__all__ = [ + "BackgroundMapper", + "ModerationMapper", + "OutputCompressionMapper", + "OutputFormatMapper", + "PartialImagesMapper", + "QualityMapper", + "SizeMapper", + "StyleMapper", +] diff --git a/packages/providers/openai/src/celeste_openai/py.typed b/packages/providers/openai/src/celeste_openai/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/packages/providers/openai/src/celeste_openai/responses/__init__.py b/packages/providers/openai/src/celeste_openai/responses/__init__.py new file mode 100644 index 00000000..6e99fbbf --- /dev/null +++ b/packages/providers/openai/src/celeste_openai/responses/__init__.py @@ -0,0 +1 @@ +"""OpenAI Responses API provider package.""" diff --git a/packages/providers/openai/src/celeste_openai/responses/client.py b/packages/providers/openai/src/celeste_openai/responses/client.py new file mode 100644 index 00000000..7a25634c --- /dev/null +++ b/packages/providers/openai/src/celeste_openai/responses/client.py @@ -0,0 +1,132 @@ +"""OpenAI Responses 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 OpenAIResponsesClient: + """Mixin for OpenAI Responses API capabilities. + + Provides shared implementation for all capabilities using the Responses API: + - _make_request() - HTTP POST to /v1/responses + - _make_stream_request() - HTTP streaming to /v1/responses + - _parse_usage() - Extract usage dict from response + - _parse_content() - Extract output array from response + - _parse_finish_reason() - Extract finish reason from response + - _build_metadata() - Filter content fields + + Usage: + class OpenAITextGenerationClient(OpenAIResponsesClient, TextGenerationClient): + def _parse_content(self, response_data, **parameters): + output = super()._parse_content(response_data) # Raw output array + for item in output: + if item.get("type") == "message": + for part in item.get("content", []): + if part.get("type") == "output_text": + return self._transform_output(part.get("text") or "", **parameters) + return "" + """ + + async def _make_request( + self, + request_body: dict[str, Any], + **parameters: Any, + ) -> httpx.Response: + """Make HTTP request to OpenAI Responses API endpoint.""" + request_body["model"] = self.model.id # type: ignore[attr-defined] + + 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.OpenAIResponsesEndpoint.CREATE_RESPONSE}", + 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 OpenAI Responses API endpoint.""" + request_body["model"] = self.model.id # type: ignore[attr-defined] + 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.OpenAIResponsesEndpoint.CREATE_RESPONSE}", + headers=headers, + json_body=request_body, + ) + + @staticmethod + def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | None]: + """Map OpenAI usage fields to unified names. + + Shared by client and streaming across all capabilities. + """ + input_details = usage_data.get("input_tokens_details", {}) + output_details = usage_data.get("output_tokens_details", {}) + return { + UsageField.INPUT_TOKENS: usage_data.get("input_tokens"), + UsageField.OUTPUT_TOKENS: usage_data.get("output_tokens"), + UsageField.TOTAL_TOKENS: usage_data.get("total_tokens"), + UsageField.CACHED_TOKENS: input_details.get("cached_tokens"), + UsageField.REASONING_TOKENS: output_details.get("reasoning_tokens"), + } + + def _parse_usage(self, response_data: dict[str, Any]) -> dict[str, int | None]: + """Extract usage data from Responses API response.""" + usage_data = response_data.get("usage", {}) + return self.map_usage_fields(usage_data) + + def _parse_content(self, response_data: dict[str, Any]) -> Any: + """Parse output array from Responses API. + + Returns raw output array that capability clients extract from. + Similar to Imagen's _parse_content returning predictions array. + """ + output = response_data.get("output", []) + if not output: + msg = "No output in response" + raise ValueError(msg) + return output + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> FinishReason: + """Extract finish reason from Responses API response. + + Returns FinishReason that capability clients wrap in their specific type. + """ + status = response_data.get("status") + if status == "completed": + output_items = response_data.get("output", []) + for item in output_items: + if item.get("type") == "message" and item.get("status") == "completed": + return FinishReason(reason="completed") + return FinishReason(reason=None) + + def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: + """Build metadata dictionary, filtering out content fields.""" + content_fields = {"output"} + filtered_data = { + k: v for k, v in response_data.items() if k not in content_fields + } + return super()._build_metadata(filtered_data) # type: ignore[misc,no-any-return] + + +__all__ = ["OpenAIResponsesClient"] diff --git a/packages/providers/openai/src/celeste_openai/responses/config.py b/packages/providers/openai/src/celeste_openai/responses/config.py new file mode 100644 index 00000000..24282c09 --- /dev/null +++ b/packages/providers/openai/src/celeste_openai/responses/config.py @@ -0,0 +1,12 @@ +"""Configuration for OpenAI Responses API.""" + +from enum import StrEnum + + +class OpenAIResponsesEndpoint(StrEnum): + """Endpoints for Responses API.""" + + CREATE_RESPONSE = "/v1/responses" + + +BASE_URL = "https://api.openai.com" diff --git a/packages/providers/openai/src/celeste_openai/responses/parameters.py b/packages/providers/openai/src/celeste_openai/responses/parameters.py new file mode 100644 index 00000000..4a33dd14 --- /dev/null +++ b/packages/providers/openai/src/celeste_openai/responses/parameters.py @@ -0,0 +1,185 @@ +"""OpenAI Responses API parameter mappers.""" + +import json +from typing import Any, get_args, get_origin + +from pydantic import BaseModel, TypeAdapter + +from celeste.models import Model +from celeste.parameters import ParameterMapper +from celeste.structured_outputs import StrictJsonSchemaGenerator +from celeste.types import StructuredOutput + + +class TemperatureMapper(ParameterMapper): + """Map temperature to OpenAI temperature field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform temperature into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["temperature"] = validated_value + return request + + +class MaxTokensMapper(ParameterMapper): + """Map max_tokens to OpenAI max_output_tokens field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform max_tokens into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["max_output_tokens"] = validated_value + return request + + +class ReasoningEffortMapper(ParameterMapper): + """Map reasoning_effort to OpenAI reasoning.effort field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform reasoning_effort into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request.setdefault("reasoning", {})["effort"] = validated_value + return request + + +class VerbosityMapper(ParameterMapper): + """Map verbosity to OpenAI text.verbosity field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform verbosity into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request.setdefault("text", {})["verbosity"] = validated_value + return request + + +class WebSearchMapper(ParameterMapper): + """Map web_search to OpenAI tools field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform web_search into provider request.""" + validated_value = self._validate_value(value, model) + if not validated_value: + return request + + request.setdefault("tools", []).append({"type": "web_search"}) + return request + + +class OutputSchemaMapper(ParameterMapper): + """Map output_schema to OpenAI Structured Outputs format. + + Handles both single BaseModel and list[BaseModel] types. + OpenAI requires top-level type: "object", so list types are wrapped. + """ + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform output_schema into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + origin = get_origin(validated_value) + if origin is list: + # OpenAI requires top-level object, wrap list in {"items": [...]} + inner_type = get_args(validated_value)[0] + inner_schema = TypeAdapter(inner_type).json_schema( + schema_generator=StrictJsonSchemaGenerator, + mode="serialization", + ) + schema = { + "type": "object", + "properties": {"items": {"type": "array", "items": inner_schema}}, + "required": ["items"], + "additionalProperties": False, + } + name = f"{inner_type.__name__.lower()}_list" + else: + schema = TypeAdapter(validated_value).json_schema( + schema_generator=StrictJsonSchemaGenerator, + mode="serialization", + ) + name = validated_value.__name__.lower() + + request.setdefault("text", {})["format"] = { + "type": "json_schema", + "name": name, + "schema": schema, + "strict": True, + } + return request + + def parse_output( + self, content: StructuredOutput, value: object | None + ) -> StructuredOutput: + """Parse JSON string to BaseModel using Pydantic's TypeAdapter.""" + if value is None: + return content + + # If content is already a BaseModel, return it unchanged + if isinstance(content, BaseModel): + return content + if isinstance(content, list) and content and isinstance(content[0], BaseModel): + return content + + if isinstance(content, str): + parsed = json.loads(content) + else: + parsed = content + + # Unwrap list from items wrapper + origin = get_origin(value) + if origin is list and isinstance(parsed, dict) and "items" in parsed: + parsed = parsed["items"] + + return TypeAdapter(value).validate_python(parsed) + + +__all__ = [ + "MaxTokensMapper", + "OutputSchemaMapper", + "ReasoningEffortMapper", + "TemperatureMapper", + "VerbosityMapper", + "WebSearchMapper", +] diff --git a/packages/providers/openai/src/celeste_openai/responses/streaming.py b/packages/providers/openai/src/celeste_openai/responses/streaming.py new file mode 100644 index 00000000..bfbba7e3 --- /dev/null +++ b/packages/providers/openai/src/celeste_openai/responses/streaming.py @@ -0,0 +1,68 @@ +"""OpenAI Responses SSE parsing for streaming.""" + +from typing import Any + +from .client import OpenAIResponsesClient + + +class OpenAIResponsesStream: + """Mixin for Responses API SSE parsing. + + Provides shared implementation for all capabilities using OpenAI Responses API streaming: + - _parse_chunk() - Parse SSE event into raw chunk dict + + Capability streams extend via super() to wrap results in typed Chunks. + + Usage: + class OpenAITextGenerationStream(OpenAIResponsesStream, TextGenerationStream): + def _parse_chunk(self, event): + raw = super()._parse_chunk(event) + if not raw: + return None + return TextGenerationChunk(...) + """ + + def _parse_chunk(self, event: dict[str, Any]) -> dict[str, Any] | None: + """Parse SSE event into raw chunk data.""" + event_type = event.get("type") + if not event_type: + return None + + if event_type == "response.output_text.delta": + delta = event.get("delta") + if delta is None: + return None + return { + "content": delta, + "finish_reason": None, + "usage": None, + "raw_event": event, + } + + if event_type == "response.output_text.done": + return None + + if event_type == "response.completed": + response_data = event.get("response", {}) + usage_data = response_data.get("usage") + + usage = None + if usage_data: + usage = OpenAIResponsesClient.map_usage_fields(usage_data) + + finish_reason = None + status = response_data.get("status") + if status == "completed": + finish_reason = "completed" + + return { + "content": "", + "finish_reason": finish_reason, + "usage": usage, + "raw_event": event, + } + + return None + + +__all__ = ["OpenAIResponsesStream"] diff --git a/packages/providers/openai/src/celeste_openai/videos/__init__.py b/packages/providers/openai/src/celeste_openai/videos/__init__.py new file mode 100644 index 00000000..4e330c1e --- /dev/null +++ b/packages/providers/openai/src/celeste_openai/videos/__init__.py @@ -0,0 +1 @@ +"""OpenAI Videos API provider package.""" diff --git a/packages/providers/openai/src/celeste_openai/videos/client.py b/packages/providers/openai/src/celeste_openai/videos/client.py new file mode 100644 index 00000000..9818066a --- /dev/null +++ b/packages/providers/openai/src/celeste_openai/videos/client.py @@ -0,0 +1,179 @@ +"""OpenAI Videos API client mixin. + +Provides shared implementation for capabilities using the OpenAI Videos API: +- video-generation (async polling pattern) +""" + +import asyncio +import base64 +import json +import logging +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 OpenAIVideosClient: + """Mixin for OpenAI Videos API video generation. + + Provides shared implementation for video generation: + - _make_request() - HTTP POST with async polling pattern + - _parse_usage() - Returns billing units from response + - _parse_finish_reason() - Returns None (Videos API doesn't provide finish reasons) + - _build_metadata() - Filter content fields, include video metadata + + The Videos API uses async polling: + 1. POST to create video job at /v1/videos + 2. Poll GET /v1/videos/{id} until completed/failed + 3. GET /v1/videos/{id}/content to retrieve video data + + Usage: + class OpenAIVideoGenerationClient(OpenAIVideosClient, VideoGenerationClient): + async def _prepare_multipart_request(self, request_body): + # Handle input_reference image uploads... + """ + + async def _make_request( + self, + request_body: dict[str, Any], + **parameters: Any, + ) -> httpx.Response: + """Make HTTP request with async polling for OpenAI video generation. + + Handles the complete async polling workflow: + 1. Create video job + 2. Poll for completion + 3. Fetch video content + """ + request_body["model"] = self.model.id # type: ignore[attr-defined] + + headers = { + **self.auth.get_headers(), # type: ignore[attr-defined] + "Content-Type": ApplicationMimeType.JSON, + } + + files, data = await self._prepare_multipart_request(request_body.copy()) + + endpoint = config.OpenAIVideosEndpoint.CREATE_VIDEO + + if files: + logger.info("Sending multipart request to OpenAI with input_reference") + response = await self.http_client.post_multipart( # type: ignore[attr-defined] + f"{config.BASE_URL}{endpoint}", + headers=headers, + files=files, + data=data, + ) + else: + logger.info(f"Sending request to OpenAI: {request_body}") + response = await self.http_client.post( # type: ignore[attr-defined] + f"{config.BASE_URL}{endpoint}", + headers=headers, + json_body=request_body, + ) + + self._handle_error_response(response) # type: ignore[attr-defined] + video_obj = response.json() + + video_id = video_obj["id"] + logger.info(f"Created video job: {video_id}") + + # Poll for completion + for _ in range(config.MAX_POLLS): + status_response = await self.http_client.get( # type: ignore[attr-defined] + f"{config.BASE_URL}{endpoint}/{video_id}", + headers=headers, + ) + self._handle_error_response(status_response) # type: ignore[attr-defined] + video_obj = status_response.json() + + status = video_obj["status"] + progress = video_obj.get("progress", 0) + + logger.info(f"Video {video_id}: {status} ({progress}%)") + + if status == config.STATUS_COMPLETED: + break + elif status == config.STATUS_FAILED: + error = video_obj.get("error", {}) + msg = ( + f"Video generation failed: {error.get('message', 'Unknown error')}" + ) + raise RuntimeError(msg) + + await asyncio.sleep(config.POLL_INTERVAL) + else: + msg = f"Video generation timeout after {config.MAX_POLLS * config.POLL_INTERVAL} seconds" + raise TimeoutError(msg) + + # Fetch video content + content_response = await self.http_client.get( # type: ignore[attr-defined] + f"{config.BASE_URL}{endpoint}/{video_id}{config.CONTENT_ENDPOINT_SUFFIX}", + headers=headers, + ) + self._handle_error_response(content_response) # type: ignore[attr-defined] + video_data = content_response.content + + # Build normalized response + response_data = { + "video_data": base64.b64encode(video_data).decode("utf-8"), + "model": video_obj.get("model", self.model.id), # type: ignore[attr-defined] + "video_id": video_id, + "seconds": video_obj.get("seconds"), + "size": video_obj.get("size"), + "created_at": video_obj.get("created_at"), + "completed_at": video_obj.get("completed_at"), + "expires_at": video_obj.get("expires_at"), + } + + return httpx.Response( + 200, + content=json.dumps(response_data).encode(), + headers={"Content-Type": ApplicationMimeType.JSON}, + ) + + async def _prepare_multipart_request( + self, + request_body: dict[str, Any], + ) -> tuple[dict[str, tuple[str, bytes, str]], dict[str, str]]: + """Prepare multipart form data from request_body. + + Override in capability client to handle input_reference or other file uploads. + Default implementation returns empty dicts (no file uploads). + """ + return {}, {} + + def _parse_usage(self, response_data: dict[str, Any]) -> dict[str, Any]: + """Extract usage data from Videos API response. + + Returns dict with seconds for capability clients to wrap in Usage type. + """ + return { + UsageField.BILLED_UNITS: response_data.get("seconds"), + } + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> FinishReason: + """Videos API doesn't provide finish reasons.""" + return FinishReason(reason=None) + + def _build_metadata(self, response_data: dict[str, Any]) -> Any: + """Build metadata dictionary, including video-specific fields.""" + content_fields = {"video_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] + + return metadata + + +__all__ = ["OpenAIVideosClient"] diff --git a/packages/capabilities/video-generation/src/celeste_video_generation/providers/openai/config.py b/packages/providers/openai/src/celeste_openai/videos/config.py similarity index 54% rename from packages/capabilities/video-generation/src/celeste_video_generation/providers/openai/config.py rename to packages/providers/openai/src/celeste_openai/videos/config.py index e45fe93e..ea2172a4 100644 --- a/packages/capabilities/video-generation/src/celeste_video_generation/providers/openai/config.py +++ b/packages/providers/openai/src/celeste_openai/videos/config.py @@ -1,14 +1,17 @@ -"""OpenAI provider configuration for video generation.""" +"""Configuration for OpenAI Videos API.""" + +from enum import StrEnum + + +class OpenAIVideosEndpoint(StrEnum): + """Endpoints for Videos API.""" + + CREATE_VIDEO = "/v1/videos" + -# HTTP Configuration BASE_URL = "https://api.openai.com" -ENDPOINT = "/v1/videos" CONTENT_ENDPOINT_SUFFIX = "/content" -# Authentication -AUTH_HEADER_NAME = "Authorization" -AUTH_HEADER_PREFIX = "Bearer " - # Polling Configuration MAX_POLLS = 60 POLL_INTERVAL = 5 # seconds diff --git a/packages/providers/openai/src/celeste_openai/videos/parameters.py b/packages/providers/openai/src/celeste_openai/videos/parameters.py new file mode 100644 index 00000000..a108a62c --- /dev/null +++ b/packages/providers/openai/src/celeste_openai/videos/parameters.py @@ -0,0 +1,71 @@ +"""OpenAI Videos API parameter mappers.""" + +from typing import Any + +from celeste.models import Model +from celeste.parameters import ParameterMapper + + +class SecondsMapper(ParameterMapper): + """Map seconds to OpenAI seconds field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform seconds into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + # API expects string, coerce int to string + if isinstance(validated_value, int): + validated_value = str(validated_value) + + request["seconds"] = validated_value + return request + + +class SizeMapper(ParameterMapper): + """Map size to OpenAI 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 InputReferenceMapper(ParameterMapper): + """Map input_reference to OpenAI input_reference field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform input_reference into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["input_reference"] = validated_value + return request + + +__all__ = [ + "InputReferenceMapper", + "SecondsMapper", + "SizeMapper", +]