From 9260d365d2cbdbab7475fc5e72c9f60b64b4e6c1 Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Fri, 19 Dec 2025 11:51:47 +0100 Subject: [PATCH 01/11] feat(providers): add Cohere Chat API package Add new celeste-cohere provider package with Chat API mixin: - CohereChatClient mixin for shared request/response handling - CohereChatStream mixin for SSE streaming support - Parameter mappers for temperature, max_tokens, thinking, output_schema - Usage field mapping for billed_units and tokens --- packages/providers/cohere/pyproject.toml | 18 +++ .../cohere/src/celeste_cohere/__init__.py | 3 + .../src/celeste_cohere/chat/__init__.py | 1 + .../cohere/src/celeste_cohere/chat/client.py | 119 ++++++++++++++ .../cohere/src/celeste_cohere/chat/config.py | 12 ++ .../src/celeste_cohere/chat/parameters.py | 147 ++++++++++++++++++ .../src/celeste_cohere/chat/streaming.py | 89 +++++++++++ .../cohere/src/celeste_cohere/py.typed | 0 8 files changed, 389 insertions(+) create mode 100644 packages/providers/cohere/pyproject.toml create mode 100644 packages/providers/cohere/src/celeste_cohere/__init__.py create mode 100644 packages/providers/cohere/src/celeste_cohere/chat/__init__.py create mode 100644 packages/providers/cohere/src/celeste_cohere/chat/client.py create mode 100644 packages/providers/cohere/src/celeste_cohere/chat/config.py create mode 100644 packages/providers/cohere/src/celeste_cohere/chat/parameters.py create mode 100644 packages/providers/cohere/src/celeste_cohere/chat/streaming.py create mode 100644 packages/providers/cohere/src/celeste_cohere/py.typed diff --git a/packages/providers/cohere/pyproject.toml b/packages/providers/cohere/pyproject.toml new file mode 100644 index 00000000..221f58a4 --- /dev/null +++ b/packages/providers/cohere/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "celeste-cohere" +version = "0.3.0" +description = "Cohere 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"] + +[tool.uv.sources] +celeste-ai = { workspace = true } + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/celeste_cohere"] diff --git a/packages/providers/cohere/src/celeste_cohere/__init__.py b/packages/providers/cohere/src/celeste_cohere/__init__.py new file mode 100644 index 00000000..e3f1b884 --- /dev/null +++ b/packages/providers/cohere/src/celeste_cohere/__init__.py @@ -0,0 +1,3 @@ +"""Cohere provider package for Celeste AI.""" + +__all__: list[str] = [] diff --git a/packages/providers/cohere/src/celeste_cohere/chat/__init__.py b/packages/providers/cohere/src/celeste_cohere/chat/__init__.py new file mode 100644 index 00000000..4ba2018a --- /dev/null +++ b/packages/providers/cohere/src/celeste_cohere/chat/__init__.py @@ -0,0 +1 @@ +"""Cohere Chat API provider package.""" diff --git a/packages/providers/cohere/src/celeste_cohere/chat/client.py b/packages/providers/cohere/src/celeste_cohere/chat/client.py new file mode 100644 index 00000000..4387ae03 --- /dev/null +++ b/packages/providers/cohere/src/celeste_cohere/chat/client.py @@ -0,0 +1,119 @@ +"""Cohere Chat 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 CohereChatClient: + """Mixin for Cohere Chat API capabilities. + + Provides shared implementation for all capabilities using the Chat API: + - _make_request() - HTTP POST to /v2/chat + - _make_stream_request() - HTTP streaming to /v2/chat + - _parse_usage() - Extract usage dict from response + - _parse_content() - Extract content array from response message + - _parse_finish_reason() - Extract finish reason from response + - _build_metadata() - Filter content fields + + Usage: + class CohereTextGenerationClient(CohereChatClient, TextGenerationClient): + def _parse_content(self, response_data, **parameters): + content_array = super()._parse_content(response_data) + text = content_array[0].get("text") or "" + return self._transform_output(text, **parameters) + """ + + async def _make_request( + self, + request_body: dict[str, Any], + **parameters: Any, + ) -> httpx.Response: + """Make HTTP request to Cohere Chat 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.CohereChatEndpoint.CREATE_CHAT}", + 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 Cohere Chat 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.CohereChatEndpoint.CREATE_CHAT}", + headers=headers, + json_body=request_body, + ) + + @staticmethod + def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | None]: + """Map Cohere usage fields to unified names. + + Shared by client and streaming across all capabilities. + """ + billed_units = usage_data.get("billed_units", {}) + tokens = usage_data.get("tokens", {}) + return { + UsageField.INPUT_TOKENS: billed_units.get("input_tokens"), + UsageField.OUTPUT_TOKENS: billed_units.get("output_tokens"), + UsageField.TOTAL_TOKENS: tokens.get("total_tokens") if tokens else None, + UsageField.CACHED_TOKENS: usage_data.get("cached_tokens"), + } + + def _parse_usage(self, response_data: dict[str, Any]) -> dict[str, int | None]: + """Extract usage data from Chat 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 content array from Chat API response message. + + Returns raw content array that capability clients extract from. + """ + message = response_data.get("message", {}) + content_array = message.get("content", []) + if not content_array: + msg = "No content in response message" + raise ValueError(msg) + return content_array + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> FinishReason: + """Extract finish reason from Chat API response.""" + reason = response_data.get("finish_reason") + return FinishReason(reason=reason) + + def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: + """Build metadata dictionary, filtering out content field.""" + content_fields = {"message"} + 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__ = ["CohereChatClient"] diff --git a/packages/providers/cohere/src/celeste_cohere/chat/config.py b/packages/providers/cohere/src/celeste_cohere/chat/config.py new file mode 100644 index 00000000..5c93fd32 --- /dev/null +++ b/packages/providers/cohere/src/celeste_cohere/chat/config.py @@ -0,0 +1,12 @@ +"""Configuration for Cohere Chat API.""" + +from enum import StrEnum + + +class CohereChatEndpoint(StrEnum): + """Endpoints for Chat API.""" + + CREATE_CHAT = "/v2/chat" + + +BASE_URL = "https://api.cohere.com" diff --git a/packages/providers/cohere/src/celeste_cohere/chat/parameters.py b/packages/providers/cohere/src/celeste_cohere/chat/parameters.py new file mode 100644 index 00000000..4ec28588 --- /dev/null +++ b/packages/providers/cohere/src/celeste_cohere/chat/parameters.py @@ -0,0 +1,147 @@ +"""Cohere Chat 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 RefResolvingJsonSchemaGenerator +from celeste.types import StructuredOutput + + +class TemperatureMapper(ParameterMapper): + """Map temperature to Cohere 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 Cohere max_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_tokens"] = validated_value + return request + + +class ThinkingMapper(ParameterMapper): + """Map thinking to Cohere thinking field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform thinking into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + if validated_value == "enabled": + request["thinking"] = {"type": "enabled"} + elif validated_value == "disabled": + request["thinking"] = {"type": "disabled"} + else: + request["thinking"] = {"type": "enabled", "token_budget": validated_value} + return request + + +class OutputSchemaMapper(ParameterMapper): + """Map output_schema to Cohere structured outputs format. + + Handles both single BaseModel and list[BaseModel] types. + Cohere requires top-level object, so lists are wrapped in {items: []}. + """ + + 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: + # Cohere requires top-level object, wrap list in {items: [...]} + inner_type = get_args(validated_value)[0] + inner_schema = TypeAdapter(inner_type).json_schema( + schema_generator=RefResolvingJsonSchemaGenerator, + mode="serialization", + ) + schema = { + "type": "object", + "properties": {"items": {"type": "array", "items": inner_schema}}, + "required": ["items"], + } + else: + schema = TypeAdapter(validated_value).json_schema( + schema_generator=RefResolvingJsonSchemaGenerator, + mode="serialization", + ) + + request["response_format"] = { + "type": "json_object", + "schema": schema, + } + return request + + def parse_output( + self, content: StructuredOutput, value: object | None + ) -> StructuredOutput: + """Parse JSON 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", + "TemperatureMapper", + "ThinkingMapper", +] diff --git a/packages/providers/cohere/src/celeste_cohere/chat/streaming.py b/packages/providers/cohere/src/celeste_cohere/chat/streaming.py new file mode 100644 index 00000000..4409733d --- /dev/null +++ b/packages/providers/cohere/src/celeste_cohere/chat/streaming.py @@ -0,0 +1,89 @@ +"""Cohere Chat SSE parsing for streaming.""" + +from typing import Any + +from .client import CohereChatClient + + +class CohereChatStream: + """Mixin for Chat API SSE parsing. + + Provides shared implementation for all capabilities using Cohere Chat API streaming: + - _parse_chunk() - Parse SSE event into raw chunk dict + + Capability streams extend via super() to wrap results in typed Chunks. + + Usage: + class CohereTextGenerationStream(CohereChatStream, 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 event_type == "content-delta": + delta = event.get("delta", {}) + message = delta.get("message", {}) + content = message.get("content", {}) + text_delta = content.get("text") + + if not text_delta: + return None + + return { + "content": text_delta, + "finish_reason": None, + "usage": None, + "raw_event": event, + } + + if event_type == "message-end": + delta = event.get("delta", {}) + finish_reason = delta.get("finish_reason") + + usage = None + usage_dict = delta.get("usage", {}) + if isinstance(usage_dict, dict): + mapped = CohereChatClient.map_usage_fields(usage_dict) + if ( + mapped.get("input_tokens") is not None + or mapped.get("output_tokens") is not None + ): + usage = mapped + + return { + "content": "", + "finish_reason": finish_reason, + "usage": usage, + "raw_event": event, + } + + if event_type == "stream-end": + finish_reason = event.get("finish_reason") + + usage = None + usage_data = event.get("usage", {}) + if isinstance(usage_data, dict): + mapped = CohereChatClient.map_usage_fields(usage_data) + if ( + mapped.get("input_tokens") is not None + or mapped.get("output_tokens") is not None + ): + usage = mapped + + return { + "content": "", + "finish_reason": finish_reason, + "usage": usage, + "raw_event": event, + } + + return None + + +__all__ = ["CohereChatStream"] diff --git a/packages/providers/cohere/src/celeste_cohere/py.typed b/packages/providers/cohere/src/celeste_cohere/py.typed new file mode 100644 index 00000000..e69de29b From 658eabe2baf76339d3986b6fcaca48bb7175ee64 Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Fri, 19 Dec 2025 11:52:36 +0100 Subject: [PATCH 02/11] feat(providers): add ElevenLabs Text-to-Speech API package Add new celeste-elevenlabs provider package with TTS API mixin: - ElevenLabsTextToSpeechClient mixin for binary audio handling - Binary audio streaming with _stream_binary_audio helper - Voice ID extraction for URL path construction - Output format to MIME type mapping - Parameter mappers for voice, output_format, speed, language_code --- packages/providers/elevenlabs/pyproject.toml | 18 ++ .../src/celeste_elevenlabs/__init__.py | 3 + .../text_to_speech/__init__.py | 1 + .../text_to_speech/client.py | 158 ++++++++++++++++++ .../text_to_speech/config.py | 17 ++ .../text_to_speech/parameters.py | 96 +++++++++++ 6 files changed, 293 insertions(+) create mode 100644 packages/providers/elevenlabs/pyproject.toml create mode 100644 packages/providers/elevenlabs/src/celeste_elevenlabs/__init__.py create mode 100644 packages/providers/elevenlabs/src/celeste_elevenlabs/text_to_speech/__init__.py create mode 100644 packages/providers/elevenlabs/src/celeste_elevenlabs/text_to_speech/client.py create mode 100644 packages/providers/elevenlabs/src/celeste_elevenlabs/text_to_speech/config.py create mode 100644 packages/providers/elevenlabs/src/celeste_elevenlabs/text_to_speech/parameters.py diff --git a/packages/providers/elevenlabs/pyproject.toml b/packages/providers/elevenlabs/pyproject.toml new file mode 100644 index 00000000..6849a9b7 --- /dev/null +++ b/packages/providers/elevenlabs/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "celeste-elevenlabs" +version = "0.3.0" +description = "ElevenLabs 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"] + +[tool.uv.sources] +celeste-ai = { workspace = true } + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/celeste_elevenlabs"] diff --git a/packages/providers/elevenlabs/src/celeste_elevenlabs/__init__.py b/packages/providers/elevenlabs/src/celeste_elevenlabs/__init__.py new file mode 100644 index 00000000..1017439c --- /dev/null +++ b/packages/providers/elevenlabs/src/celeste_elevenlabs/__init__.py @@ -0,0 +1,3 @@ +"""ElevenLabs provider package for Celeste AI.""" + +__all__: list[str] = [] diff --git a/packages/providers/elevenlabs/src/celeste_elevenlabs/text_to_speech/__init__.py b/packages/providers/elevenlabs/src/celeste_elevenlabs/text_to_speech/__init__.py new file mode 100644 index 00000000..0ff679af --- /dev/null +++ b/packages/providers/elevenlabs/src/celeste_elevenlabs/text_to_speech/__init__.py @@ -0,0 +1 @@ +"""ElevenLabs Text to Speech API provider package.""" diff --git a/packages/providers/elevenlabs/src/celeste_elevenlabs/text_to_speech/client.py b/packages/providers/elevenlabs/src/celeste_elevenlabs/text_to_speech/client.py new file mode 100644 index 00000000..1aab1bc0 --- /dev/null +++ b/packages/providers/elevenlabs/src/celeste_elevenlabs/text_to_speech/client.py @@ -0,0 +1,158 @@ +"""ElevenLabs Text-to-Speech API client with shared implementation.""" + +from collections.abc import AsyncIterator +from typing import Any + +import httpx + +from celeste.mime_types import ApplicationMimeType, AudioMimeType + +from . import config + + +class ElevenLabsTextToSpeechClient: + """Mixin for ElevenLabs Text-to-Speech API. + + Provides shared implementation for speech generation: + - _make_request() - HTTP POST to /v1/text-to-speech/{voice_id} + - _make_stream_request() - HTTP streaming with binary chunks + - _parse_usage() - Returns empty dict (TTS doesn't return usage) + - _map_output_format_to_mime_type() - Map format string to AudioMimeType + + The TTS endpoint returns binary audio data, not JSON. + Capability clients must handle the binary response in their generate() override. + + Usage: + class ElevenLabsSpeechGenerationClient(ElevenLabsTextToSpeechClient, 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 ElevenLabs TTS endpoint. + + Returns the raw response with binary audio content. + Voice ID is extracted from request_body["_voice_id"] and used in URL path. + """ + # Extract voice_id from request_body (set by VoiceMapper) + voice_id = request_body.pop("_voice_id", None) + if voice_id is None: + voice_id = parameters.get("voice", config.DEFAULT_VOICE_ID) + + # Set model_id + request_body["model_id"] = self.model.id # type: ignore[attr-defined] + + # Build URL with voice_id in path + endpoint = config.ElevenLabsTextToSpeechEndpoint.CREATE_SPEECH.format( + voice_id=voice_id + ) + + 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}{endpoint}", + headers=headers, + json_body=request_body, + ) + + def _make_stream_request( + self, + request_body: dict[str, Any], + **parameters: Any, + ) -> AsyncIterator[dict[str, Any]]: + """Make HTTP streaming request returning binary audio chunks. + + ElevenLabs streams binary audio data, not JSON SSE events. + We wrap the binary stream to yield dicts compatible with Stream interface. + """ + # Extract voice_id from request_body + voice_id = request_body.pop("_voice_id", None) + if voice_id is None: + voice_id = parameters.get("voice", config.DEFAULT_VOICE_ID) + + # Set model_id + request_body["model_id"] = self.model.id # type: ignore[attr-defined] + + # Build URL with voice_id in path + endpoint = config.ElevenLabsTextToSpeechEndpoint.STREAM_SPEECH.format( + voice_id=voice_id + ) + + headers = { + **self.auth.get_headers(), # type: ignore[attr-defined] + "Content-Type": ApplicationMimeType.JSON, + } + + return self._stream_binary_audio( + f"{config.BASE_URL}{endpoint}", + headers=headers, + json_body=request_body, + ) + + async def _stream_binary_audio( + self, + url: str, + headers: dict[str, str], + json_body: dict[str, Any], + ) -> AsyncIterator[dict[str, Any]]: + """Stream binary audio data and yield as dict events. + + Wraps httpx streaming to yield dicts compatible with Stream interface. + """ + client = await self.http_client._get_client() # type: ignore[attr-defined] + + async with client.stream( + "POST", + url, + json=json_body, + headers=headers, + ) as response: + if not response.is_success: + error_text = await response.aread() + msg = f"HTTP {response.status_code}: {error_text.decode('utf-8', errors='ignore')}" + raise httpx.HTTPStatusError( + msg, + request=response.request, + response=response, + ) + + async for chunk in response.aiter_bytes(): + if chunk: + yield {"data": chunk} + + def _parse_usage(self, response_data: dict[str, Any]) -> dict[str, int | None]: + """ElevenLabs TTS doesn't return usage in response body.""" + return {} + + def _map_output_format_to_mime_type( + self, output_format: str | None + ) -> AudioMimeType: + """Map ElevenLabs output_format to AudioMimeType. + + ElevenLabs format: {codec}_{sample_rate}_{bitrate} (e.g., mp3_44100_128) + """ + if output_format is None: + return AudioMimeType.MP3 + + parts = output_format.split("_") + if not parts: + return AudioMimeType.MP3 + + codec = parts[0].lower() + codec_map: dict[str, AudioMimeType] = { + "mp3": AudioMimeType.MP3, + "pcm": AudioMimeType.PCM, + "aac": AudioMimeType.AAC, + "flac": AudioMimeType.FLAC, + } + return codec_map.get(codec, AudioMimeType.MP3) + + +__all__ = ["ElevenLabsTextToSpeechClient"] diff --git a/packages/providers/elevenlabs/src/celeste_elevenlabs/text_to_speech/config.py b/packages/providers/elevenlabs/src/celeste_elevenlabs/text_to_speech/config.py new file mode 100644 index 00000000..aea0be89 --- /dev/null +++ b/packages/providers/elevenlabs/src/celeste_elevenlabs/text_to_speech/config.py @@ -0,0 +1,17 @@ +"""Configuration for ElevenLabs Text-to-Speech API.""" + +from enum import StrEnum + + +class ElevenLabsTextToSpeechEndpoint(StrEnum): + """Endpoints for Text-to-Speech API.""" + + CREATE_SPEECH = "/v1/text-to-speech/{voice_id}" + STREAM_SPEECH = "/v1/text-to-speech/{voice_id}/stream" + LIST_VOICES = "/v1/voices" + + +BASE_URL = "https://api.elevenlabs.io" + +# Default voice ID (Rachel) +DEFAULT_VOICE_ID = "21m00Tcm4TlvDq8ikWAM" diff --git a/packages/providers/elevenlabs/src/celeste_elevenlabs/text_to_speech/parameters.py b/packages/providers/elevenlabs/src/celeste_elevenlabs/text_to_speech/parameters.py new file mode 100644 index 00000000..22d691e2 --- /dev/null +++ b/packages/providers/elevenlabs/src/celeste_elevenlabs/text_to_speech/parameters.py @@ -0,0 +1,96 @@ +"""ElevenLabs Text To Speech API parameter mappers.""" + +from typing import Any + +from celeste.models import Model +from celeste.parameters import ParameterMapper + + +class VoiceMapper(ParameterMapper): + """Map voice parameter to ElevenLabs URL path. + + Note: Voice ID goes in URL path, not request body. + This mapper validates the voice_id but the actual URL construction + happens in _make_request(). + """ + + 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 not None: + request["_voice_id"] = validated_value + return request + + +class OutputFormatMapper(ParameterMapper): + """Map response_format parameter to ElevenLabs output_format field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform response_format into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + request["output_format"] = "mp3_44100_128" + return request + + request["output_format"] = validated_value + return request + + +class SpeedMapper(ParameterMapper): + """Map speed parameter to ElevenLabs voice_settings.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 + + if "voice_settings" not in request: + request["voice_settings"] = {} + + request["voice_settings"]["speed"] = validated_value + return request + + +class LanguageCodeMapper(ParameterMapper): + """Map language parameter to ElevenLabs language_code field. + + Only supported by eleven_turbo_v2_5 and eleven_flash_v2_5 models. + """ + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform language into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request["language_code"] = str(validated_value) + return request + + +__all__ = [ + "LanguageCodeMapper", + "OutputFormatMapper", + "SpeedMapper", + "VoiceMapper", +] From 80c855d8d512343f3fef9ca6df695673357ee748 Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Fri, 19 Dec 2025 11:53:02 +0100 Subject: [PATCH 03/11] feat(providers): add xAI Responses API package Add new celeste-xai provider package with Responses API mixin: - XAIResponsesClient mixin for nested output structure parsing - XAIResponsesStream mixin for SSE streaming support - Parameter mappers for temperature, max_tokens, reasoning_effort - Tool mappers for web_search, x_search, code_execution - Structured output support with strict JSON schema - Usage field mapping including reasoning tokens --- packages/providers/xai/pyproject.toml | 18 ++ .../providers/xai/src/celeste_xai/__init__.py | 3 + .../providers/xai/src/celeste_xai/py.typed | 0 .../xai/src/celeste_xai/responses/__init__.py | 1 + .../xai/src/celeste_xai/responses/client.py | 127 +++++++++++ .../xai/src/celeste_xai/responses/config.py | 12 + .../src/celeste_xai/responses/parameters.py | 206 ++++++++++++++++++ .../src/celeste_xai/responses/streaming.py | 71 ++++++ 8 files changed, 438 insertions(+) create mode 100644 packages/providers/xai/pyproject.toml create mode 100644 packages/providers/xai/src/celeste_xai/__init__.py create mode 100644 packages/providers/xai/src/celeste_xai/py.typed create mode 100644 packages/providers/xai/src/celeste_xai/responses/__init__.py create mode 100644 packages/providers/xai/src/celeste_xai/responses/client.py create mode 100644 packages/providers/xai/src/celeste_xai/responses/config.py create mode 100644 packages/providers/xai/src/celeste_xai/responses/parameters.py create mode 100644 packages/providers/xai/src/celeste_xai/responses/streaming.py diff --git a/packages/providers/xai/pyproject.toml b/packages/providers/xai/pyproject.toml new file mode 100644 index 00000000..650fd45b --- /dev/null +++ b/packages/providers/xai/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "celeste-xai" +version = "0.3.0" +description = "xAI 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"] + +[tool.uv.sources] +celeste-ai = { workspace = true } + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/celeste_xai"] diff --git a/packages/providers/xai/src/celeste_xai/__init__.py b/packages/providers/xai/src/celeste_xai/__init__.py new file mode 100644 index 00000000..6e887224 --- /dev/null +++ b/packages/providers/xai/src/celeste_xai/__init__.py @@ -0,0 +1,3 @@ +"""XAI provider package for Celeste AI.""" + +__all__: list[str] = [] diff --git a/packages/providers/xai/src/celeste_xai/py.typed b/packages/providers/xai/src/celeste_xai/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/packages/providers/xai/src/celeste_xai/responses/__init__.py b/packages/providers/xai/src/celeste_xai/responses/__init__.py new file mode 100644 index 00000000..7825c8cf --- /dev/null +++ b/packages/providers/xai/src/celeste_xai/responses/__init__.py @@ -0,0 +1 @@ +"""XAI Responses API provider package.""" diff --git a/packages/providers/xai/src/celeste_xai/responses/client.py b/packages/providers/xai/src/celeste_xai/responses/client.py new file mode 100644 index 00000000..9c62913e --- /dev/null +++ b/packages/providers/xai/src/celeste_xai/responses/client.py @@ -0,0 +1,127 @@ +"""XAI 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 XAIResponsesClient: + """Mixin for XAI 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 status + - _build_metadata() - Filter content fields + + Capability clients extend parsing methods via super() to wrap/transform results. + + Usage: + class XAITextGenerationClient(XAIResponsesClient, TextGenerationClient): + def _parse_content(self, response_data, **parameters): + output = super()._parse_content(response_data) + 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 XAI Responses 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.XAIResponsesEndpoint.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 XAI Responses 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.XAIResponsesEndpoint.CREATE_RESPONSE}", + headers=headers, + json_body=request_body, + ) + + @staticmethod + def map_usage_fields(usage_data: dict[str, Any]) -> dict[str, int | None]: + """Map XAI usage fields to unified names. + + Shared by client and streaming across all capabilities. + """ + input_details = usage_data.get("prompt_tokens_details", {}) + output_details = usage_data.get("completion_tokens_details", {}) + return { + UsageField.INPUT_TOKENS: usage_data.get("prompt_tokens"), + UsageField.OUTPUT_TOKENS: usage_data.get("completion_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: + """Return output array from response.""" + 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.""" + 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__ = ["XAIResponsesClient"] diff --git a/packages/providers/xai/src/celeste_xai/responses/config.py b/packages/providers/xai/src/celeste_xai/responses/config.py new file mode 100644 index 00000000..c28648e9 --- /dev/null +++ b/packages/providers/xai/src/celeste_xai/responses/config.py @@ -0,0 +1,12 @@ +"""Configuration for XAI Responses API.""" + +from enum import StrEnum + + +class XAIResponsesEndpoint(StrEnum): + """Endpoints for Responses API.""" + + CREATE_RESPONSE = "/v1/responses" + + +BASE_URL = "https://api.x.ai" diff --git a/packages/providers/xai/src/celeste_xai/responses/parameters.py b/packages/providers/xai/src/celeste_xai/responses/parameters.py new file mode 100644 index 00000000..3b1dce79 --- /dev/null +++ b/packages/providers/xai/src/celeste_xai/responses/parameters.py @@ -0,0 +1,206 @@ +"""xAI 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 XAI 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 XAI 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 XAI 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 WebSearchMapper(ParameterMapper): + """Map web_search to XAI tools array.""" + + 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 XSearchMapper(ParameterMapper): + """Map x_search to XAI tools array (search X/Twitter).""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform x_search into provider request.""" + validated_value = self._validate_value(value, model) + if not validated_value: + return request + + request.setdefault("tools", []).append({"type": "x_search"}) + return request + + +class CodeExecutionMapper(ParameterMapper): + """Map code_execution to XAI tools array.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform code_execution into provider request.""" + validated_value = self._validate_value(value, model) + if not validated_value: + return request + + request.setdefault("tools", []).append({"type": "code_execution"}) + return request + + +class OutputSchemaMapper(ParameterMapper): + """Map output_schema to XAI structured outputs format. + + Handles both single BaseModel and list[BaseModel] types. + XAI requires top-level object, so lists are wrapped in {items: []}. + """ + + 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: + # XAI 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__ = [ + "CodeExecutionMapper", + "MaxTokensMapper", + "OutputSchemaMapper", + "ReasoningEffortMapper", + "TemperatureMapper", + "WebSearchMapper", + "XSearchMapper", +] diff --git a/packages/providers/xai/src/celeste_xai/responses/streaming.py b/packages/providers/xai/src/celeste_xai/responses/streaming.py new file mode 100644 index 00000000..998f6af9 --- /dev/null +++ b/packages/providers/xai/src/celeste_xai/responses/streaming.py @@ -0,0 +1,71 @@ +"""XAI Responses SSE parsing for streaming.""" + +from typing import Any + +from .client import XAIResponsesClient + + +class XAIResponsesStream: + """Mixin for Responses API SSE parsing. + + Provides shared implementation for all capabilities using XAI 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 XAITextGenerationStream(XAIResponsesStream, 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 + + # Content delta events + 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, + } + + # Ignore done event (no data to extract) + if event_type == "response.output_text.done": + return None + + # Completion event with usage + if event_type == "response.completed": + response_data = event.get("response", {}) + usage_data = response_data.get("usage") + + usage = None + if usage_data: + usage = XAIResponsesClient.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__ = ["XAIResponsesStream"] From c84cba748ba88693826bfd4b0e56dd74dc4ac4d2 Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Fri, 19 Dec 2025 11:53:26 +0100 Subject: [PATCH 04/11] feat(speech-generation): add Language enum for multilingual TTS Add Language StrEnum with ISO 639-1 codes for speech generation: - 31 supported languages including Arabic, Chinese, Japanese, etc. - StrEnum allows direct string usage (Language.ENGLISH -> "en") - Used by ElevenLabs multilingual models (eleven_turbo_v2_5, eleven_flash_v2_5) - Export Language from package __init__.py --- .../src/celeste_speech_generation/__init__.py | 2 + .../celeste_speech_generation/languages.py | 47 +++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 packages/capabilities/speech-generation/src/celeste_speech_generation/languages.py diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/__init__.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/__init__.py index 231e54ad..b71207c0 100644 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/__init__.py +++ b/packages/capabilities/speech-generation/src/celeste_speech_generation/__init__.py @@ -21,6 +21,7 @@ def register_package() -> None: SpeechGenerationOutput, SpeechGenerationUsage, ) +from celeste_speech_generation.languages import Language # noqa: E402 # Aggregate voices from all providers (after Voice is imported) from celeste_speech_generation.providers.elevenlabs.voices import ( # noqa: E402 @@ -43,6 +44,7 @@ def register_package() -> None: __all__ = [ "VOICES", + "Language", "SpeechGenerationChunk", "SpeechGenerationInput", "SpeechGenerationOutput", diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/languages.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/languages.py new file mode 100644 index 00000000..d9c8d358 --- /dev/null +++ b/packages/capabilities/speech-generation/src/celeste_speech_generation/languages.py @@ -0,0 +1,47 @@ +"""Language definitions for speech generation.""" + +from enum import StrEnum + + +class Language(StrEnum): + """ISO 639-1 language codes for speech generation. + + Values are ISO 639-1 codes, allowing both enum and string usage: + - `Language.ENGLISH` → "en" + - `"en"` → works directly + """ + + ARABIC = "ar" + CHINESE = "zh" + CZECH = "cs" + DANISH = "da" + DUTCH = "nl" + ENGLISH = "en" + FILIPINO = "fil" + FINNISH = "fi" + FRENCH = "fr" + GERMAN = "de" + GREEK = "el" + HINDI = "hi" + HUNGARIAN = "hu" + INDONESIAN = "id" + ITALIAN = "it" + JAPANESE = "ja" + KOREAN = "ko" + MALAY = "ms" + NORWEGIAN = "no" + POLISH = "pl" + PORTUGUESE = "pt" + ROMANIAN = "ro" + RUSSIAN = "ru" + SLOVAK = "sk" + SPANISH = "es" + SWEDISH = "sv" + TAMIL = "ta" + THAI = "th" + TURKISH = "tr" + UKRAINIAN = "uk" + VIETNAMESE = "vi" + + +__all__ = ["Language"] From 51eb018267048f1b717240efeaddd929a21f1d18 Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Fri, 19 Dec 2025 11:54:20 +0100 Subject: [PATCH 05/11] refactor(speech-generation): migrate ElevenLabs to mixin pattern Migrate ElevenLabs provider to use celeste-elevenlabs package: - Inherit from ElevenLabsTextToSpeechClient mixin - Remove config.py (moved to provider package) - Expand voices list from 4 to 50+ default voices - Add all output formats (MP3, PCM, ulaw, alaw, opus variants) - Add Language parameter constraint for multilingual models - Use provider parameter mappers - Update pyproject.toml with celeste-elevenlabs dependency --- .../speech-generation/pyproject.toml | 1 + .../providers/elevenlabs/client.py | 131 +------- .../providers/elevenlabs/config.py | 10 - .../providers/elevenlabs/models.py | 86 ++--- .../providers/elevenlabs/parameters.py | 121 ++----- .../providers/elevenlabs/voices.py | 314 +++++++++++++++++- .../tests/integration_tests/conftest.py | 5 - 7 files changed, 370 insertions(+), 298 deletions(-) delete mode 100644 packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/config.py diff --git a/packages/capabilities/speech-generation/pyproject.toml b/packages/capabilities/speech-generation/pyproject.toml index e31e8811..e518eb7f 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-elevenlabs = { workspace = true } celeste-openai = { workspace = true } [project.entry-points."celeste.packages"] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/client.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/client.py index 8324bc2f..54f7ddad 100644 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/client.py +++ b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/client.py @@ -1,12 +1,10 @@ """ElevenLabs client implementation for speech generation.""" -from collections.abc import AsyncIterator from typing import Any, Unpack -import httpx +from celeste_elevenlabs.text_to_speech.client import ElevenLabsTextToSpeechClient 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 ( @@ -14,15 +12,18 @@ SpeechGenerationOutput, SpeechGenerationUsage, ) -from celeste_speech_generation.parameters import SpeechGenerationParameters +from celeste_speech_generation.parameters import ( + SpeechGenerationParameter, + SpeechGenerationParameters, +) -from . import config from .parameters import ELEVENLABS_PARAMETER_MAPPERS from .streaming import ElevenLabsSpeechGenerationStream -from .voices import ELEVENLABS_VOICES -class ElevenLabsSpeechGenerationClient(SpeechGenerationClient): +class ElevenLabsSpeechGenerationClient( + ElevenLabsTextToSpeechClient, SpeechGenerationClient +): """ElevenLabs client for speech generation.""" @classmethod @@ -34,11 +35,9 @@ def _init_request(self, inputs: SpeechGenerationInput) -> dict[str, Any]: return {"text": inputs.text} def _parse_usage(self, response_data: dict[str, Any]) -> SpeechGenerationUsage: - """Parse usage from response. - - ElevenLabs 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, @@ -54,50 +53,6 @@ def _parse_content( msg = "ElevenLabs TTS returns binary responses, use generate() override" raise NotImplementedError(msg) - def _map_output_format_to_mime_type( - self, output_format: str | None - ) -> AudioMimeType: - """Map ElevenLabs output_format string to AudioMimeType.""" - if output_format is None: - return AudioMimeType.MP3 - - # Parse format: {codec}_{sample_rate}_{bitrate} - # e.g., mp3_44100_128, pcm_22050_16 - parts = output_format.split("_") - if not parts: - return AudioMimeType.MP3 - - codec = parts[0].lower() - codec_map: dict[str, AudioMimeType] = { - "mp3": AudioMimeType.MP3, - "pcm": AudioMimeType.PCM, - "aac": AudioMimeType.AAC, - "flac": AudioMimeType.FLAC, - } - return codec_map.get(codec, 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.""" - voice_id = request_body.get("_voice_id") or ELEVENLABS_VOICES[0].id - request_body.pop("_voice_id", None) # Remove temporary key if present - request_body["model_id"] = self.model.id - endpoint = config.ENDPOINT.format(voice_id=voice_id) - - headers = { - **self.auth.get_headers(), - "Content-Type": ApplicationMimeType.JSON, - } - - return await self.http_client.post( - f"{config.BASE_URL}{endpoint}", - headers=headers, - json_body=request_body, - ) - async def generate( self, *args: str, @@ -120,7 +75,9 @@ async def generate( raise ValueError(msg) # Determine MIME type from output_format parameter - output_format = parameters.get("response_format") or "mp3_44100_128" + output_format = ( + parameters.get(SpeechGenerationParameter.OUTPUT_FORMAT) or "mp3_44100_128" + ) mime_type = self._map_output_format_to_mime_type(output_format) # Extract headers from response (ElevenLabs returns metadata like request-id in headers) @@ -136,65 +93,5 @@ def _stream_class(self) -> type[ElevenLabsSpeechGenerationStream]: """Return the Stream class for this client.""" return ElevenLabsSpeechGenerationStream - def _make_stream_request( - self, - request_body: dict[str, Any], - **parameters: Unpack[SpeechGenerationParameters], - ) -> AsyncIterator[dict[str, Any]]: - """Make HTTP streaming request and return async iterator of binary audio chunks. - - ElevenLabs streams binary audio data, not JSON SSE events. - We wrap the binary stream to yield dicts compatible with Stream interface. - """ - voice_id = request_body.get("_voice_id") or ELEVENLABS_VOICES[0].id - request_body.pop("_voice_id", None) # Remove temporary key if present - request_body["model_id"] = self.model.id - stream_endpoint = config.STREAM_ENDPOINT.format(voice_id=voice_id) - - headers = { - **self.auth.get_headers(), - "Content-Type": ApplicationMimeType.JSON, - } - - return self._stream_binary_audio( - f"{config.BASE_URL}{stream_endpoint}", - headers=headers, - json_body=request_body, - ) - - async def _stream_binary_audio( - self, - url: str, - headers: dict[str, str], - json_body: dict[str, Any], - ) -> AsyncIterator[dict[str, Any]]: - """Stream binary audio data and yield as dict events. - - Wraps httpx streaming to yield dicts compatible with Stream interface. - """ - client = await self.http_client._get_client() - - async with client.stream( - "POST", - url, - json=json_body, - headers=headers, - ) as response: - # Check for errors - if not response.is_success: - error_text = await response.aread() - msg = f"HTTP {response.status_code}: {error_text.decode('utf-8', errors='ignore')}" - raise httpx.HTTPStatusError( - msg, - request=response.request, - response=response, - ) - - # Stream binary audio chunks - async for chunk in response.aiter_bytes(): - if chunk: - # Yield as dict to match Stream interface expectation - yield {"data": chunk} - __all__ = ["ElevenLabsSpeechGenerationClient"] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/config.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/config.py deleted file mode 100644 index f9493128..00000000 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/config.py +++ /dev/null @@ -1,10 +0,0 @@ -"""ElevenLabs provider configuration for speech generation.""" - -# HTTP Configuration -BASE_URL = "https://api.elevenlabs.io" -ENDPOINT = "/v1/text-to-speech/{voice_id}" -STREAM_ENDPOINT = "/v1/text-to-speech/{voice_id}/stream" - -# Authentication -AUTH_HEADER_NAME = "xi-api-key" -AUTH_HEADER_PREFIX = "" # No prefix, just the API key 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 1e0b8c5e..7fc76481 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 @@ -3,26 +3,45 @@ from celeste import Model, Provider from celeste.constraints import Choice, Range from celeste_speech_generation.constraints import VoiceConstraint +from celeste_speech_generation.languages import Language from celeste_speech_generation.parameters import SpeechGenerationParameter from .voices import ELEVENLABS_VOICES +# Valid output formats for ElevenLabs API +ELEVENLABS_OUTPUT_FORMATS = [ + "mp3_22050_32", + "mp3_44100_32", + "mp3_44100_64", + "mp3_44100_96", + "mp3_44100_128", + "mp3_44100_192", + "pcm_8000", + "pcm_16000", + "pcm_22050", + "pcm_24000", + "pcm_44100", + "pcm_48000", + "ulaw_8000", + "alaw_8000", + "opus_48000_32", + "opus_48000_64", + "opus_48000_96", + "opus_48000_128", + "opus_48000_192", +] + MODELS: list[Model] = [ Model( id="eleven_v3", provider=Provider.ELEVENLABS, - display_name="Eleven v3", + display_name="Eleven v3 (Alpha)", streaming=True, parameter_constraints={ SpeechGenerationParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES), SpeechGenerationParameter.SPEED: Range(min=0.7, max=1.2), SpeechGenerationParameter.OUTPUT_FORMAT: Choice( - options=[ - "mp3_44100_128", - "pcm_22050_16", - "pcm_24000_16", - "pcm_44100_16", - ] + options=ELEVENLABS_OUTPUT_FORMATS ), }, ), @@ -35,12 +54,7 @@ SpeechGenerationParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES), SpeechGenerationParameter.SPEED: Range(min=0.7, max=1.2), SpeechGenerationParameter.OUTPUT_FORMAT: Choice( - options=[ - "mp3_44100_128", - "pcm_22050_16", - "pcm_24000_16", - "pcm_44100_16", - ] + options=ELEVENLABS_OUTPUT_FORMATS ), }, ), @@ -52,13 +66,9 @@ parameter_constraints={ SpeechGenerationParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES), SpeechGenerationParameter.SPEED: Range(min=0.7, max=1.2), + SpeechGenerationParameter.LANGUAGE: Choice(options=list(Language)), SpeechGenerationParameter.OUTPUT_FORMAT: Choice( - options=[ - "mp3_44100_128", - "pcm_22050_16", - "pcm_24000_16", - "pcm_44100_16", - ] + options=ELEVENLABS_OUTPUT_FORMATS ), }, ), @@ -71,12 +81,7 @@ SpeechGenerationParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES), SpeechGenerationParameter.SPEED: Range(min=0.7, max=1.2), SpeechGenerationParameter.OUTPUT_FORMAT: Choice( - options=[ - "mp3_44100_128", - "pcm_22050_16", - "pcm_24000_16", - "pcm_44100_16", - ] + options=ELEVENLABS_OUTPUT_FORMATS ), }, ), @@ -88,13 +93,9 @@ parameter_constraints={ SpeechGenerationParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES), SpeechGenerationParameter.SPEED: Range(min=0.7, max=1.2), + SpeechGenerationParameter.LANGUAGE: Choice(options=list(Language)), SpeechGenerationParameter.OUTPUT_FORMAT: Choice( - options=[ - "mp3_44100_128", - "pcm_22050_16", - "pcm_24000_16", - "pcm_44100_16", - ] + options=ELEVENLABS_OUTPUT_FORMATS ), }, ), @@ -107,12 +108,7 @@ SpeechGenerationParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES), SpeechGenerationParameter.SPEED: Range(min=0.7, max=1.2), SpeechGenerationParameter.OUTPUT_FORMAT: Choice( - options=[ - "mp3_44100_128", - "pcm_22050_16", - "pcm_24000_16", - "pcm_44100_16", - ] + options=ELEVENLABS_OUTPUT_FORMATS ), }, ), @@ -125,30 +121,20 @@ SpeechGenerationParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES), SpeechGenerationParameter.SPEED: Range(min=0.7, max=1.2), SpeechGenerationParameter.OUTPUT_FORMAT: Choice( - options=[ - "mp3_44100_128", - "pcm_22050_16", - "pcm_24000_16", - "pcm_44100_16", - ] + options=ELEVENLABS_OUTPUT_FORMATS ), }, ), Model( id="eleven_monolingual_v1", provider=Provider.ELEVENLABS, - display_name="Eleven Monolingual v1", + display_name="Eleven English v1", streaming=True, parameter_constraints={ SpeechGenerationParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES), SpeechGenerationParameter.SPEED: Range(min=0.7, max=1.2), SpeechGenerationParameter.OUTPUT_FORMAT: Choice( - options=[ - "mp3_44100_128", - "pcm_22050_16", - "pcm_24000_16", - "pcm_44100_16", - ] + options=ELEVENLABS_OUTPUT_FORMATS ), }, ), 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 543353bc..6d8fd72e 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 @@ -1,124 +1,43 @@ -"""ElevenLabs parameter mappers for speech generation.""" +"""ElevenLabs Text To Speech parameter mappers for speech generation.""" + +from celeste_elevenlabs.text_to_speech.parameters import ( + LanguageCodeMapper as _LanguageCodeMapper, +) +from celeste_elevenlabs.text_to_speech.parameters import ( + OutputFormatMapper as _OutputFormatMapper, +) +from celeste_elevenlabs.text_to_speech.parameters import ( + SpeedMapper as _SpeedMapper, +) +from celeste_elevenlabs.text_to_speech.parameters import ( + VoiceMapper as _VoiceMapper, +) -from typing import Any - -from celeste.models import Model from celeste.parameters import ParameterMapper from celeste_speech_generation.parameters import SpeechGenerationParameter -class VoiceMapper(ParameterMapper): - """Map voice parameter to ElevenLabs URL path. - - Note: Voice ID goes in URL path, not request body. - This mapper validates the voice_id but the actual URL construction - happens in _make_request(). - """ - +class VoiceMapper(_VoiceMapper): name = SpeechGenerationParameter.VOICE - def map( - self, - request: dict[str, Any], - value: object, - model: Model, - ) -> dict[str, Any]: - """Validate and store voice ID for URL construction. - - ElevenLabs requires voice_id in the URL path, not the request body. - This mapper validates the voice_id and stores it in the request dict - under '_voice_id' for later use in _make_request(). - - Args: - request: Provider request dictionary to modify. - value: The voice ID or name (e.g., 'Rachel', '21m00Tcm4TlvDq8ikWAM'). - model: Model instance with parameter constraints. - - Returns: - Modified request dictionary with _voice_id key. - """ - validated_value = self._validate_value(value, model) - # Voice ID is stored in request for later use in _make_request() - # but not added to request body - if validated_value is not None: - request["_voice_id"] = validated_value - return request - - -class OutputFormatMapper(ParameterMapper): - """Map response_format parameter to ElevenLabs output_format field.""" +class OutputFormatMapper(_OutputFormatMapper): name = SpeechGenerationParameter.OUTPUT_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 ElevenLabs output_format. - Defaults to 'mp3_44100_128' if not provided. - - Args: - request: Provider request dictionary to modify. - value: Output format string (e.g., 'mp3_44100_128', 'pcm_22050_16'). - model: Model instance with parameter constraints. - - Returns: - Modified request dictionary with output_format parameter. - """ - validated_value = self._validate_value(value, model) - if validated_value is None: - # Default to mp3_44100_128 if not provided - request["output_format"] = "mp3_44100_128" - return request - - request["output_format"] = validated_value - return request - - -class SpeedMapper(ParameterMapper): - """Map speed parameter to ElevenLabs voice_settings.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 ElevenLabs voice_settings.speed. - Valid range is 0.7 to 1.2 for ElevenLabs models. - - Args: - request: Provider request dictionary to modify. - value: The playback speed multiplier (0.7 to 1.2). - model: Model instance with parameter constraints. - - Returns: - Modified request dictionary with voice_settings.speed parameter. - """ - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - - # Ensure voice_settings object exists - if "voice_settings" not in request: - request["voice_settings"] = {} - request["voice_settings"]["speed"] = validated_value - return request +class LanguageCodeMapper(_LanguageCodeMapper): + name = SpeechGenerationParameter.LANGUAGE ELEVENLABS_PARAMETER_MAPPERS: list[ParameterMapper] = [ VoiceMapper(), OutputFormatMapper(), SpeedMapper(), + LanguageCodeMapper(), ] __all__ = ["ELEVENLABS_PARAMETER_MAPPERS"] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/voices.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/voices.py index f668c2ff..b6337e3c 100644 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/voices.py +++ b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/elevenlabs/voices.py @@ -3,36 +3,320 @@ from celeste import Provider from celeste_speech_generation.voices import Voice -# Common ElevenLabs voices (hardcoded initially) +# ElevenLabs default voices (from API: GET /v2/voices?voice_type=default) ELEVENLABS_VOICES = [ Voice( - id="pNInz6obpgDQGcFmaJgB", + id="21m00Tcm4TlvDq8ikWAM", provider=Provider.ELEVENLABS, - name="Adam", - languages=set(), # ElevenLabs voices support multiple languages but don't specify per-voice - ), # Keep - replacement for Antoni/Arnold + name="Rachel", + languages=set(), + ), + Voice( + id="29vD33N1CtxCmqQRPOHJ", + provider=Provider.ELEVENLABS, + name="Drew", + languages=set(), + ), + Voice( + id="2EiwWnXFnvU5JabPnv8n", + provider=Provider.ELEVENLABS, + name="Clyde", + languages=set(), + ), + Voice( + id="5Q0t7uMcjvnagumLfvZi", + provider=Provider.ELEVENLABS, + name="Paul", + languages=set(), + ), + Voice( + id="9BWtsMINqrJLrRacOk9x", + provider=Provider.ELEVENLABS, + name="Aria", + languages=set(), + ), + Voice( + id="AZnzlk1XvdvUeBnXmlld", + provider=Provider.ELEVENLABS, + name="Domi", + languages=set(), + ), + Voice( + id="CYw3kZ02Hs0563khs1Fj", + provider=Provider.ELEVENLABS, + name="Dave", + languages=set(), + ), + Voice( + id="CwhRBWXzGAHq8TQ4Fs17", + provider=Provider.ELEVENLABS, + name="Roger", + languages=set(), + ), + Voice( + id="D38z5RcWu1voky8WS1ja", + provider=Provider.ELEVENLABS, + name="Fin", + languages=set(), + ), Voice( id="EXAVITQu4vr4xnSDxMaL", provider=Provider.ELEVENLABS, - name="Bella", + name="Sarah", languages=set(), - ), # Keep - not being replaced + ), + Voice( + id="ErXwobaYiN019PkySvjV", + provider=Provider.ELEVENLABS, + name="Antoni", + languages=set(), + ), Voice( id="FGY2WhTYpPnrIDTdsKH5", provider=Provider.ELEVENLABS, name="Laura", languages=set(), - ), # NEW - replaces Domi (ID confirmed from API) + ), + Voice( + id="GBv7mTt0atIp3Br8iCZE", + provider=Provider.ELEVENLABS, + name="Thomas", + languages=set(), + ), + Voice( + id="IKne3meq5aSn9XLyUdCD", + provider=Provider.ELEVENLABS, + name="Charlie", + languages=set(), + ), + Voice( + id="JBFqnCBsd6RMkjVDRZzb", + provider=Provider.ELEVENLABS, + name="George", + languages=set(), + ), + Voice( + id="LcfcDJNUP1GQjkzn1xUU", + provider=Provider.ELEVENLABS, + name="Emily", + languages=set(), + ), + Voice( + id="MF3mGyEYCl7XYWbV9V6O", + provider=Provider.ELEVENLABS, + name="Elli", + languages=set(), + ), + Voice( + id="N2lVS1w4EtoT3dr4eOWO", + provider=Provider.ELEVENLABS, + name="Callum", + languages=set(), + ), + Voice( + id="ODq5zmih8GrVes37Dizd", + provider=Provider.ELEVENLABS, + name="Patrick", + languages=set(), + ), + Voice( + id="SAz9YHcvj6GT2YYXdXww", + provider=Provider.ELEVENLABS, + name="River", + languages=set(), + ), + Voice( + id="SOYHLrjzK2X1ezoPC6cr", + provider=Provider.ELEVENLABS, + name="Harry", + languages=set(), + ), + Voice( + id="TX3LPaxmHKxFdv7VOQHJ", + provider=Provider.ELEVENLABS, + name="Liam", + languages=set(), + ), + Voice( + id="ThT5KcBeYPX3keUQqHPh", + provider=Provider.ELEVENLABS, + name="Dorothy", + languages=set(), + ), + Voice( + id="TxGEqnHWrfWFTfGW9XjX", + provider=Provider.ELEVENLABS, + name="Josh", + languages=set(), + ), + Voice( + id="VR6AewLTigWG4xSOukaG", + provider=Provider.ELEVENLABS, + name="Arnold", + languages=set(), + ), + Voice( + id="XB0fDUnXU5powFXDhCwa", + provider=Provider.ELEVENLABS, + name="Charlotte", + languages=set(), + ), + Voice( + id="Xb7hH8MSUJpSbSDYk0k2", + provider=Provider.ELEVENLABS, + name="Alice", + languages=set(), + ), + Voice( + id="XrExE9yKIg1WjnnlVkGX", + provider=Provider.ELEVENLABS, + name="Matilda", + languages=set(), + ), + Voice( + id="ZQe5CZNOzWyzPSCn5a3c", + provider=Provider.ELEVENLABS, + name="James", + languages=set(), + ), + Voice( + id="Zlb1dXrM653N07WRdFW3", + provider=Provider.ELEVENLABS, + name="Joseph", + languages=set(), + ), + Voice( + id="bIHbv24MWmeRgasZH58o", + provider=Provider.ELEVENLABS, + name="Will", + languages=set(), + ), + Voice( + id="bVMeCyTHy58xNoL34h3p", + provider=Provider.ELEVENLABS, + name="Jeremy", + languages=set(), + ), + Voice( + id="cgSgspJ2msm6clMCkdW9", + provider=Provider.ELEVENLABS, + name="Jessica", + languages=set(), + ), + Voice( + id="cjVigY5qzO86Huf0OWal", + provider=Provider.ELEVENLABS, + name="Eric", + languages=set(), + ), + Voice( + id="flq6f7yk4E4fJM5XTYuZ", + provider=Provider.ELEVENLABS, + name="Michael", + languages=set(), + ), + Voice( + id="g5CIjZEefAph4nQFvHAz", + provider=Provider.ELEVENLABS, + name="Ethan", + languages=set(), + ), + Voice( + id="iP95p4xoKVk53GoZ742B", + provider=Provider.ELEVENLABS, + name="Chris", + languages=set(), + ), + Voice( + id="jBpfuIE2acCO8z3wKNLl", + provider=Provider.ELEVENLABS, + name="Gigi", + languages=set(), + ), + Voice( + id="jsCqWAovK2LkecY7zXl4", + provider=Provider.ELEVENLABS, + name="Freya", + languages=set(), + ), + Voice( + id="nPczCjzI2devNBz1zQrb", + provider=Provider.ELEVENLABS, + name="Brian", + languages=set(), + ), + Voice( + id="oWAxZDx7w5VEj9dCyTzz", + provider=Provider.ELEVENLABS, + name="Grace", + languages=set(), + ), + Voice( + id="onwK4e9ZLuTAKqWW03F9", + provider=Provider.ELEVENLABS, + name="Daniel", + languages=set(), + ), + Voice( + id="pFZP5JQG7iQjIQuC4Bku", + provider=Provider.ELEVENLABS, + name="Lily", + languages=set(), + ), + Voice( + id="pMsXgVXv3BLzUgSXRplE", + provider=Provider.ELEVENLABS, + name="Serena", + languages=set(), + ), + Voice( + id="pNInz6obpgDQGcFmaJgB", + provider=Provider.ELEVENLABS, + name="Adam", + languages=set(), + ), + Voice( + id="piTKgcLEGmPE4e6mEKli", + provider=Provider.ELEVENLABS, + name="Nicole", + languages=set(), + ), + Voice( + id="pqHfZKP75CvOlQylNhV4", + provider=Provider.ELEVENLABS, + name="Bill", + languages=set(), + ), + Voice( + id="t0jbNlBVZ17f02VDIeMI", + provider=Provider.ELEVENLABS, + name="Jessie", + languages=set(), + ), + Voice( + id="yoZ06aMxZJJ28mfd3POQ", + provider=Provider.ELEVENLABS, + name="Sam", + languages=set(), + ), + Voice( + id="z9fAnlkpzviPz146aGWa", + provider=Provider.ELEVENLABS, + name="Glinda", + languages=set(), + ), + Voice( + id="zcAOhNBS3c14rBihAFp1", + provider=Provider.ELEVENLABS, + name="Giovanni", + languages=set(), + ), Voice( - id="NOpBlnGInO9m6vDvFkFC", + id="zrHiDhphv9ZnVXBqCLjz", provider=Provider.ELEVENLABS, - name="Spuds", + name="Mimi", languages=set(), - ), # Spuds (Grandpa Spuds Oxley) - # TODO: Add Janet when available in API (replaces Rachel, Serena, Glinda) - # TODO: Add Peter when available in API (replaces Elli, Fin) - # TODO: Add Craig when available in API (replaces Josh, Jeremy) - # TODO: Add Riley when available in API (replaces Sam, Grace) + ), ] __all__ = ["ELEVENLABS_VOICES"] diff --git a/packages/capabilities/speech-generation/tests/integration_tests/conftest.py b/packages/capabilities/speech-generation/tests/integration_tests/conftest.py index 81c92564..d06d892f 100644 --- a/packages/capabilities/speech-generation/tests/integration_tests/conftest.py +++ b/packages/capabilities/speech-generation/tests/integration_tests/conftest.py @@ -1,7 +1,6 @@ """Pytest configuration and fixtures for integration tests.""" from collections.abc import AsyncGenerator -from typing import Any import pytest_asyncio @@ -18,7 +17,3 @@ async def cleanup_http_clients() -> AsyncGenerator[None, None]: """ yield await close_all_http_clients() - - -def pytest_configure(config: Any) -> None: # noqa: ANN401 - config.addinivalue_line("markers", "integration: mark test as an integration test") From a4a1e36b8a61dc028d9b25d67d2e5183bf94d8ff Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Fri, 19 Dec 2025 11:54:58 +0100 Subject: [PATCH 06/11] refactor(text-generation): migrate Cohere to mixin pattern Migrate Cohere provider to use celeste-cohere package: - Inherit from CohereChatClient and CohereChatStream mixins - Remove config.py (moved to provider package) - Use provider parameter mappers via imports - Update streaming to use provider stream mixin - Update pyproject.toml with celeste-cohere dependency --- .../text-generation/pyproject.toml | 2 + .../providers/cohere/client.py | 86 +------ .../providers/cohere/config.py | 11 - .../providers/cohere/parameters.py | 221 +++--------------- .../providers/cohere/streaming.py | 135 +++-------- 5 files changed, 71 insertions(+), 384 deletions(-) delete mode 100644 packages/capabilities/text-generation/src/celeste_text_generation/providers/cohere/config.py diff --git a/packages/capabilities/text-generation/pyproject.toml b/packages/capabilities/text-generation/pyproject.toml index 9976d70b..03ccae7c 100644 --- a/packages/capabilities/text-generation/pyproject.toml +++ b/packages/capabilities/text-generation/pyproject.toml @@ -28,9 +28,11 @@ Issues = "https://github.com/withceleste/celeste-python/issues" [tool.uv.sources] celeste-ai = { workspace = true } celeste-anthropic = { workspace = true } +celeste-cohere = { workspace = true } celeste-google = { workspace = true } celeste-mistral = { workspace = true } celeste-openai = { workspace = true } +celeste-xai = { workspace = true } [project.entry-points."celeste.packages"] text-generation = "celeste_text_generation:register_package" diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/cohere/client.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/cohere/client.py index 6ec85c9b..b8eac45f 100644 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/cohere/client.py +++ b/packages/capabilities/text-generation/src/celeste_text_generation/providers/cohere/client.py @@ -1,13 +1,11 @@ """Cohere client implementation for text generation.""" -from collections.abc import AsyncIterator from typing import Any, Unpack -import httpx -from pydantic import BaseModel +from celeste_cohere.chat.client import CohereChatClient -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 COHERE_PARAMETER_MAPPERS from .streaming import CohereTextGenerationStream -class CohereTextGenerationClient(TextGenerationClient): +class CohereTextGenerationClient(CohereChatClient, TextGenerationClient): """Cohere client for text generation.""" @classmethod @@ -41,29 +38,14 @@ 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", {}) - - billed_units = usage_data.get("billed_units", {}) - tokens = usage_data.get("tokens", {}) - - input_tokens = billed_units.get("input_tokens") - output_tokens = billed_units.get("output_tokens") - - if input_tokens is not None or output_tokens is not None: - return TextGenerationUsage( - input_tokens=input_tokens, - output_tokens=output_tokens, - total_tokens=tokens.get("total_tokens") if tokens else None, - cached_tokens=usage_data.get("cached_tokens"), - ) - - return TextGenerationUsage() + 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.""" message = response_data.get("message", {}) content_array = message.get("content", []) @@ -78,66 +60,14 @@ def _parse_content( def _parse_finish_reason( self, response_data: dict[str, Any] - ) -> TextGenerationFinishReason | None: + ) -> TextGenerationFinishReason: """Parse finish reason from response.""" finish_reason_str = response_data.get("finish_reason") - return ( - TextGenerationFinishReason(reason=finish_reason_str) - if finish_reason_str - else 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 = {"message"} - 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, - ) + return TextGenerationFinishReason(reason=finish_reason_str) def _stream_class(self) -> type[CohereTextGenerationStream]: """Return the Stream class for this client.""" return CohereTextGenerationStream - 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__ = ["CohereTextGenerationClient"] diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/cohere/config.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/cohere/config.py deleted file mode 100644 index 7e2e967d..00000000 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/cohere/config.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Cohere provider configuration for text generation.""" - -# HTTP Configuration -BASE_URL = "https://api.cohere.com" -ENDPOINT = "/v2/chat" -STREAM_ENDPOINT = ENDPOINT - -# Authentication -AUTH_HEADER_NAME = "Authorization" -AUTH_HEADER_PREFIX = "Bearer " -CLIENT_NAME_HEADER = "X-Client-Name" diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/cohere/parameters.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/cohere/parameters.py index ddab9333..809d7dc7 100644 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/cohere/parameters.py +++ b/packages/capabilities/text-generation/src/celeste_text_generation/providers/cohere/parameters.py @@ -1,9 +1,19 @@ -"""Cohere parameter mappers for text generation.""" - -import json -from typing import Any, get_args, get_origin - -from pydantic import BaseModel, TypeAdapter +"""Cohere Chat parameter mappers for text generation.""" + +from typing import Any + +from celeste_cohere.chat.parameters import ( + MaxTokensMapper as _MaxTokensMapper, +) +from celeste_cohere.chat.parameters import ( + OutputSchemaMapper as _OutputSchemaMapper, +) +from celeste_cohere.chat.parameters import ( + TemperatureMapper as _TemperatureMapper, +) +from celeste_cohere.chat.parameters import ( + ThinkingMapper as _ThinkingMapper, +) from celeste.core import Parameter from celeste.models import Model @@ -11,48 +21,16 @@ from celeste_text_generation.parameters import TextGenerationParameter -class TemperatureMapper(ParameterMapper): - """Map temperature parameter to Cohere 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 Cohere max_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_tokens"] = validated_value - return request - -class ThinkingBudgetMapper(ParameterMapper): - """Map thinking_budget parameter to Cohere thinking parameter.""" +class ThinkingBudgetMapper(_ThinkingMapper): + """Translate unified thinking_budget values to Cohere-native format.""" name = TextGenerationParameter.THINKING_BUDGET @@ -62,170 +40,25 @@ def map( value: object, model: Model, ) -> dict[str, Any]: - """Transform thinking_budget into provider request. - - Maps unified thinking_budget to Cohere thinking parameter: - - -1: Unlimited thinking ({"type": "enabled"}) - - 0: Disable thinking ({"type": "disabled"}) - - > 0: Set token budget ({"token_budget": value}) - """ + """Transform thinking_budget with unified value translation.""" validated_value = self._validate_value(value, model) if validated_value is None: return request - # Map to Cohere thinking parameter format + # Translate unified → provider-native if validated_value == -1: - # Unlimited thinking (default) - request["thinking"] = {"type": "enabled"} + provider_value = "enabled" elif validated_value == 0: - # Disable thinking - request["thinking"] = {"type": "disabled"} + provider_value = "disabled" else: - # Set token budget - request["thinking"] = {"token_budget": validated_value} - - return request + provider_value = validated_value + return super().map(request, provider_value, model) -class OutputSchemaMapper(ParameterMapper): - """Map output_schema parameter to Cohere response_format.""" +class OutputSchemaMapper(_OutputSchemaMapper): name = TextGenerationParameter.OUTPUT_SCHEMA - def map( - self, - request: dict[str, Any], - value: object, - model: Model, - ) -> dict[str, Any]: - """Transform output_schema into provider request. - - Converts Pydantic BaseModel or list[BaseModel] to Cohere JSON Schema format. - Sets request["response_format"] = {"type": "json_object", "schema": {...}}. - """ - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - - schema = self._convert_to_cohere_schema(validated_value) - request["response_format"] = { - "type": "json_object", - "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. - - Args: - content: Raw text content (JSON string when output_schema is set). - value: Original output_schema parameter value. - - Returns: - BaseModel instance if value provided, otherwise str unchanged. - """ - if value is None: - return content - - # Parse JSON string first - parsed_json = json.loads(content) - - # For list[T] models, unwrap the items wrapper (Cohere wraps arrays in {"items": [...]}) - origin = get_origin(value) - if origin is list and isinstance(parsed_json, dict) and "items" in parsed_json: - parsed_json = parsed_json["items"] - - # Parse to BaseModel instance using TypeAdapter - # TypeAdapter handles both Person and list[Person] - return TypeAdapter(value).validate_json(json.dumps(parsed_json)) - - def _convert_to_cohere_schema(self, output_schema: Any) -> dict[str, Any]: # noqa: ANN401 - """Convert Pydantic BaseModel or list[BaseModel] to Cohere JSON Schema format. - - Cohere requires flattened schemas without $ref/$defs. - For list[T] models, wraps array schema in object with items property. - """ - origin = get_origin(output_schema) - if origin is list: - # For list[T], wrap array schema in object wrapper - 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: - # For BaseModel, use model_json_schema directly - json_schema = output_schema.model_json_schema() - - # Resolve $ref references inline (Cohere requires flattened schemas) - json_schema = self._resolve_refs(json_schema) - - return json_schema - - def _resolve_refs(self, schema: dict[str, Any]) -> dict[str, Any]: - """Resolve all $ref references and inline definitions (Cohere requires flattened schemas). - - This method: - 1. Collects all $defs dictionaries from the schema tree - 2. Removes $defs keys from the schema - 3. Replaces $ref references with inlined definitions - 4. Recursively processes nested objects/arrays - """ - defs: dict[str, Any] = {} - - def collect_defs(value: object) -> None: - """Recursively collect all $defs dictionaries.""" - if isinstance(value, dict): - if "$defs" in value: - defs.update(value["$defs"]) - for v in value.values(): - collect_defs(v) - elif isinstance(value, list): - for item in value: - collect_defs(item) - - collect_defs(schema) - - def remove_defs(value: object) -> object: - """Recursively remove all $defs keys.""" - if isinstance(value, dict): - result = {k: remove_defs(v) for k, v in value.items() if k != "$defs"} - return result - elif isinstance(value, list): - return [remove_defs(item) for item in value] - return value - - schema = remove_defs(schema) - - def resolve(value: object) -> object: - """Recursively resolve $ref references in schema.""" - if isinstance(value, dict): - if "$ref" in value: - ref_path = value["$ref"] - if ref_path.startswith("#/$defs/"): - ref_name = ref_path.split("/")[-1] - if ref_name in defs: - resolved = defs[ref_name].copy() - # Merge any additional properties from the $ref object - resolved.update( - {k: v for k, v in value.items() if k != "$ref"} - ) - return resolve(resolved) - return {k: resolve(v) for k, v in value.items()} - elif isinstance(value, list): - return [resolve(item) for item in value] - return value - - return resolve(schema) - COHERE_PARAMETER_MAPPERS: list[ParameterMapper] = [ TemperatureMapper(), diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/cohere/streaming.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/cohere/streaming.py index 1567bc83..79fcdf81 100644 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/cohere/streaming.py +++ b/packages/capabilities/text-generation/src/celeste_text_generation/providers/cohere/streaming.py @@ -1,9 +1,11 @@ """Cohere streaming for text generation.""" -import logging from collections.abc import Callable from typing import Any, Unpack +from celeste_cohere.chat.streaming import CohereChatStream + +from celeste.types import StructuredOutput from celeste_text_generation.io import ( TextGenerationChunk, TextGenerationFinishReason, @@ -13,121 +15,45 @@ from celeste_text_generation.parameters import TextGenerationParameters from celeste_text_generation.streaming import TextGenerationStream -logger = logging.getLogger(__name__) - -class CohereTextGenerationStream(TextGenerationStream): +class CohereTextGenerationStream(CohereChatStream, TextGenerationStream): """Cohere 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 with output transformation support. - - Args: - sse_iterator: Server-Sent Events iterator. - transform_output: Function to transform accumulated content (e.g., JSON → BaseModel). - **parameters: Parameters passed to stream() for output transformation. - """ + """Initialize stream with output transformation support.""" super().__init__(sse_iterator, **parameters) self._transform_output = transform_output def _parse_chunk(self, event: dict[str, Any]) -> TextGenerationChunk | None: - """Parse SSE event into Chunk, extracting text deltas and metadata.""" - event_type = event.get("type") - - if event_type == "content-delta": - delta = event.get("delta", {}) - message = delta.get("message", {}) - content = message.get("content", {}) - text_delta = content.get("text") - - if not text_delta: - return None - - return TextGenerationChunk( - content=text_delta, - finish_reason=None, - usage=None, - ) - - if event_type == "message-end": - delta = event.get("delta", {}) - finish_reason_str = delta.get("finish_reason") - finish_reason = ( - TextGenerationFinishReason(reason=finish_reason_str) - if finish_reason_str - else None - ) - - usage_dict = delta.get("usage", {}) - usage = None - if isinstance(usage_dict, dict): - billed_units = usage_dict.get("billed_units", {}) - tokens = usage_dict.get("tokens", {}) - - input_tokens = billed_units.get("input_tokens") - output_tokens = billed_units.get("output_tokens") - - if input_tokens is not None or output_tokens is not None: - usage = TextGenerationUsage( - input_tokens=input_tokens, - output_tokens=output_tokens, - total_tokens=tokens.get("total_tokens") if tokens else None, - cached_tokens=usage_dict.get("cached_tokens"), - ) - - return TextGenerationChunk( - content="", - finish_reason=finish_reason, - usage=usage, - ) - - if event_type == "stream-end": - finish_reason_str = event.get("finish_reason") - finish_reason = ( - TextGenerationFinishReason(reason=finish_reason_str) - if finish_reason_str - else None - ) - - usage_data = event.get("usage", {}) - usage = None - if isinstance(usage_data, dict): - billed_units = usage_data.get("billed_units", {}) - tokens = usage_data.get("tokens", {}) - - input_tokens = billed_units.get("input_tokens") - output_tokens = billed_units.get("output_tokens") - - if input_tokens is not None or output_tokens is not None: - usage = TextGenerationUsage( - input_tokens=input_tokens, - output_tokens=output_tokens, - total_tokens=tokens.get("total_tokens") if tokens else None, - cached_tokens=usage_data.get("cached_tokens"), - ) - - return TextGenerationChunk( - content="", - finish_reason=finish_reason, - usage=usage, - ) - - return None + """Parse SSE event into typed Chunk.""" + raw = super()._parse_chunk(event) + if not raw: + return None + + usage = TextGenerationUsage(**raw["usage"]) if raw["usage"] else None + finish_reason = ( + TextGenerationFinishReason(reason=raw["finish_reason"]) + if raw["finish_reason"] + else None + ) - def _parse_usage(self, chunks: list[TextGenerationChunk]) -> TextGenerationUsage: - """Parse usage from chunks, using the last chunk with usage metadata.""" - if not chunks: - return TextGenerationUsage() + 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: + """Extract usage from final chunk.""" for chunk in reversed(chunks): if chunk.usage: return chunk.usage - return TextGenerationUsage() def _parse_output( @@ -135,7 +61,7 @@ def _parse_output( chunks: list[TextGenerationChunk], **parameters: Unpack[TextGenerationParameters], ) -> TextGenerationOutput: - """Assemble chunks into final output, applying parameter transformations.""" + """Assemble chunks into final output.""" content_chunks = [chunk for chunk in chunks if chunk.content] content = "".join(chunk.content for chunk in content_chunks) content = self._transform_output(content, **parameters) @@ -143,11 +69,18 @@ def _parse_output( usage = self._parse_usage(chunks) finish_reason = chunks[-1].finish_reason if chunks else None + raw_events = [ + c.metadata["raw_event"] + for c in chunks + if c.metadata.get("raw_event", {}).get("type") + in ("message-end", "stream-end") + ] + return TextGenerationOutput( content=content, usage=usage, finish_reason=finish_reason, - metadata={}, + metadata={"raw_response": raw_events}, ) From 9a73bfde6e7f6b671b0f62d7b4cfaf0397d7ad77 Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Fri, 19 Dec 2025 11:55:20 +0100 Subject: [PATCH 07/11] refactor(text-generation): migrate xAI to mixin pattern Migrate xAI provider to use celeste-xai package: - Inherit from XAIResponsesClient and XAIResponsesStream mixins - Remove config.py (moved to provider package) - Use provider parameter mappers via imports - Update streaming to use provider stream mixin --- .../providers/xai/client.py | 116 ++-------- .../providers/xai/config.py | 10 - .../providers/xai/parameters.py | 212 ++---------------- .../providers/xai/streaming.py | 104 +++------ 4 files changed, 69 insertions(+), 373 deletions(-) delete mode 100644 packages/capabilities/text-generation/src/celeste_text_generation/providers/xai/config.py diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/xai/client.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/xai/client.py index ea0530fd..994d68d4 100644 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/xai/client.py +++ b/packages/capabilities/text-generation/src/celeste_text_generation/providers/xai/client.py @@ -1,13 +1,11 @@ """XAI client implementation for text generation.""" -from collections.abc import AsyncIterator from typing import Any, Unpack -import httpx -from pydantic import BaseModel +from celeste_xai.responses.client import XAIResponsesClient -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 XAI_PARAMETER_MAPPERS from .streaming import XAITextGenerationStream -class XAITextGenerationClient(TextGenerationClient): +class XAITextGenerationClient(XAIResponsesClient, TextGenerationClient): """XAI client for text generation.""" @classmethod @@ -29,113 +26,40 @@ def parameter_mappers(cls) -> list[ParameterMapper]: return XAI_PARAMETER_MAPPERS def _init_request(self, inputs: TextGenerationInput) -> dict[str, Any]: - """Initialize request from XAI messages array format.""" - messages = [ - { - "role": "user", - "content": inputs.prompt, - } - ] - - return {"messages": messages} + """Initialize request from XAI Responses API format.""" + return {"input": inputs.prompt} def _parse_usage(self, response_data: dict[str, Any]) -> TextGenerationUsage: """Parse usage from response.""" - usage_data = response_data.get("usage", {}) - prompt_tokens_details = usage_data.get("prompt_tokens_details", {}) - completion_tokens_details = usage_data.get("completion_tokens_details", {}) - - return TextGenerationUsage( - input_tokens=usage_data.get("prompt_tokens"), - output_tokens=usage_data.get("completion_tokens"), - total_tokens=usage_data.get("total_tokens"), - cached_tokens=prompt_tokens_details.get("cached_tokens"), - reasoning_tokens=completion_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.""" - choices = response_data.get("choices", []) - if not choices: - msg = "No choices in response" - raise ValueError(msg) - - message = choices[0].get("message", {}) - content = message.get("content") or "" - - return self._transform_output(content, **parameters) + output = super()._parse_content(response_data) # Raw output array + # Find message item and extract text + for item in output: + if item.get("type") == "message": + 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.""" - choices = response_data.get("choices", []) - if not choices: - return None - - choice = choices[0] - finish_reason = choice.get("finish_reason") - - if not finish_reason: - return None - - return TextGenerationFinishReason(reason=finish_reason) - - 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 = {"choices"} - 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[XAITextGenerationStream]: """Return the Stream class for this client.""" return XAITextGenerationStream - 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__ = ["XAITextGenerationClient"] diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/xai/config.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/xai/config.py deleted file mode 100644 index 04472e3b..00000000 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/xai/config.py +++ /dev/null @@ -1,10 +0,0 @@ -"""XAI provider configuration for text generation.""" - -# HTTP Configuration -BASE_URL = "https://api.x.ai/v1" -ENDPOINT = "/chat/completions" -STREAM_ENDPOINT = ENDPOINT - -# Authentication -AUTH_HEADER_NAME = "Authorization" -AUTH_HEADER_PREFIX = "Bearer " diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/xai/parameters.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/xai/parameters.py index 903f6bfb..7bf92a96 100644 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/xai/parameters.py +++ b/packages/capabilities/text-generation/src/celeste_text_generation/providers/xai/parameters.py @@ -1,213 +1,43 @@ -"""XAI parameter mappers for text generation.""" - -import json -from typing import Any, get_args, get_origin - -from pydantic import BaseModel, TypeAdapter +"""XAI Responses parameter mappers for text generation.""" + +from celeste_xai.responses.parameters import ( + MaxTokensMapper as _MaxTokensMapper, +) +from celeste_xai.responses.parameters import ( + OutputSchemaMapper as _OutputSchemaMapper, +) +from celeste_xai.responses.parameters import ( + ReasoningEffortMapper as _ReasoningEffortMapper, +) +from celeste_xai.responses.parameters import ( + TemperatureMapper as _TemperatureMapper, +) 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 XAI response_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_json_schema(validated_value) - schema_name = self._get_schema_name(validated_value) - - request["response_format"] = { - "type": "json_schema", - "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_json_schema(self, output_schema: Any) -> dict[str, Any]: # noqa: ANN401 - """Convert Pydantic BaseModel or list[BaseModel] to 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(json_schema) - return json_schema - - def _transform_schema( - self, schema: dict[str, Any], defs: dict[str, Any] | None = None - ) -> dict[str, Any]: - """Recursively transform schema for API compatibility.""" - 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(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(value, defs) - elif isinstance(value, list): - result[key] = [ - self._transform_schema(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 XAI 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 XAI max_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_tokens"] = validated_value - return request +class ThinkingBudgetMapper(_ReasoningEffortMapper): + name = TextGenerationParameter.THINKING_BUDGET -class ThinkingLevelMapper(ParameterMapper): - """Map thinking_level parameter to XAI reasoning_effort field.""" - - name = TextGenerationParameter.THINKING_LEVEL - - def map( - self, - request: dict[str, Any], - value: object, - model: Model, - ) -> dict[str, Any]: - """Transform thinking_level into provider request.""" - validated_value = self._validate_value(value, model) - if validated_value is None: - return request - - request["reasoning_effort"] = validated_value - return request +class OutputSchemaMapper(_OutputSchemaMapper): + name = TextGenerationParameter.OUTPUT_SCHEMA XAI_PARAMETER_MAPPERS: list[ParameterMapper] = [ TemperatureMapper(), MaxTokensMapper(), - ThinkingLevelMapper(), + ThinkingBudgetMapper(), OutputSchemaMapper(), ] diff --git a/packages/capabilities/text-generation/src/celeste_text_generation/providers/xai/streaming.py b/packages/capabilities/text-generation/src/celeste_text_generation/providers/xai/streaming.py index daffd7b7..5b003b97 100644 --- a/packages/capabilities/text-generation/src/celeste_text_generation/providers/xai/streaming.py +++ b/packages/capabilities/text-generation/src/celeste_text_generation/providers/xai/streaming.py @@ -3,6 +3,9 @@ from collections.abc import Callable from typing import Any, Unpack +from celeste_xai.responses.streaming import XAIResponsesStream + +from celeste.types import StructuredOutput from celeste_text_generation.io import ( TextGenerationChunk, TextGenerationFinishReason, @@ -13,94 +16,44 @@ from celeste_text_generation.streaming import TextGenerationStream -class XAITextGenerationStream(TextGenerationStream): +class XAITextGenerationStream(XAIResponsesStream, TextGenerationStream): """XAI 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 with output transformation support. - - Args: - sse_iterator: Server-Sent Events iterator. - transform_output: Function to transform accumulated content (e.g., JSON → BaseModel). - **parameters: Parameters passed to stream() for output transformation. - """ + """Initialize stream.""" super().__init__(sse_iterator, **parameters) self._transform_output = transform_output def _parse_chunk(self, event: dict[str, Any]) -> TextGenerationChunk | None: - """Parse chunk from SSE event. - - Extract from choices[0].delta.content (content delta events). - Extract finish_reason and usage from final event when finish_reason is not null. - Return None if no text delta (filter lifecycle events). - """ - choices = event.get("choices", []) - if not choices: - return None - - first_choice = choices[0] - if not isinstance(first_choice, dict): - return None - - delta = first_choice.get("delta", {}) - if not isinstance(delta, dict): + """Parse SSE event into typed Chunk.""" + raw = super()._parse_chunk(event) + if not raw: return None - # Extract content delta - content_delta = delta.get("content") - finish_reason_str = first_choice.get("finish_reason") - - # Extract usage from event if present (in final event) - usage = None - usage_dict = event.get("usage") - if isinstance(usage_dict, dict): - prompt_tokens_details = usage_dict.get("prompt_tokens_details", {}) - completion_tokens_details = usage_dict.get("completion_tokens_details", {}) - - usage = TextGenerationUsage( - input_tokens=usage_dict.get("prompt_tokens"), - output_tokens=usage_dict.get("completion_tokens"), - total_tokens=usage_dict.get("total_tokens"), - cached_tokens=prompt_tokens_details.get("cached_tokens"), - reasoning_tokens=completion_tokens_details.get("reasoning_tokens"), - ) - - # Create finish reason if present + usage = TextGenerationUsage(**raw["usage"]) if raw["usage"] else None finish_reason = ( - TextGenerationFinishReason(reason=finish_reason_str) - if finish_reason_str + TextGenerationFinishReason(reason=raw["finish_reason"]) + if raw["finish_reason"] else None ) - # If no content delta and no finish reason, filter this event - if not content_delta and not finish_reason: - return None - return TextGenerationChunk( - content=content_delta or "", # Empty string if no content (final event) + 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. - - XAI provides usage metadata in the final event (when finish_reason is not null). - Use the last chunk that has usage metadata. - """ - if not chunks: - return TextGenerationUsage() - - # Usage metadata is typically in the final chunk (when finish_reason is set) + """Extract usage from final chunk.""" for chunk in reversed(chunks): if chunk.usage: return chunk.usage - return TextGenerationUsage() def _parse_output( @@ -108,26 +61,25 @@ def _parse_output( chunks: list[TextGenerationChunk], **parameters: Unpack[TextGenerationParameters], ) -> TextGenerationOutput: - """Assemble chunks into final output with structured output support. - - Concatenates text chunks, then applies parameter transformations - (e.g., JSON → BaseModel if output_schema provided). - """ - # Filter out empty chunks (from final events) - content_chunks = [chunk for chunk in chunks if chunk.content] - - # Concatenate text chunks - content = "".join(chunk.content for chunk in content_chunks) - - # Apply parameter transformations (e.g., JSON → BaseModel if output_schema provided) + """Assemble chunks into final output.""" + content = "".join(chunk.content for chunk in chunks) content = self._transform_output(content, **parameters) - 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}, ) + + +__all__ = ["XAITextGenerationStream"] From 18c948c1fe3b9b6974651cfbaade2919606842d9 Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Fri, 19 Dec 2025 11:56:12 +0100 Subject: [PATCH 08/11] chore: minor provider client updates - Update Google Imagen and Veo client implementations - Update Anthropic package exports - Update OpenAI package exports --- packages/providers/anthropic/pyproject.toml | 1 + .../anthropic/src/celeste_anthropic/__init__.py | 3 +++ .../google/src/celeste_google/imagen/client.py | 11 +++++++++-- .../providers/google/src/celeste_google/veo/client.py | 6 +++--- .../providers/openai/src/celeste_openai/__init__.py | 2 ++ 5 files changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/providers/anthropic/pyproject.toml b/packages/providers/anthropic/pyproject.toml index 63e51e9d..1e81fce8 100644 --- a/packages/providers/anthropic/pyproject.toml +++ b/packages/providers/anthropic/pyproject.toml @@ -5,6 +5,7 @@ description = "Anthropic 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"] [tool.uv.sources] celeste-ai = { workspace = true } diff --git a/packages/providers/anthropic/src/celeste_anthropic/__init__.py b/packages/providers/anthropic/src/celeste_anthropic/__init__.py index e69de29b..a421162a 100644 --- a/packages/providers/anthropic/src/celeste_anthropic/__init__.py +++ b/packages/providers/anthropic/src/celeste_anthropic/__init__.py @@ -0,0 +1,3 @@ +"""Anthropic provider package for Celeste AI.""" + +__all__: list[str] = [] diff --git a/packages/providers/google/src/celeste_google/imagen/client.py b/packages/providers/google/src/celeste_google/imagen/client.py index 1d9b85ef..d52f4695 100644 --- a/packages/providers/google/src/celeste_google/imagen/client.py +++ b/packages/providers/google/src/celeste_google/imagen/client.py @@ -4,6 +4,7 @@ import httpx +from celeste.core import UsageField from celeste.io import FinishReason from celeste.mime_types import ApplicationMimeType @@ -49,8 +50,14 @@ async def _make_request( ) def _parse_usage(self, response_data: dict[str, Any]) -> dict[str, int | None]: - """Imagen API doesn't provide usage metadata.""" - return {} + """Extract usage data from Imagen API response. + + Imagen API doesn't provide token usage, but we can extract num_images. + """ + predictions = response_data.get("predictions", []) + return { + UsageField.NUM_IMAGES: len(predictions), + } def _parse_content(self, response_data: dict[str, Any]) -> Any: """Parse predictions from response. diff --git a/packages/providers/google/src/celeste_google/veo/client.py b/packages/providers/google/src/celeste_google/veo/client.py index 32e07c20..68a01f84 100644 --- a/packages/providers/google/src/celeste_google/veo/client.py +++ b/packages/providers/google/src/celeste_google/veo/client.py @@ -111,10 +111,10 @@ def _parse_content(self, response_data: dict[str, Any]) -> Any: return generated_samples[0].get("video", {}) def _parse_usage(self, response_data: dict[str, Any]) -> dict[str, Any]: - """Parse usage from Veo API response. + """Extract usage data 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. + Veo API doesn't provide usage metadata. + Returns empty dict for capability clients to wrap in Usage type. """ return {} diff --git a/packages/providers/openai/src/celeste_openai/__init__.py b/packages/providers/openai/src/celeste_openai/__init__.py index ca08362a..f25385ae 100644 --- a/packages/providers/openai/src/celeste_openai/__init__.py +++ b/packages/providers/openai/src/celeste_openai/__init__.py @@ -1 +1,3 @@ """OpenAI provider package for Celeste AI.""" + +__all__: list[str] = [] From a8c40d942178c800e8b9696050da36960665ddca Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Fri, 19 Dec 2025 11:56:56 +0100 Subject: [PATCH 09/11] refactor(speech-generation): migrate Google to use celeste-google config - Use GoogleGenerateContentEndpoint from celeste-google package - Remove config.py (moved to provider package) - Move PCM audio constants to client module --- .../providers/google/client.py | 19 ++++++++++++++----- .../providers/google/config.py | 15 --------------- 2 files changed, 14 insertions(+), 20 deletions(-) delete mode 100644 packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/config.py diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/client.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/client.py index 612fd289..ae75ce7e 100644 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/client.py +++ b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/client.py @@ -5,6 +5,8 @@ import httpx +from celeste_google.generate_content import config + from celeste.artifacts import AudioArtifact from celeste.mime_types import ApplicationMimeType, AudioMimeType from celeste.parameters import ParameterMapper @@ -15,9 +17,14 @@ ) from celeste_speech_generation.parameters import SpeechGenerationParameters -from . import config from .parameters import GOOGLE_PARAMETER_MAPPERS +# PCM Audio Format Specifications +# Source: Google Gemini TTS docs (ffmpeg -f s16le -ar 24000 -ac 1) +PCM_SAMPLE_RATE = 24000 # Hz +PCM_CHANNELS = 1 # Mono +PCM_SAMPLE_WIDTH = 2 # Bytes (16-bit) + class GoogleSpeechGenerationClient(SpeechGenerationClient): """Google client for speech generation.""" @@ -85,9 +92,9 @@ def _parse_content( data=pcm_bytes, mime_type=AudioMimeType.PCM, metadata={ - "sample_rate": config.PCM_SAMPLE_RATE, - "channels": config.PCM_CHANNELS, - "sample_width": config.PCM_SAMPLE_WIDTH, + "sample_rate": PCM_SAMPLE_RATE, + "channels": PCM_CHANNELS, + "sample_width": PCM_SAMPLE_WIDTH, }, ) @@ -106,7 +113,9 @@ async def _make_request( **parameters: Unpack[SpeechGenerationParameters], ) -> httpx.Response: """Make HTTP request(s) and return response object.""" - endpoint = config.ENDPOINT.format(model_id=self.model.id) + endpoint = config.GoogleGenerateContentEndpoint.GENERATE_CONTENT.format( + model_id=self.model.id + ) headers = { **self.auth.get_headers(), diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/config.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/config.py deleted file mode 100644 index 1845d7d1..00000000 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/config.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Google provider configuration for speech generation.""" - -# HTTP Configuration -BASE_URL = "https://generativelanguage.googleapis.com" -ENDPOINT = "/v1beta/models/{model_id}:generateContent" - -# Authentication -AUTH_HEADER_NAME = "x-goog-api-key" -AUTH_HEADER_PREFIX = "" # Empty string for plain key - -# PCM Audio Format Specifications -# Source: Google Gemini TTS docs (ffmpeg -f s16le -ar 24000 -ac 1) -PCM_SAMPLE_RATE = 24000 # Hz -PCM_CHANNELS = 1 # Mono -PCM_SAMPLE_WIDTH = 2 # Bytes (16-bit) From 4962e19eb2db55b81bedb014cd84fe65e836034e Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Fri, 19 Dec 2025 12:00:23 +0100 Subject: [PATCH 10/11] fix: sort imports in Google speech-generation client --- .../src/celeste_speech_generation/providers/google/client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/client.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/client.py index ae75ce7e..6e38c0a4 100644 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/client.py +++ b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/client.py @@ -4,7 +4,6 @@ from typing import Any, Unpack import httpx - from celeste_google.generate_content import config from celeste.artifacts import AudioArtifact From 541c5d2eee5b6d628b2c892901f6f4f7dfc47a9f Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Fri, 19 Dec 2025 12:03:39 +0100 Subject: [PATCH 11/11] fix: add py.typed marker to celeste_elevenlabs package --- packages/providers/elevenlabs/src/celeste_elevenlabs/py.typed | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 packages/providers/elevenlabs/src/celeste_elevenlabs/py.typed diff --git a/packages/providers/elevenlabs/src/celeste_elevenlabs/py.typed b/packages/providers/elevenlabs/src/celeste_elevenlabs/py.typed new file mode 100644 index 00000000..e69de29b