From 6a506bc0a5f29823e13f761fe15b3eb8d0c8dbc9 Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Mon, 15 Dec 2025 17:42:15 +0100 Subject: [PATCH 1/8] feat(providers): add OpenAI API package Add standalone provider package for OpenAI APIs with mixin pattern for capability-agnostic reuse across multiple endpoints. ## Images API (OpenAIImagesClient) - HTTP POST/streaming to /v1/images/generations endpoint - Supports DALL-E 2/3 (b64_json format) and gpt-image-1 (streaming) - Usage parsing: input_tokens, output_tokens, total_tokens - Content extraction from data array - Revised prompt handling in metadata ## Responses API (OpenAIResponsesClient) - HTTP POST/streaming to /v1/responses endpoint - Unified API for text generation capabilities - Usage parsing with cached and reasoning tokens support - Content extraction from output array - Finish reason mapping (completed status) ## Videos API (OpenAIVideosClient) - Async polling workflow for video generation - Phase 1: POST to /v1/videos to create job - Phase 2: Poll GET /v1/videos/{id} until completed/failed - Phase 3: GET /v1/videos/{id}/content to retrieve video - Usage parsing: billing units (seconds) - Multipart request support for input_reference images ## Audio API (OpenAIAudioClient) - HTTP POST to /v1/audio/speech endpoint - Binary audio response handling - Response format mapping (mp3, opus, aac, flac, wav, pcm) - MIME type conversion to AudioMimeType All clients follow the mixin pattern for reuse across capabilities. --- packages/providers/openai/pyproject.toml | 35 ++++ .../openai/src/celeste_openai/__init__.py | 1 + .../src/celeste_openai/audio/__init__.py | 1 + .../openai/src/celeste_openai/audio/client.py | 76 +++++++ .../openai/src/celeste_openai/audio/config.py | 14 ++ .../src/celeste_openai/audio/parameters.py | 106 ++++++++++ .../src/celeste_openai/images/__init__.py | 1 + .../src/celeste_openai/images/client.py | 131 +++++++++++++ .../src/celeste_openai/images/config.py | 14 ++ .../src/celeste_openai/images/parameters.py | 162 +++++++++++++++ .../openai/src/celeste_openai/py.typed | 0 .../src/celeste_openai/responses/__init__.py | 1 + .../src/celeste_openai/responses/client.py | 132 +++++++++++++ .../src/celeste_openai/responses/config.py | 12 ++ .../celeste_openai/responses/parameters.py | 185 ++++++++++++++++++ .../src/celeste_openai/responses/streaming.py | 68 +++++++ .../src/celeste_openai/videos/__init__.py | 1 + .../src/celeste_openai/videos/client.py | 179 +++++++++++++++++ .../src/celeste_openai/videos/config.py | 21 ++ .../src/celeste_openai/videos/parameters.py | 71 +++++++ 20 files changed, 1211 insertions(+) create mode 100644 packages/providers/openai/pyproject.toml create mode 100644 packages/providers/openai/src/celeste_openai/__init__.py create mode 100644 packages/providers/openai/src/celeste_openai/audio/__init__.py create mode 100644 packages/providers/openai/src/celeste_openai/audio/client.py create mode 100644 packages/providers/openai/src/celeste_openai/audio/config.py create mode 100644 packages/providers/openai/src/celeste_openai/audio/parameters.py create mode 100644 packages/providers/openai/src/celeste_openai/images/__init__.py create mode 100644 packages/providers/openai/src/celeste_openai/images/client.py create mode 100644 packages/providers/openai/src/celeste_openai/images/config.py create mode 100644 packages/providers/openai/src/celeste_openai/images/parameters.py create mode 100644 packages/providers/openai/src/celeste_openai/py.typed create mode 100644 packages/providers/openai/src/celeste_openai/responses/__init__.py create mode 100644 packages/providers/openai/src/celeste_openai/responses/client.py create mode 100644 packages/providers/openai/src/celeste_openai/responses/config.py create mode 100644 packages/providers/openai/src/celeste_openai/responses/parameters.py create mode 100644 packages/providers/openai/src/celeste_openai/responses/streaming.py create mode 100644 packages/providers/openai/src/celeste_openai/videos/__init__.py create mode 100644 packages/providers/openai/src/celeste_openai/videos/client.py create mode 100644 packages/providers/openai/src/celeste_openai/videos/config.py create mode 100644 packages/providers/openai/src/celeste_openai/videos/parameters.py 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..c79345d9 --- /dev/null +++ b/packages/providers/openai/src/celeste_openai/images/client.py @@ -0,0 +1,131 @@ +"""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] + + # Add revised_prompt from first image if present + data = response_data.get("data", []) + if data and data[0].get("revised_prompt"): + metadata["revised_prompt"] = data[0]["revised_prompt"] + + 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/providers/openai/src/celeste_openai/videos/config.py b/packages/providers/openai/src/celeste_openai/videos/config.py new file mode 100644 index 00000000..ea2172a4 --- /dev/null +++ b/packages/providers/openai/src/celeste_openai/videos/config.py @@ -0,0 +1,21 @@ +"""Configuration for OpenAI Videos API.""" + +from enum import StrEnum + + +class OpenAIVideosEndpoint(StrEnum): + """Endpoints for Videos API.""" + + CREATE_VIDEO = "/v1/videos" + + +BASE_URL = "https://api.openai.com" +CONTENT_ENDPOINT_SUFFIX = "/content" + +# Polling Configuration +MAX_POLLS = 60 +POLL_INTERVAL = 5 # seconds + +# Status Constants +STATUS_COMPLETED = "completed" +STATUS_FAILED = "failed" 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", +] From e0b3f0053714b5b3c8490ae0eb4c305645207659 Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Mon, 15 Dec 2025 17:58:58 +0100 Subject: [PATCH 2/8] fix(providers): add _parse_usage to GoogleVeoClient Add _parse_usage method to GoogleVeoClient to resolve mypy type checking error. GoogleVeoClient was missing this implementation, causing unsafe super() call in GoogleVideoGenerationClient. Google Veo API doesn't return usage data in the response, so this method returns an empty dict that capability clients can wrap in their Usage type. --- .../celeste_text_generation/providers/google/config.py | 10 ---------- .../providers/google/src/celeste_google/veo/client.py | 8 ++++++++ 2 files changed, 8 insertions(+), 10 deletions(-) delete mode 100644 packages/capabilities/text-generation/src/celeste_text_generation/providers/google/config.py 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/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. From 0c0b05298b923d1b3bc35b0b2cdfec0883b8069c Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Mon, 15 Dec 2025 17:59:19 +0100 Subject: [PATCH 3/8] refactor(speech-generation): rename RESPONSE_FORMAT to OUTPUT_FORMAT Standardize parameter naming across speech generation capability. Changes unified parameter name from RESPONSE_FORMAT to OUTPUT_FORMAT to match enum definition and be more consistent with other capabilities. Breaking change: Updates parameter name in: - SpeechGenerationParameter enum - SpeechGenerationParameters class - All ElevenLabs models (9 occurrences) - All OpenAI models (3 occurrences) - Provider parameter mappers --- .../celeste_speech_generation/parameters.py | 5 +- .../providers/elevenlabs/models.py | 16 +-- .../providers/elevenlabs/parameters.py | 2 +- .../providers/openai/client.py | 58 ++------ .../providers/openai/models.py | 6 +- .../providers/openai/parameters.py | 129 +++--------------- 6 files changed, 43 insertions(+), 173 deletions(-) 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/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"] From 292784b1bd2a0e4673cc1ccf83c2500c6da5a18b Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Mon, 15 Dec 2025 17:59:29 +0100 Subject: [PATCH 4/8] refactor(image-generation): migrate OpenAI and Google to provider mixins Migrate image generation capability clients to use provider package mixins, eliminating code duplication and centralizing API-specific logic. ## Changes - OpenAI client now inherits from OpenAIImagesClient mixin - Parameter mappers inherit from provider package mappers - Google client uses super()._parse_usage() pattern - Remove unused config.py file (config now in provider package) - Remove revised_prompt handling from provider mixin (handled in capability) ## Code Reduction - ~188 lines removed across client and parameter files - Significant deduplication of HTTP request logic --- .../providers/google/imagen.py | 4 +- .../providers/openai/client.py | 72 ++---------- .../providers/openai/config.py | 10 -- .../providers/openai/parameters.py | 111 +++--------------- .../test_image_generation/__init__.py | 2 +- .../src/celeste_openai/images/client.py | 5 - 6 files changed, 26 insertions(+), 178 deletions(-) delete mode 100644 packages/capabilities/image-generation/src/celeste_image_generation/providers/openai/config.py 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/providers/openai/src/celeste_openai/images/client.py b/packages/providers/openai/src/celeste_openai/images/client.py index c79345d9..6c176663 100644 --- a/packages/providers/openai/src/celeste_openai/images/client.py +++ b/packages/providers/openai/src/celeste_openai/images/client.py @@ -120,11 +120,6 @@ def _build_metadata(self, response_data: dict[str, Any]) -> Any: metadata = super()._build_metadata(filtered_data) # type: ignore[misc] - # Add revised_prompt from first image if present - data = response_data.get("data", []) - if data and data[0].get("revised_prompt"): - metadata["revised_prompt"] = data[0]["revised_prompt"] - return metadata From 09b0e6da0557e092e501db4c332bac68fa53fbba Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Mon, 15 Dec 2025 17:59:35 +0100 Subject: [PATCH 5/8] refactor(speech-generation): migrate OpenAI and ElevenLabs to provider mixins Migrate speech generation capability clients to use provider package mixins, eliminating code duplication and centralizing API-specific logic. ## Changes - OpenAI client now inherits from OpenAIAudioClient mixin - Parameter mappers inherit from provider package mappers - Add SpeechGenerationFinishReason type for consistency - Remove unused config.py file (config now in provider package) - Update _create_inputs to use parameters.get() pattern - Simplify VoiceConstraint docstring - Update tests to reflect new structure ## Code Reduction - ~187 lines removed across client and parameter files - Significant deduplication of HTTP request and parameter mapping logic --- .../src/celeste_speech_generation/client.py | 6 ++---- .../src/celeste_speech_generation/constraints.py | 5 +---- .../src/celeste_speech_generation/io.py | 11 +++++++---- .../providers/openai/config.py | 9 --------- .../test_speech_generation/__init__.py | 1 + .../test_speech_generation/test_generate.py | 12 +++++------- .../test_speech_generation/test_stream.py | 4 ++-- 7 files changed, 18 insertions(+), 30 deletions(-) delete mode 100644 packages/capabilities/speech-generation/src/celeste_speech_generation/providers/openai/config.py 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/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/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) From 31b24742cd99f9d366f260b79317c691433c8d20 Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Mon, 15 Dec 2025 17:59:54 +0100 Subject: [PATCH 6/8] refactor(video-generation): migrate OpenAI and Google to provider mixins Migrate video generation capability clients to use provider package mixins, eliminating code duplication and centralizing API-specific logic. ## Changes - OpenAI client now inherits from OpenAIVideosClient mixin - Parameter mappers inherit from provider package mappers - Google client uses super()._parse_usage() pattern (after Commit 1 fix) - Remove unused config.py file (config now in provider package) - Remove async polling logic (now in provider mixin) - Simplify _parse_usage to use mixin's implementation ## Code Reduction - ~178 lines removed across client and parameter files - Significant deduplication of HTTP request and polling logic --- .../providers/google/client.py | 3 +- .../providers/openai/client.py | 114 +----------------- .../providers/openai/config.py | 18 --- .../providers/openai/parameters.py | 64 ++-------- 4 files changed, 20 insertions(+), 179 deletions(-) delete mode 100644 packages/capabilities/video-generation/src/celeste_video_generation/providers/openai/config.py 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/config.py b/packages/capabilities/video-generation/src/celeste_video_generation/providers/openai/config.py deleted file mode 100644 index e45fe93e..00000000 --- a/packages/capabilities/video-generation/src/celeste_video_generation/providers/openai/config.py +++ /dev/null @@ -1,18 +0,0 @@ -"""OpenAI provider configuration for video generation.""" - -# 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 - -# Status Constants -STATUS_COMPLETED = "completed" -STATUS_FAILED = "failed" 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(), ] From 8352677d00e1ca8aaa87019efd71d4765e4f963f Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Mon, 15 Dec 2025 18:00:13 +0100 Subject: [PATCH 7/8] refactor(text-generation): migrate OpenAI to provider mixins and remove config files Migrate text generation capability client to use provider package mixins, eliminating code duplication and centralizing API-specific logic. ## Changes - OpenAI client now inherits from OpenAIResponsesClient mixin - Parameter mappers inherit from provider package mappers - Streaming class inherits from OpenAIResponsesStream mixin - Remove unused config.py file (config now in provider package) - Simplify _parse_content to use mixin's output array parsing - Simplify _parse_finish_reason to use mixin's implementation ## Code Reduction - ~429 lines removed across client, parameters, and streaming files - Significant deduplication of HTTP request, streaming, and schema logic --- .../providers/openai/client.py | 122 ++-------- .../providers/openai/config.py | 10 - .../providers/openai/parameters.py | 227 ++---------------- .../providers/openai/streaming.py | 80 +++--- 4 files changed, 69 insertions(+), 370 deletions(-) delete mode 100644 packages/capabilities/text-generation/src/celeste_text_generation/providers/openai/config.py 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}, ) From 0d28a6cba3733646facfc8f78f99c695cf376024 Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Mon, 15 Dec 2025 18:01:23 +0100 Subject: [PATCH 8/8] fix(capabilities): add celeste-openai dependency to all capabilities Add celeste-openai to [tool.uv.sources] in all capability packages that now import from the OpenAI provider package after the mixin migration. ## Changes - image-generation: Add celeste-openai (imports celeste_openai.images) - speech-generation: Add celeste-openai (imports celeste_openai.audio) - text-generation: Add celeste-openai (imports celeste_openai.responses) - video-generation: Add celeste-openai (imports celeste_openai.videos) This ensures workspace dependencies are properly declared for the refactored capability clients that use OpenAI provider mixins. --- packages/capabilities/image-generation/pyproject.toml | 1 + packages/capabilities/speech-generation/pyproject.toml | 1 + packages/capabilities/text-generation/pyproject.toml | 1 + packages/capabilities/video-generation/pyproject.toml | 1 + 4 files changed, 4 insertions(+) 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/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/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/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"