diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4c08ac1f..b9be8339 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -64,7 +64,7 @@ jobs:
python-version: ${{ inputs.python-version || '3.12' }}
- run: |
if [ -d "packages" ]; then
- uv run mypy -p celeste && uv run mypy tests/ && uv run mypy packages/image-generation packages/text-generation packages/video-generation
+ uv run mypy -p celeste && uv run mypy tests/ && uv run mypy packages/image-generation packages/text-generation packages/video-generation packages/speech-generation
else
uv run mypy -p celeste && uv run mypy tests/
fi
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index fb4ceb92..ef3aacc3 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -41,17 +41,74 @@ jobs:
uses: ./.github/workflows/ci.yml
secrets: inherit
- integration-test:
- needs: [validate-release, run-ci]
+ detect-changes:
+ needs: run-ci
runs-on: ubuntu-latest
+ outputs:
+ packages: ${{ steps.build-matrix.outputs.packages }}
+ has-changes: ${{ steps.build-matrix.outputs.has-changes }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - uses: dorny/paths-filter@v3
+ id: filter
+ with:
+ base: ${{ github.event.before }}
+ filters: |
+ text-generation:
+ - 'packages/text-generation/**'
+ image-generation:
+ - 'packages/image-generation/**'
+ video-generation:
+ - 'packages/video-generation/**'
+ speech-generation:
+ - 'packages/speech-generation/**'
+ core:
+ - 'src/celeste/**'
+ - id: build-matrix
+ run: |
+ PACKAGES=()
+ CORE="${{ steps.filter.outputs.core }}"
+
+ if [[ "$CORE" == "true" || "${{ steps.filter.outputs.text-generation }}" == "true" ]]; then
+ PACKAGES+=("text-generation")
+ fi
+ if [[ "$CORE" == "true" || "${{ steps.filter.outputs.image-generation }}" == "true" ]]; then
+ PACKAGES+=("image-generation")
+ fi
+ if [[ "$CORE" == "true" || "${{ steps.filter.outputs.video-generation }}" == "true" ]]; then
+ PACKAGES+=("video-generation")
+ fi
+ if [[ "$CORE" == "true" || "${{ steps.filter.outputs.speech-generation }}" == "true" ]]; then
+ PACKAGES+=("speech-generation")
+ fi
+
+ # Convert to JSON array
+ JSON=$(printf '%s\n' "${PACKAGES[@]}" | jq -R . | jq -s -c .)
+ echo "packages=$JSON" >> $GITHUB_OUTPUT
+
+ if [[ ${#PACKAGES[@]} -gt 0 ]]; then
+ echo "has-changes=true" >> $GITHUB_OUTPUT
+ else
+ echo "has-changes=false" >> $GITHUB_OUTPUT
+ fi
+
+ integration-tests:
+ needs: [validate-release, run-ci, detect-changes]
+ if: needs.detect-changes.outputs.has-changes == 'true'
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
environment:
name: integration-tests
+ strategy:
+ fail-fast: false
+ matrix:
+ package: ${{ fromJSON(needs.detect-changes.outputs.packages) }}
steps:
- uses: actions/checkout@v4
- with:
- fetch-depth: 1
- uses: ./.github/actions/setup-python-uv
- - name: Run integration tests
+ - name: Run ${{ matrix.package }} integration tests
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
@@ -61,10 +118,16 @@ jobs:
COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }}
BYTEDANCE_API_KEY: ${{ secrets.BYTEDANCE_API_KEY }}
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
- run: make integration-test
+ ELEVENLABS_API_KEY: ${{ secrets.ELEVENLABS_API_KEY }}
+ run: uv run pytest packages/${{ matrix.package }}/tests/integration_tests/ -m integration -v
build:
- needs: [validate-release, run-ci, integration-test]
+ needs: [validate-release, run-ci, detect-changes, integration-tests]
+ if: |
+ always() &&
+ needs.validate-release.result == 'success' &&
+ needs.run-ci.result == 'success' &&
+ (needs.integration-tests.result == 'success' || needs.integration-tests.result == 'skipped')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
diff --git a/Makefile b/Makefile
index 00344053..2fa2a81d 100644
--- a/Makefile
+++ b/Makefile
@@ -33,7 +33,7 @@ format:
# Type checking (fail fast on any error)
typecheck:
- @uv run mypy -p celeste && uv run mypy tests/ && uv run mypy packages/image-generation packages/text-generation packages/video-generation
+ @uv run mypy -p celeste && uv run mypy tests/ && uv run mypy packages/image-generation packages/text-generation packages/video-generation packages/speech-generation
# Testing
test:
diff --git a/packages/image-generation/src/celeste_image_generation/client.py b/packages/image-generation/src/celeste_image_generation/client.py
index b18dc22f..301c9cf3 100644
--- a/packages/image-generation/src/celeste_image_generation/client.py
+++ b/packages/image-generation/src/celeste_image_generation/client.py
@@ -45,12 +45,14 @@ def _parse_finish_reason(
"""Parse finish reason from provider response."""
def _create_inputs(
- self, *args: str, **parameters: Unpack[ImageGenerationParameters]
+ self,
+ *args: str,
+ prompt: str | None = None,
+ **parameters: Unpack[ImageGenerationParameters],
) -> ImageGenerationInput:
"""Map positional arguments to Input type."""
if args:
return ImageGenerationInput(prompt=args[0])
- prompt: str | None = parameters.get("prompt")
if prompt is None:
msg = (
"prompt is required (either as positional argument or keyword argument)"
diff --git a/packages/speech-generation/README.md b/packages/speech-generation/README.md
new file mode 100644
index 00000000..58115836
--- /dev/null
+++ b/packages/speech-generation/README.md
@@ -0,0 +1,79 @@
+
+
+#

Celeste Speech Generation
+
+**Speech Generation capability for Celeste AI**
+
+[](https://www.python.org/)
+[](../../LICENSE)
+
+[Quick Start](#-quick-start) • [Documentation](https://withceleste.ai/docs) • [Request Provider](https://github.com/withceleste/celeste-python/issues/new)
+
+
+
+---
+
+## 🚀 Quick Start
+
+```python
+from celeste import create_client, Capability, Provider
+
+client = create_client(
+ capability=Capability.SPEECH_GENERATION,
+ provider=Provider.ELEVENLABS,
+)
+
+response = await client.generate(text="Welcome to Celeste AI. Transform your text into natural, expressive speech with just a few lines of code.")
+# response.content is an AudioArtifact with binary audio data
+```
+
+**Install:**
+```bash
+uv add "celeste-ai[speech-generation]"
+```
+
+---
+
+## Supported Providers
+
+
+
+
+

+

+

+
+
+**Missing a provider?** [Request it](https://github.com/withceleste/celeste-python/issues/new) – ⚡ **we ship fast**.
+
+
+
+---
+
+**Streaming**: ✅ Supported
+
+**Parameters**: See [API Documentation](https://withceleste.ai/docs/api) for full parameter reference.
+
+---
+
+## 🤝 Contributing
+
+See [CONTRIBUTING.md](../../CONTRIBUTING.md) for guidelines.
+
+**Request a provider:** [GitHub Issues](https://github.com/withceleste/celeste-python/issues/new)
+
+---
+
+## 📄 License
+
+Apache 2.0 License – see [LICENSE](../../LICENSE) for details.
+
+---
+
+
+
+**[Get Started](https://withceleste.ai/docs/quickstart)** • **[Documentation](https://withceleste.ai/docs)** • **[GitHub](https://github.com/withceleste/celeste-python)**
+
+Made with ❤️ by developers tired of framework lock-in
+
+
diff --git a/packages/speech-generation/pyproject.toml b/packages/speech-generation/pyproject.toml
new file mode 100644
index 00000000..01121501
--- /dev/null
+++ b/packages/speech-generation/pyproject.toml
@@ -0,0 +1,39 @@
+[project]
+name = "celeste-speech-generation"
+version = "0.2.8"
+description = "Speech generation package for Celeste AI. Unified interface for all providers"
+authors = [{name = "Kamilbenkirane", email = "kamil@withceleste.ai"}]
+readme = "README.md"
+license = {text = "Apache-2.0"}
+requires-python = ">=3.12"
+classifiers = [
+ "Development Status :: 3 - Alpha",
+ "Intended Audience :: Developers",
+ "License :: OSI Approved :: Apache Software License",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Operating System :: OS Independent",
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
+ "Typing :: Typed",
+]
+keywords = ["ai", "speech-generation", "tts", "text-to-speech", "openai", "google", "elevenlabs", "audio-ai"]
+
+[project.urls]
+Homepage = "https://withceleste.ai"
+Documentation = "https://withceleste.ai/docs"
+Repository = "https://github.com/withceleste/celeste-python"
+Issues = "https://github.com/withceleste/celeste-python/issues"
+
+[tool.uv.sources]
+celeste-ai = { workspace = true }
+
+[project.entry-points."celeste.packages"]
+speech-generation = "celeste_speech_generation:register_package"
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/celeste_speech_generation"]
diff --git a/packages/speech-generation/src/celeste_speech_generation/__init__.py b/packages/speech-generation/src/celeste_speech_generation/__init__.py
new file mode 100644
index 00000000..231e54ad
--- /dev/null
+++ b/packages/speech-generation/src/celeste_speech_generation/__init__.py
@@ -0,0 +1,53 @@
+"""Celeste speech generation capability."""
+
+
+def register_package() -> None:
+ """Register speech generation package (client and models)."""
+ from celeste.client import register_client
+ from celeste.core import Capability
+ from celeste.models import register_models
+ from celeste_speech_generation.models import MODELS
+ from celeste_speech_generation.providers import PROVIDERS
+
+ for provider, client_class in PROVIDERS:
+ register_client(Capability.SPEECH_GENERATION, provider, client_class)
+
+ register_models(MODELS, capability=Capability.SPEECH_GENERATION)
+
+
+from celeste_speech_generation.io import ( # noqa: E402
+ SpeechGenerationChunk,
+ SpeechGenerationInput,
+ SpeechGenerationOutput,
+ SpeechGenerationUsage,
+)
+
+# Aggregate voices from all providers (after Voice is imported)
+from celeste_speech_generation.providers.elevenlabs.voices import ( # noqa: E402
+ ELEVENLABS_VOICES,
+)
+from celeste_speech_generation.providers.google.voices import ( # noqa: E402
+ GOOGLE_VOICES,
+)
+from celeste_speech_generation.providers.openai.voices import ( # noqa: E402
+ OPENAI_VOICES,
+)
+from celeste_speech_generation.streaming import SpeechGenerationStream # noqa: E402
+from celeste_speech_generation.voices import Voice # noqa: E402
+
+VOICES: list[Voice] = [
+ *GOOGLE_VOICES,
+ *OPENAI_VOICES,
+ *ELEVENLABS_VOICES,
+]
+
+__all__ = [
+ "VOICES",
+ "SpeechGenerationChunk",
+ "SpeechGenerationInput",
+ "SpeechGenerationOutput",
+ "SpeechGenerationStream",
+ "SpeechGenerationUsage",
+ "Voice",
+ "register_package",
+]
diff --git a/packages/speech-generation/src/celeste_speech_generation/client.py b/packages/speech-generation/src/celeste_speech_generation/client.py
new file mode 100644
index 00000000..1401cb53
--- /dev/null
+++ b/packages/speech-generation/src/celeste_speech_generation/client.py
@@ -0,0 +1,71 @@
+"""Base client for speech generation."""
+
+from abc import abstractmethod
+from typing import Any, Unpack
+
+import httpx
+
+from celeste.artifacts import AudioArtifact
+from celeste.client import Client
+from celeste.exceptions import ValidationError
+from celeste_speech_generation.io import (
+ SpeechGenerationInput,
+ SpeechGenerationOutput,
+ SpeechGenerationUsage,
+)
+from celeste_speech_generation.parameters import SpeechGenerationParameters
+
+
+class SpeechGenerationClient(
+ Client[SpeechGenerationInput, SpeechGenerationOutput, SpeechGenerationParameters]
+):
+ """Client for speech generation operations."""
+
+ @abstractmethod
+ def _init_request(self, inputs: SpeechGenerationInput) -> dict[str, Any]:
+ """Initialize provider-specific request structure."""
+
+ @abstractmethod
+ def _parse_usage(self, response_data: dict[str, Any]) -> SpeechGenerationUsage:
+ """Parse usage information from provider response."""
+
+ @abstractmethod
+ def _parse_content(
+ self,
+ response_data: dict[str, Any],
+ **parameters: Unpack[SpeechGenerationParameters],
+ ) -> AudioArtifact:
+ """Parse content from provider response."""
+
+ def _create_inputs(
+ self,
+ *args: str,
+ text: str | None = None,
+ **parameters: Unpack[SpeechGenerationParameters],
+ ) -> SpeechGenerationInput:
+ """Map positional arguments to Input type."""
+ if args:
+ return SpeechGenerationInput(text=args[0])
+ if text is None:
+ msg = "text is required (either as positional argument or keyword argument)"
+ raise ValidationError(msg)
+ return SpeechGenerationInput(text=text)
+
+ @classmethod
+ def _output_class(cls) -> type[SpeechGenerationOutput]:
+ """Return the Output class for this client."""
+ return SpeechGenerationOutput
+
+ def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]:
+ """Build metadata dictionary from response data."""
+ metadata = super()._build_metadata(response_data)
+ metadata["raw_response"] = response_data
+ return metadata
+
+ @abstractmethod
+ async def _make_request(
+ self,
+ request_body: dict[str, Any],
+ **parameters: Unpack[SpeechGenerationParameters],
+ ) -> httpx.Response:
+ """Make HTTP request(s) and return response object."""
diff --git a/packages/speech-generation/src/celeste_speech_generation/constraints.py b/packages/speech-generation/src/celeste_speech_generation/constraints.py
new file mode 100644
index 00000000..5d6b7aa4
--- /dev/null
+++ b/packages/speech-generation/src/celeste_speech_generation/constraints.py
@@ -0,0 +1,42 @@
+"""Constraint models for speech generation."""
+
+from pydantic import Field
+
+from celeste.constraints import Constraint
+from celeste.exceptions import ConstraintViolationError
+from celeste_speech_generation.voices import Voice
+
+
+class VoiceConstraint(Constraint):
+ """Voice constraint - value must be a valid voice ID or name from the provided voices.
+
+ Accepts both voice IDs and names. If a name is provided, returns the corresponding ID.
+ """
+
+ voices: list[Voice] = Field(min_length=1)
+
+ def __call__(self, value: str) -> str:
+ """Validate value is a valid voice ID or name and return the ID."""
+ if not isinstance(value, str):
+ msg = f"Must be string, got {type(value).__name__}"
+ raise ConstraintViolationError(msg)
+
+ # Check if value is a voice ID
+ voice_ids = [v.id for v in self.voices]
+ if value in voice_ids:
+ return value
+
+ # Check if value is a voice name and return corresponding ID
+ voice_name_to_id = {v.name: v.id for v in self.voices}
+ if value in voice_name_to_id:
+ return voice_name_to_id[value]
+
+ # Neither ID nor name matches
+ voice_names = [v.name for v in self.voices]
+ msg = (
+ f"Must be one of {voice_names} (names) or {voice_ids} (IDs), got {value!r}"
+ )
+ raise ConstraintViolationError(msg)
+
+
+__all__ = ["VoiceConstraint"]
diff --git a/packages/speech-generation/src/celeste_speech_generation/io.py b/packages/speech-generation/src/celeste_speech_generation/io.py
new file mode 100644
index 00000000..f6b0a191
--- /dev/null
+++ b/packages/speech-generation/src/celeste_speech_generation/io.py
@@ -0,0 +1,40 @@
+"""Input and output types for speech generation."""
+
+from celeste.artifacts import AudioArtifact
+from celeste.io import Chunk, Input, Output, Usage
+
+
+class SpeechGenerationInput(Input):
+ """Input for speech generation operations."""
+
+ text: str
+
+
+class SpeechGenerationUsage(Usage):
+ """Speech generation usage metrics.
+
+ All fields optional since providers vary.
+ """
+
+
+class SpeechGenerationOutput(Output[AudioArtifact]):
+ """Output with audio artifact content."""
+
+
+class SpeechGenerationChunk(Chunk[bytes]):
+ """Typed chunk for speech generation streaming.
+
+ Note: Unlike TextGenerationChunk, this class intentionally omits a finish_reason
+ field. TTS providers stream raw audio bytes without completion signals - the
+ stream simply ends when audio generation is complete.
+ """
+
+ usage: SpeechGenerationUsage | None = None
+
+
+__all__ = [
+ "SpeechGenerationChunk",
+ "SpeechGenerationInput",
+ "SpeechGenerationOutput",
+ "SpeechGenerationUsage",
+]
diff --git a/packages/speech-generation/src/celeste_speech_generation/models.py b/packages/speech-generation/src/celeste_speech_generation/models.py
new file mode 100644
index 00000000..c95d7cd4
--- /dev/null
+++ b/packages/speech-generation/src/celeste_speech_generation/models.py
@@ -0,0 +1,14 @@
+"""Model definitions for speech generation."""
+
+from celeste import Model
+from celeste_speech_generation.providers.elevenlabs.models import (
+ MODELS as ELEVENLABS_MODELS,
+)
+from celeste_speech_generation.providers.google.models import MODELS as GOOGLE_MODELS
+from celeste_speech_generation.providers.openai.models import MODELS as OPENAI_MODELS
+
+MODELS: list[Model] = [
+ *GOOGLE_MODELS,
+ *OPENAI_MODELS,
+ *ELEVENLABS_MODELS,
+]
diff --git a/packages/speech-generation/src/celeste_speech_generation/parameters.py b/packages/speech-generation/src/celeste_speech_generation/parameters.py
new file mode 100644
index 00000000..af54865d
--- /dev/null
+++ b/packages/speech-generation/src/celeste_speech_generation/parameters.py
@@ -0,0 +1,23 @@
+"""Parameters for speech generation."""
+
+from enum import StrEnum
+
+from celeste.parameters import Parameters
+
+
+class SpeechGenerationParameter(StrEnum):
+ """Unified parameter names for speech generation capability."""
+
+ VOICE = "voice"
+ SPEED = "speed"
+ RESPONSE_FORMAT = "response_format"
+ PROMPT = "prompt"
+
+
+class SpeechGenerationParameters(Parameters):
+ """Parameters for speech generation."""
+
+ voice: str | None
+ speed: float | None
+ response_format: str | None
+ prompt: str | None
diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/__init__.py b/packages/speech-generation/src/celeste_speech_generation/providers/__init__.py
new file mode 100644
index 00000000..aa8f14f6
--- /dev/null
+++ b/packages/speech-generation/src/celeste_speech_generation/providers/__init__.py
@@ -0,0 +1,28 @@
+"""Provider implementations for speech generation."""
+
+from celeste import Client, Provider
+
+__all__ = ["PROVIDERS"]
+
+
+def _get_providers() -> list[tuple[Provider, type[Client]]]:
+ """Lazy-load providers."""
+ # Import clients directly from .client modules to avoid __init__.py imports
+ from celeste_speech_generation.providers.elevenlabs.client import (
+ ElevenLabsSpeechGenerationClient,
+ )
+ from celeste_speech_generation.providers.google.client import (
+ GoogleSpeechGenerationClient,
+ )
+ from celeste_speech_generation.providers.openai.client import (
+ OpenAISpeechGenerationClient,
+ )
+
+ return [
+ (Provider.GOOGLE, GoogleSpeechGenerationClient),
+ (Provider.OPENAI, OpenAISpeechGenerationClient),
+ (Provider.ELEVENLABS, ElevenLabsSpeechGenerationClient),
+ ]
+
+
+PROVIDERS: list[tuple[Provider, type[Client]]] = _get_providers()
diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/__init__.py b/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/__init__.py
new file mode 100644
index 00000000..8de72b1e
--- /dev/null
+++ b/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/__init__.py
@@ -0,0 +1,11 @@
+"""ElevenLabs provider for speech generation."""
+
+from .client import ElevenLabsSpeechGenerationClient
+from .models import MODELS
+from .streaming import ElevenLabsSpeechGenerationStream
+
+__all__ = [
+ "MODELS",
+ "ElevenLabsSpeechGenerationClient",
+ "ElevenLabsSpeechGenerationStream",
+]
diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/client.py b/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/client.py
new file mode 100644
index 00000000..1c3ae0bd
--- /dev/null
+++ b/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/client.py
@@ -0,0 +1,200 @@
+"""ElevenLabs client implementation for speech generation."""
+
+from collections.abc import AsyncIterator
+from typing import Any, Unpack
+
+import httpx
+
+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 (
+ SpeechGenerationInput,
+ SpeechGenerationOutput,
+ SpeechGenerationUsage,
+)
+from celeste_speech_generation.parameters import SpeechGenerationParameters
+
+from . import config
+from .parameters import ELEVENLABS_PARAMETER_MAPPERS
+from .streaming import ElevenLabsSpeechGenerationStream
+from .voices import ELEVENLABS_VOICES
+
+
+class ElevenLabsSpeechGenerationClient(SpeechGenerationClient):
+ """ElevenLabs client for speech generation."""
+
+ @classmethod
+ def parameter_mappers(cls) -> list[ParameterMapper]:
+ return ELEVENLABS_PARAMETER_MAPPERS
+
+ def _init_request(self, inputs: SpeechGenerationInput) -> dict[str, Any]:
+ """Initialize request from ElevenLabs API format."""
+ 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()
+
+ def _parse_content(
+ self,
+ response_data: dict[str, Any],
+ **parameters: Unpack[SpeechGenerationParameters],
+ ) -> AudioArtifact:
+ """Parse content from response.
+
+ Note: This method is not used for ElevenLabs TTS since we override generate()
+ to handle binary responses. Kept for interface compliance.
+ """
+ # This should never be called for ElevenLabs TTS
+ 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 = {
+ config.AUTH_HEADER_NAME: self.api_key.get_secret_value(),
+ "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,
+ **parameters: Unpack[SpeechGenerationParameters],
+ ) -> SpeechGenerationOutput:
+ """Generate speech from text.
+
+ Override base generate() to handle binary audio response from ElevenLabs TTS.
+ """
+ inputs = self._create_inputs(*args, **parameters)
+ inputs, parameters = self._validate_artifacts(inputs, **parameters)
+ request_body = self._build_request(inputs, **parameters)
+ response = await self._make_request(request_body, **parameters)
+ self._handle_error_response(response)
+
+ # Handle binary response (ElevenLabs TTS returns raw audio bytes, not JSON)
+ audio_bytes = response.content
+ if not audio_bytes:
+ msg = "No audio data in response"
+ raise ValueError(msg)
+
+ # Determine MIME type from output_format parameter
+ output_format = parameters.get("response_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)
+ headers_dict = dict(response.headers)
+
+ return self._output_class()(
+ content=AudioArtifact(data=audio_bytes, mime_type=mime_type),
+ usage=SpeechGenerationUsage(),
+ metadata=self._build_metadata(headers_dict),
+ )
+
+ 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 = {
+ config.AUTH_HEADER_NAME: self.api_key.get_secret_value(),
+ "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/speech-generation/src/celeste_speech_generation/providers/elevenlabs/config.py b/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/config.py
new file mode 100644
index 00000000..f9493128
--- /dev/null
+++ b/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/config.py
@@ -0,0 +1,10 @@
+"""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/speech-generation/src/celeste_speech_generation/providers/elevenlabs/models.py b/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/models.py
new file mode 100644
index 00000000..5983e097
--- /dev/null
+++ b/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/models.py
@@ -0,0 +1,83 @@
+"""ElevenLabs models for speech generation."""
+
+from celeste import Model, Provider
+from celeste.constraints import Choice, Range
+from celeste_speech_generation.constraints import VoiceConstraint
+from celeste_speech_generation.parameters import SpeechGenerationParameter
+
+from .voices import ELEVENLABS_VOICES
+
+MODELS: list[Model] = [
+ Model(
+ id="eleven_multilingual_v2",
+ provider=Provider.ELEVENLABS,
+ display_name="Eleven Multilingual v2",
+ streaming=True,
+ parameter_constraints={
+ SpeechGenerationParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES),
+ SpeechGenerationParameter.SPEED: Range(min=0.7, max=1.2),
+ SpeechGenerationParameter.RESPONSE_FORMAT: Choice(
+ options=[
+ "mp3_44100_128",
+ "pcm_22050_16",
+ "pcm_24000_16",
+ "pcm_44100_16",
+ ]
+ ),
+ },
+ ),
+ Model(
+ id="eleven_turbo_v2",
+ provider=Provider.ELEVENLABS,
+ display_name="Eleven Turbo v2",
+ streaming=True,
+ parameter_constraints={
+ SpeechGenerationParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES),
+ SpeechGenerationParameter.SPEED: Range(min=0.7, max=1.2),
+ SpeechGenerationParameter.RESPONSE_FORMAT: Choice(
+ options=[
+ "mp3_44100_128",
+ "pcm_22050_16",
+ "pcm_24000_16",
+ "pcm_44100_16",
+ ]
+ ),
+ },
+ ),
+ Model(
+ id="eleven_flash_v2_5",
+ provider=Provider.ELEVENLABS,
+ display_name="Eleven Flash v2.5",
+ streaming=True,
+ parameter_constraints={
+ SpeechGenerationParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES),
+ SpeechGenerationParameter.SPEED: Range(min=0.7, max=1.2),
+ SpeechGenerationParameter.RESPONSE_FORMAT: Choice(
+ options=[
+ "mp3_44100_128",
+ "pcm_22050_16",
+ "pcm_24000_16",
+ "pcm_44100_16",
+ ]
+ ),
+ },
+ ),
+ Model(
+ id="eleven_monolingual_v1",
+ provider=Provider.ELEVENLABS,
+ display_name="Eleven Monolingual v1",
+ streaming=True,
+ parameter_constraints={
+ SpeechGenerationParameter.VOICE: VoiceConstraint(voices=ELEVENLABS_VOICES),
+ SpeechGenerationParameter.SPEED: Range(min=0.7, max=1.2),
+ SpeechGenerationParameter.RESPONSE_FORMAT: Choice(
+ options=[
+ "mp3_44100_128",
+ "pcm_22050_16",
+ "pcm_24000_16",
+ "pcm_44100_16",
+ ]
+ ),
+ },
+ ),
+]
diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/parameters.py b/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/parameters.py
new file mode 100644
index 00000000..12e7271e
--- /dev/null
+++ b/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/parameters.py
@@ -0,0 +1,124 @@
+"""ElevenLabs parameter mappers for speech generation."""
+
+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().
+ """
+
+ 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."""
+
+ name = SpeechGenerationParameter.RESPONSE_FORMAT
+
+ def map(
+ self,
+ request: dict[str, Any],
+ value: object,
+ model: Model,
+ ) -> dict[str, Any]:
+ """Transform response_format into provider request.
+
+ Maps the unified response_format parameter to 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."""
+
+ 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
+
+
+ELEVENLABS_PARAMETER_MAPPERS: list[ParameterMapper] = [
+ VoiceMapper(),
+ OutputFormatMapper(),
+ SpeedMapper(),
+]
+
+__all__ = ["ELEVENLABS_PARAMETER_MAPPERS"]
diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/streaming.py b/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/streaming.py
new file mode 100644
index 00000000..af2545b1
--- /dev/null
+++ b/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/streaming.py
@@ -0,0 +1,44 @@
+"""ElevenLabs streaming for speech generation."""
+
+from typing import Any
+
+from celeste_speech_generation.io import (
+ SpeechGenerationChunk,
+ SpeechGenerationUsage,
+)
+from celeste_speech_generation.streaming import SpeechGenerationStream
+
+
+class ElevenLabsSpeechGenerationStream(SpeechGenerationStream):
+ """ElevenLabs streaming for speech generation."""
+
+ def _parse_chunk(self, event: dict[str, Any]) -> SpeechGenerationChunk | None:
+ """Parse binary audio chunk from event dict.
+
+ Event dict contains {"data": bytes} for binary audio chunks.
+ """
+ audio_bytes = event.get("data")
+ if audio_bytes is None:
+ return None
+
+ if not isinstance(audio_bytes, bytes):
+ return None
+
+ # Return chunk with binary audio data
+ return SpeechGenerationChunk(
+ content=audio_bytes,
+ usage=None, # Usage calculated in _parse_usage()
+ metadata={"content_length": len(audio_bytes)},
+ )
+
+ def _parse_usage(
+ self, chunks: list[SpeechGenerationChunk]
+ ) -> SpeechGenerationUsage:
+ """Parse usage from chunks.
+
+ ElevenLabs doesn't return usage in streaming response.
+ """
+ return SpeechGenerationUsage()
+
+
+__all__ = ["ElevenLabsSpeechGenerationStream"]
diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/voices.py b/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/voices.py
new file mode 100644
index 00000000..18ec82ca
--- /dev/null
+++ b/packages/speech-generation/src/celeste_speech_generation/providers/elevenlabs/voices.py
@@ -0,0 +1,32 @@
+"""ElevenLabs voice definitions for speech generation."""
+
+from celeste import Provider
+from celeste_speech_generation.voices import Voice
+
+# Common ElevenLabs voices (hardcoded initially)
+ELEVENLABS_VOICES = [
+ Voice(
+ id="pNInz6obpgDQGcFmaJgB",
+ provider=Provider.ELEVENLABS,
+ name="Adam",
+ languages=set(), # ElevenLabs voices support multiple languages but don't specify per-voice
+ ), # Keep - replacement for Antoni/Arnold
+ Voice(
+ id="EXAVITQu4vr4xnSDxMaL",
+ provider=Provider.ELEVENLABS,
+ name="Bella",
+ languages=set(),
+ ), # Keep - not being replaced
+ Voice(
+ id="FGY2WhTYpPnrIDTdsKH5",
+ provider=Provider.ELEVENLABS,
+ name="Laura",
+ languages=set(),
+ ), # NEW - replaces Domi (ID confirmed from API)
+ # 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/speech-generation/src/celeste_speech_generation/providers/google/__init__.py b/packages/speech-generation/src/celeste_speech_generation/providers/google/__init__.py
new file mode 100644
index 00000000..35519f93
--- /dev/null
+++ b/packages/speech-generation/src/celeste_speech_generation/providers/google/__init__.py
@@ -0,0 +1,6 @@
+"""Google provider for speech generation."""
+
+from .client import GoogleSpeechGenerationClient
+from .models import MODELS
+
+__all__ = ["MODELS", "GoogleSpeechGenerationClient"]
diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/google/client.py b/packages/speech-generation/src/celeste_speech_generation/providers/google/client.py
new file mode 100644
index 00000000..3ccfee93
--- /dev/null
+++ b/packages/speech-generation/src/celeste_speech_generation/providers/google/client.py
@@ -0,0 +1,123 @@
+"""Google client implementation for speech generation."""
+
+import base64
+from typing import Any, Unpack
+
+import httpx
+
+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 (
+ SpeechGenerationInput,
+ SpeechGenerationUsage,
+)
+from celeste_speech_generation.parameters import SpeechGenerationParameters
+
+from . import config
+from .parameters import GOOGLE_PARAMETER_MAPPERS
+
+
+class GoogleSpeechGenerationClient(SpeechGenerationClient):
+ """Google client for speech generation."""
+
+ @classmethod
+ def parameter_mappers(cls) -> list[ParameterMapper]:
+ return GOOGLE_PARAMETER_MAPPERS
+
+ def _init_request(self, inputs: SpeechGenerationInput) -> dict[str, Any]:
+ """Initialize request from Google contents array format."""
+ contents = [
+ {
+ "parts": [{"text": inputs.text}],
+ }
+ ]
+
+ generation_config: dict[str, Any] = {
+ "responseModalities": ["AUDIO"],
+ "speechConfig": {},
+ }
+
+ return {
+ "contents": contents,
+ "generationConfig": generation_config,
+ }
+
+ def _parse_usage(self, response_data: dict[str, Any]) -> SpeechGenerationUsage:
+ """Parse usage from response.
+
+ Google Gemini doesn't return usage metrics for TTS.
+ """
+ return SpeechGenerationUsage()
+
+ def _parse_content(
+ self,
+ response_data: dict[str, Any],
+ **parameters: Unpack[SpeechGenerationParameters],
+ ) -> AudioArtifact:
+ """Parse content from response."""
+ candidates = response_data.get("candidates", [])
+ if not candidates:
+ msg = "No candidates in response"
+ raise ValueError(msg)
+
+ candidate = candidates[0]
+ content = candidate.get("content", {})
+ parts = content.get("parts", [])
+
+ if not parts:
+ msg = "No parts in candidate content"
+ raise ValueError(msg)
+
+ inline_data = parts[0].get("inlineData", {})
+ base64_audio = inline_data.get("data")
+
+ if not base64_audio:
+ msg = "No audio data in response"
+ raise ValueError(msg)
+
+ # Decode base64 to get raw PCM bytes
+ # Google returns PCM audio (24kHz, 16-bit, mono)
+ pcm_bytes = base64.b64decode(base64_audio)
+
+ return AudioArtifact(
+ data=pcm_bytes,
+ mime_type=AudioMimeType.PCM,
+ metadata={
+ "sample_rate": config.PCM_SAMPLE_RATE,
+ "channels": config.PCM_CHANNELS,
+ "sample_width": config.PCM_SAMPLE_WIDTH,
+ },
+ )
+
+ 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 = {"candidates"}
+ 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[SpeechGenerationParameters],
+ ) -> httpx.Response:
+ """Make HTTP request(s) and return response object."""
+ endpoint = config.ENDPOINT.format(model_id=self.model.id)
+
+ headers = {
+ config.AUTH_HEADER_NAME: f"{config.AUTH_HEADER_PREFIX}{self.api_key.get_secret_value()}",
+ "Content-Type": ApplicationMimeType.JSON,
+ }
+
+ return await self.http_client.post(
+ f"{config.BASE_URL}{endpoint}",
+ headers=headers,
+ json_body=request_body,
+ )
+
+
+__all__ = ["GoogleSpeechGenerationClient"]
diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/google/config.py b/packages/speech-generation/src/celeste_speech_generation/providers/google/config.py
new file mode 100644
index 00000000..1845d7d1
--- /dev/null
+++ b/packages/speech-generation/src/celeste_speech_generation/providers/google/config.py
@@ -0,0 +1,15 @@
+"""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)
diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/google/models.py b/packages/speech-generation/src/celeste_speech_generation/providers/google/models.py
new file mode 100644
index 00000000..6e8dcca1
--- /dev/null
+++ b/packages/speech-generation/src/celeste_speech_generation/providers/google/models.py
@@ -0,0 +1,31 @@
+"""Google models for speech generation."""
+
+from celeste import Model, Provider
+from celeste.constraints import Str
+from celeste_speech_generation.constraints import VoiceConstraint
+from celeste_speech_generation.parameters import SpeechGenerationParameter
+
+from .voices import GOOGLE_VOICES
+
+MODELS: list[Model] = [
+ Model(
+ id="gemini-2.5-flash-preview-tts",
+ provider=Provider.GOOGLE,
+ display_name="Gemini 2.5 Flash TTS (Preview)",
+ streaming=False,
+ parameter_constraints={
+ SpeechGenerationParameter.VOICE: VoiceConstraint(voices=GOOGLE_VOICES),
+ SpeechGenerationParameter.PROMPT: Str(),
+ },
+ ),
+ Model(
+ id="gemini-2.5-pro-preview-tts",
+ provider=Provider.GOOGLE,
+ display_name="Gemini 2.5 Pro TTS (Preview)",
+ streaming=False,
+ parameter_constraints={
+ SpeechGenerationParameter.VOICE: VoiceConstraint(voices=GOOGLE_VOICES),
+ SpeechGenerationParameter.PROMPT: Str(),
+ },
+ ),
+]
diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/google/parameters.py b/packages/speech-generation/src/celeste_speech_generation/providers/google/parameters.py
new file mode 100644
index 00000000..0f4dc189
--- /dev/null
+++ b/packages/speech-generation/src/celeste_speech_generation/providers/google/parameters.py
@@ -0,0 +1,64 @@
+"""Google parameter mappers for speech generation."""
+
+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 Google speechConfig.voiceConfig.prebuiltVoiceConfig.voiceName."""
+
+ name = SpeechGenerationParameter.VOICE
+
+ def map(
+ self,
+ request: dict[str, Any],
+ value: object,
+ model: Model,
+ ) -> dict[str, Any]:
+ """Transform voice into provider request."""
+ validated_value = self._validate_value(value, model)
+ if validated_value is None:
+ return request
+
+ request.setdefault("generationConfig", {}).setdefault(
+ "speechConfig", {}
+ ).setdefault("voiceConfig", {}).setdefault("prebuiltVoiceConfig", {})[
+ "voiceName"
+ ] = validated_value
+ return request
+
+
+class PromptMapper(ParameterMapper):
+ """Map prompt parameter to style instructions in text."""
+
+ name = SpeechGenerationParameter.PROMPT
+
+ def map(
+ self,
+ request: dict[str, Any],
+ value: object,
+ model: Model,
+ ) -> dict[str, Any]:
+ """Prepend prompt as style instruction to text using SSML-like tags."""
+ if not value or not isinstance(value, str):
+ return request
+
+ contents = request.get("contents", [])
+ if contents and contents[0].get("parts"):
+ text = contents[0]["parts"][0].get("text", "")
+ contents[0]["parts"][0]["text"] = (
+ f""
+ )
+
+ return request
+
+
+GOOGLE_PARAMETER_MAPPERS: list[ParameterMapper] = [
+ VoiceMapper(),
+ PromptMapper(),
+]
+
+__all__ = ["GOOGLE_PARAMETER_MAPPERS"]
diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/google/voices.py b/packages/speech-generation/src/celeste_speech_generation/providers/google/voices.py
new file mode 100644
index 00000000..789a9f48
--- /dev/null
+++ b/packages/speech-generation/src/celeste_speech_generation/providers/google/voices.py
@@ -0,0 +1,177 @@
+"""Google voice definitions for speech generation."""
+
+from celeste import Provider
+from celeste_speech_generation.voices import Voice
+
+# All Google Gemini voices support all 24 languages
+GOOGLE_LANGUAGES = {
+ "ar-EG",
+ "de-DE",
+ "en-US",
+ "es-US",
+ "fr-FR",
+ "hi-IN",
+ "id-ID",
+ "it-IT",
+ "ja-JP",
+ "ko-KR",
+ "pt-BR",
+ "ru-RU",
+ "nl-NL",
+ "pl-PL",
+ "th-TH",
+ "tr-TR",
+ "vi-VN",
+ "ro-RO",
+ "uk-UA",
+ "bn-BD",
+ "en-IN",
+ "mr-IN",
+ "ta-IN",
+ "te-IN",
+}
+
+GOOGLE_VOICES = [
+ Voice(
+ id="Zephyr", provider=Provider.GOOGLE, name="Zephyr", languages=GOOGLE_LANGUAGES
+ ),
+ Voice(id="Puck", provider=Provider.GOOGLE, name="Puck", languages=GOOGLE_LANGUAGES),
+ Voice(
+ id="Charon", provider=Provider.GOOGLE, name="Charon", languages=GOOGLE_LANGUAGES
+ ),
+ Voice(id="Kore", provider=Provider.GOOGLE, name="Kore", languages=GOOGLE_LANGUAGES),
+ Voice(
+ id="Fenrir", provider=Provider.GOOGLE, name="Fenrir", languages=GOOGLE_LANGUAGES
+ ),
+ Voice(id="Leda", provider=Provider.GOOGLE, name="Leda", languages=GOOGLE_LANGUAGES),
+ Voice(id="Orus", provider=Provider.GOOGLE, name="Orus", languages=GOOGLE_LANGUAGES),
+ Voice(
+ id="Aoede", provider=Provider.GOOGLE, name="Aoede", languages=GOOGLE_LANGUAGES
+ ),
+ Voice(
+ id="Callirrhoe",
+ provider=Provider.GOOGLE,
+ name="Callirrhoe",
+ languages=GOOGLE_LANGUAGES,
+ ),
+ Voice(
+ id="Autonoe",
+ provider=Provider.GOOGLE,
+ name="Autonoe",
+ languages=GOOGLE_LANGUAGES,
+ ),
+ Voice(
+ id="Enceladus",
+ provider=Provider.GOOGLE,
+ name="Enceladus",
+ languages=GOOGLE_LANGUAGES,
+ ),
+ Voice(
+ id="Iapetus",
+ provider=Provider.GOOGLE,
+ name="Iapetus",
+ languages=GOOGLE_LANGUAGES,
+ ),
+ Voice(
+ id="Umbriel",
+ provider=Provider.GOOGLE,
+ name="Umbriel",
+ languages=GOOGLE_LANGUAGES,
+ ),
+ Voice(
+ id="Algieba",
+ provider=Provider.GOOGLE,
+ name="Algieba",
+ languages=GOOGLE_LANGUAGES,
+ ),
+ Voice(
+ id="Despina",
+ provider=Provider.GOOGLE,
+ name="Despina",
+ languages=GOOGLE_LANGUAGES,
+ ),
+ Voice(
+ id="Erinome",
+ provider=Provider.GOOGLE,
+ name="Erinome",
+ languages=GOOGLE_LANGUAGES,
+ ),
+ Voice(
+ id="Algenib",
+ provider=Provider.GOOGLE,
+ name="Algenib",
+ languages=GOOGLE_LANGUAGES,
+ ),
+ Voice(
+ id="Rasalgethi",
+ provider=Provider.GOOGLE,
+ name="Rasalgethi",
+ languages=GOOGLE_LANGUAGES,
+ ),
+ Voice(
+ id="Laomedeia",
+ provider=Provider.GOOGLE,
+ name="Laomedeia",
+ languages=GOOGLE_LANGUAGES,
+ ),
+ Voice(
+ id="Achernar",
+ provider=Provider.GOOGLE,
+ name="Achernar",
+ languages=GOOGLE_LANGUAGES,
+ ),
+ Voice(
+ id="Alnilam",
+ provider=Provider.GOOGLE,
+ name="Alnilam",
+ languages=GOOGLE_LANGUAGES,
+ ),
+ Voice(
+ id="Schedar",
+ provider=Provider.GOOGLE,
+ name="Schedar",
+ languages=GOOGLE_LANGUAGES,
+ ),
+ Voice(
+ id="Gacrux", provider=Provider.GOOGLE, name="Gacrux", languages=GOOGLE_LANGUAGES
+ ),
+ Voice(
+ id="Pulcherrima",
+ provider=Provider.GOOGLE,
+ name="Pulcherrima",
+ languages=GOOGLE_LANGUAGES,
+ ),
+ Voice(
+ id="Achird", provider=Provider.GOOGLE, name="Achird", languages=GOOGLE_LANGUAGES
+ ),
+ Voice(
+ id="Zubenelgenubi",
+ provider=Provider.GOOGLE,
+ name="Zubenelgenubi",
+ languages=GOOGLE_LANGUAGES,
+ ),
+ Voice(
+ id="Vindemiatrix",
+ provider=Provider.GOOGLE,
+ name="Vindemiatrix",
+ languages=GOOGLE_LANGUAGES,
+ ),
+ Voice(
+ id="Sadachbia",
+ provider=Provider.GOOGLE,
+ name="Sadachbia",
+ languages=GOOGLE_LANGUAGES,
+ ),
+ Voice(
+ id="Sadaltager",
+ provider=Provider.GOOGLE,
+ name="Sadaltager",
+ languages=GOOGLE_LANGUAGES,
+ ),
+ Voice(
+ id="Sulafat",
+ provider=Provider.GOOGLE,
+ name="Sulafat",
+ languages=GOOGLE_LANGUAGES,
+ ),
+]
diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/openai/__init__.py b/packages/speech-generation/src/celeste_speech_generation/providers/openai/__init__.py
new file mode 100644
index 00000000..0887c667
--- /dev/null
+++ b/packages/speech-generation/src/celeste_speech_generation/providers/openai/__init__.py
@@ -0,0 +1,6 @@
+"""OpenAI provider for speech generation."""
+
+from .client import OpenAISpeechGenerationClient
+from .models import MODELS
+
+__all__ = ["MODELS", "OpenAISpeechGenerationClient"]
diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/openai/client.py b/packages/speech-generation/src/celeste_speech_generation/providers/openai/client.py
new file mode 100644
index 00000000..3465fc35
--- /dev/null
+++ b/packages/speech-generation/src/celeste_speech_generation/providers/openai/client.py
@@ -0,0 +1,122 @@
+"""OpenAI client implementation for speech generation."""
+
+from typing import Any, Unpack
+
+import httpx
+
+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 (
+ SpeechGenerationInput,
+ SpeechGenerationOutput,
+ SpeechGenerationUsage,
+)
+from celeste_speech_generation.parameters import SpeechGenerationParameters
+
+from . import config
+from .parameters import OPENAI_PARAMETER_MAPPERS
+
+
+class OpenAISpeechGenerationClient(SpeechGenerationClient):
+ """OpenAI client for speech generation."""
+
+ @classmethod
+ def parameter_mappers(cls) -> list[ParameterMapper]:
+ return OPENAI_PARAMETER_MAPPERS
+
+ def _init_request(self, inputs: SpeechGenerationInput) -> dict[str, Any]:
+ """Initialize request from OpenAI API format."""
+ return {"input": inputs.text}
+
+ def _parse_usage(self, response_data: dict[str, Any]) -> SpeechGenerationUsage:
+ """Parse usage from response.
+
+ OpenAI TTS doesn't return usage metrics in response.
+ """
+ return SpeechGenerationUsage()
+
+ def _parse_content(
+ self,
+ response_data: dict[str, Any],
+ **parameters: Unpack[SpeechGenerationParameters],
+ ) -> AudioArtifact:
+ """Parse content from response.
+
+ Note: This method is not used for OpenAI TTS since we override generate()
+ to handle binary responses. Kept for interface compliance.
+ """
+ # This should never be called for OpenAI TTS
+ msg = "OpenAI TTS returns binary responses, use generate() override"
+ raise NotImplementedError(msg)
+
+ def _map_response_format_to_mime_type(
+ self, response_format: str | None
+ ) -> AudioMimeType:
+ """Map OpenAI response_format to AudioMimeType."""
+ format_map: dict[str, AudioMimeType] = {
+ "mp3": AudioMimeType.MP3,
+ "opus": AudioMimeType.OGG, # OGG is closest match for Opus
+ "aac": AudioMimeType.AAC,
+ "flac": AudioMimeType.FLAC,
+ }
+ return format_map.get(
+ response_format or "", AudioMimeType.MP3
+ ) # Default to MP3
+
+ async def _make_request(
+ self,
+ request_body: dict[str, Any],
+ **parameters: Unpack[SpeechGenerationParameters],
+ ) -> httpx.Response:
+ """Make HTTP request(s) and return response object."""
+ request_body["model"] = self.model.id
+
+ headers = {
+ config.AUTH_HEADER_NAME: f"{config.AUTH_HEADER_PREFIX}{self.api_key.get_secret_value()}",
+ "Content-Type": ApplicationMimeType.JSON,
+ }
+
+ return await self.http_client.post(
+ f"{config.BASE_URL}{config.ENDPOINT}",
+ headers=headers,
+ json_body=request_body,
+ )
+
+ async def generate(
+ self,
+ *args: str,
+ **parameters: Unpack[SpeechGenerationParameters],
+ ) -> SpeechGenerationOutput:
+ """Generate speech from text.
+
+ Override base generate() to handle binary audio response from OpenAI TTS.
+ """
+ inputs = self._create_inputs(*args, **parameters)
+ inputs, parameters = self._validate_artifacts(inputs, **parameters)
+ request_body = self._build_request(inputs, **parameters)
+ response = await self._make_request(request_body, **parameters)
+ self._handle_error_response(response)
+
+ # Handle binary response (OpenAI TTS returns raw audio bytes, not JSON)
+ audio_bytes = response.content
+ if not audio_bytes:
+ msg = "No audio data in response"
+ raise ValueError(msg)
+
+ # Determine MIME type from response_format parameter (default to mp3)
+ response_format = parameters.get("response_format") or "mp3"
+ mime_type = self._map_response_format_to_mime_type(response_format)
+
+ # Extract headers from response (OpenAI may return metadata in headers)
+ headers_dict = dict(response.headers)
+
+ return self._output_class()(
+ content=AudioArtifact(data=audio_bytes, mime_type=mime_type),
+ usage=SpeechGenerationUsage(),
+ metadata=self._build_metadata(headers_dict),
+ )
+
+
+__all__ = ["OpenAISpeechGenerationClient"]
diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/openai/config.py b/packages/speech-generation/src/celeste_speech_generation/providers/openai/config.py
new file mode 100644
index 00000000..226ded5a
--- /dev/null
+++ b/packages/speech-generation/src/celeste_speech_generation/providers/openai/config.py
@@ -0,0 +1,9 @@
+"""OpenAI provider configuration for speech generation."""
+
+# HTTP Configuration
+BASE_URL = "https://api.openai.com"
+ENDPOINT = "/v1/audio/speech"
+
+# Authentication
+AUTH_HEADER_NAME = "Authorization"
+AUTH_HEADER_PREFIX = "Bearer "
diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/openai/models.py b/packages/speech-generation/src/celeste_speech_generation/providers/openai/models.py
new file mode 100644
index 00000000..f4771222
--- /dev/null
+++ b/packages/speech-generation/src/celeste_speech_generation/providers/openai/models.py
@@ -0,0 +1,61 @@
+"""OpenAI models for speech generation."""
+
+from celeste import Model, Provider
+from celeste.constraints import Choice, Range
+from celeste.mime_types import AudioMimeType
+from celeste_speech_generation.constraints import VoiceConstraint
+from celeste_speech_generation.parameters import SpeechGenerationParameter
+
+from .voices import GPT4O_MINI_TTS_VOICES, TTS1_HD_VOICES, TTS1_VOICES
+
+# Common response format options for all OpenAI TTS models
+_RESPONSE_FORMAT_OPTIONS = [
+ AudioMimeType.MP3,
+ AudioMimeType.OGG, # Maps to "opus" in OpenAI API
+ AudioMimeType.AAC,
+ AudioMimeType.FLAC,
+]
+
+MODELS: list[Model] = [
+ Model(
+ id="tts-1",
+ provider=Provider.OPENAI,
+ display_name="TTS-1",
+ streaming=False,
+ parameter_constraints={
+ SpeechGenerationParameter.VOICE: VoiceConstraint(voices=TTS1_VOICES),
+ SpeechGenerationParameter.SPEED: Range(min=0.25, max=4.0),
+ SpeechGenerationParameter.RESPONSE_FORMAT: Choice(
+ options=_RESPONSE_FORMAT_OPTIONS
+ ),
+ },
+ ),
+ Model(
+ id="tts-1-hd",
+ provider=Provider.OPENAI,
+ display_name="TTS-1 HD",
+ streaming=False,
+ parameter_constraints={
+ SpeechGenerationParameter.VOICE: VoiceConstraint(voices=TTS1_HD_VOICES),
+ SpeechGenerationParameter.SPEED: Range(min=0.25, max=4.0),
+ SpeechGenerationParameter.RESPONSE_FORMAT: Choice(
+ options=_RESPONSE_FORMAT_OPTIONS
+ ),
+ },
+ ),
+ Model(
+ id="gpt-4o-mini-tts",
+ provider=Provider.OPENAI,
+ display_name="GPT-4o Mini TTS",
+ streaming=False,
+ parameter_constraints={
+ SpeechGenerationParameter.VOICE: VoiceConstraint(
+ voices=GPT4O_MINI_TTS_VOICES
+ ),
+ SpeechGenerationParameter.SPEED: Range(min=0.25, max=4.0),
+ SpeechGenerationParameter.RESPONSE_FORMAT: Choice(
+ options=_RESPONSE_FORMAT_OPTIONS
+ ),
+ },
+ ),
+]
diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/openai/parameters.py b/packages/speech-generation/src/celeste_speech_generation/providers/openai/parameters.py
new file mode 100644
index 00000000..f824f253
--- /dev/null
+++ b/packages/speech-generation/src/celeste_speech_generation/providers/openai/parameters.py
@@ -0,0 +1,132 @@
+"""OpenAI parameter mappers for speech generation."""
+
+from typing import Any
+
+from celeste.mime_types import AudioMimeType
+from celeste.models import Model
+from celeste.parameters import ParameterMapper
+from celeste_speech_generation.parameters import SpeechGenerationParameter
+
+
+class VoiceMapper(ParameterMapper):
+ """Map voice parameter to OpenAI voice field."""
+
+ name = SpeechGenerationParameter.VOICE
+
+ def map(
+ self,
+ request: dict[str, Any],
+ value: object,
+ model: Model,
+ ) -> dict[str, Any]:
+ """Transform voice into provider request.
+
+ Maps the unified voice parameter to the OpenAI API voice field.
+
+ Args:
+ request: Provider request dictionary to modify.
+ value: The voice ID or name (e.g., 'alloy', 'echo', 'nova').
+ model: Model instance with parameter constraints.
+
+ Returns:
+ Modified request dictionary with voice parameter.
+ """
+ validated_value = self._validate_value(value, model)
+ if validated_value is None:
+ return request
+
+ request["voice"] = validated_value
+ return request
+
+
+class SpeedMapper(ParameterMapper):
+ """Map speed parameter to OpenAI speed field."""
+
+ name = SpeechGenerationParameter.SPEED
+
+ def map(
+ self,
+ request: dict[str, Any],
+ value: object,
+ model: Model,
+ ) -> dict[str, Any]:
+ """Transform speed into provider request.
+
+ Maps the unified speed parameter to the OpenAI API speed field.
+ Valid range is 0.25 to 4.0.
+
+ Args:
+ request: Provider request dictionary to modify.
+ value: The playback speed multiplier (0.25 to 4.0).
+ model: Model instance with parameter constraints.
+
+ Returns:
+ Modified request dictionary with speed parameter.
+ """
+ validated_value = self._validate_value(value, model)
+ if validated_value is None:
+ return request
+
+ request["speed"] = validated_value
+ return request
+
+
+class ResponseFormatMapper(ParameterMapper):
+ """Map response_format parameter to OpenAI response_format field."""
+
+ name = SpeechGenerationParameter.RESPONSE_FORMAT
+
+ def map(
+ self,
+ request: dict[str, Any],
+ value: object,
+ model: Model,
+ ) -> dict[str, Any]:
+ """Transform response_format into provider request.
+
+ Maps the unified response_format parameter to the OpenAI API format.
+ Accepts both string values ('mp3', 'opus') and AudioMimeType enums.
+
+ Args:
+ request: Provider request dictionary to modify.
+ value: Output format as string or AudioMimeType (mp3, opus, aac, flac).
+ model: Model instance with parameter constraints.
+
+ Returns:
+ Modified request dictionary with response_format parameter.
+ """
+ # Convert string values to AudioMimeType enum before validation
+ if isinstance(value, str) and not isinstance(value, AudioMimeType):
+ string_to_mime_type: dict[str, AudioMimeType] = {
+ "mp3": AudioMimeType.MP3,
+ "opus": AudioMimeType.OGG, # OpenAI uses "opus" for OGG format
+ "aac": AudioMimeType.AAC,
+ "flac": AudioMimeType.FLAC,
+ }
+ value = string_to_mime_type.get(value.lower(), value)
+
+ validated_value = self._validate_value(value, model)
+ if validated_value is None:
+ return request
+
+ # Convert AudioMimeType enum to OpenAI string format
+ mime_type_to_openai_format: dict[AudioMimeType, str] = {
+ AudioMimeType.MP3: "mp3",
+ AudioMimeType.OGG: "opus", # OpenAI uses "opus" for OGG format
+ AudioMimeType.AAC: "aac",
+ AudioMimeType.FLAC: "flac",
+ }
+
+ # validated_value is now guaranteed to be AudioMimeType after constraint validation
+ response_format = mime_type_to_openai_format.get(validated_value, "mp3")
+ request["response_format"] = response_format
+ return request
+
+
+OPENAI_PARAMETER_MAPPERS: list[ParameterMapper] = [
+ VoiceMapper(),
+ SpeedMapper(),
+ ResponseFormatMapper(),
+]
+
+__all__ = ["OPENAI_PARAMETER_MAPPERS"]
diff --git a/packages/speech-generation/src/celeste_speech_generation/providers/openai/voices.py b/packages/speech-generation/src/celeste_speech_generation/providers/openai/voices.py
new file mode 100644
index 00000000..67d40966
--- /dev/null
+++ b/packages/speech-generation/src/celeste_speech_generation/providers/openai/voices.py
@@ -0,0 +1,140 @@
+"""OpenAI voice definitions for speech generation."""
+
+from celeste import Provider
+from celeste_speech_generation.voices import Voice
+
+# Model-specific voice lists
+# tts-1 supports: alloy, ash, coral, echo, fable, nova, onyx, sage, shimmer (9 voices, NO ballad)
+TTS1_VOICES = [
+ Voice(
+ id="alloy",
+ provider=Provider.OPENAI,
+ name="Alloy",
+ languages=set(), # OpenAI voices support multiple languages but don't specify per-voice
+ ),
+ Voice(
+ id="ash",
+ provider=Provider.OPENAI,
+ name="Ash",
+ languages=set(),
+ ),
+ Voice(
+ id="coral",
+ provider=Provider.OPENAI,
+ name="Coral",
+ languages=set(),
+ ),
+ Voice(
+ id="echo",
+ provider=Provider.OPENAI,
+ name="Echo",
+ languages=set(),
+ ),
+ Voice(
+ id="fable",
+ provider=Provider.OPENAI,
+ name="Fable",
+ languages=set(),
+ ),
+ Voice(
+ id="nova",
+ provider=Provider.OPENAI,
+ name="Nova",
+ languages=set(),
+ ),
+ Voice(
+ id="onyx",
+ provider=Provider.OPENAI,
+ name="Onyx",
+ languages=set(),
+ ),
+ Voice(
+ id="sage",
+ provider=Provider.OPENAI,
+ name="Sage",
+ languages=set(),
+ ),
+ Voice(
+ id="shimmer",
+ provider=Provider.OPENAI,
+ name="Shimmer",
+ languages=set(),
+ ),
+]
+
+# tts-1-hd supports same voices as tts-1 (conservative assumption)
+TTS1_HD_VOICES = TTS1_VOICES
+
+# gpt-4o-mini-tts supports all 10 voices including ballad
+GPT4O_MINI_TTS_VOICES = [
+ Voice(
+ id="alloy",
+ provider=Provider.OPENAI,
+ name="Alloy",
+ languages=set(),
+ ),
+ Voice(
+ id="ash",
+ provider=Provider.OPENAI,
+ name="Ash",
+ languages=set(),
+ ),
+ Voice(
+ id="ballad",
+ provider=Provider.OPENAI,
+ name="Ballad",
+ languages=set(),
+ ),
+ Voice(
+ id="coral",
+ provider=Provider.OPENAI,
+ name="Coral",
+ languages=set(),
+ ),
+ Voice(
+ id="echo",
+ provider=Provider.OPENAI,
+ name="Echo",
+ languages=set(),
+ ),
+ Voice(
+ id="fable",
+ provider=Provider.OPENAI,
+ name="Fable",
+ languages=set(),
+ ),
+ Voice(
+ id="nova",
+ provider=Provider.OPENAI,
+ name="Nova",
+ languages=set(),
+ ),
+ Voice(
+ id="onyx",
+ provider=Provider.OPENAI,
+ name="Onyx",
+ languages=set(),
+ ),
+ Voice(
+ id="sage",
+ provider=Provider.OPENAI,
+ name="Sage",
+ languages=set(),
+ ),
+ Voice(
+ id="shimmer",
+ provider=Provider.OPENAI,
+ name="Shimmer",
+ languages=set(),
+ ),
+]
+
+# Union of all voices for package-level exports
+OPENAI_VOICES = GPT4O_MINI_TTS_VOICES
+
+__all__ = [
+ "GPT4O_MINI_TTS_VOICES",
+ "OPENAI_VOICES",
+ "TTS1_HD_VOICES",
+ "TTS1_VOICES",
+]
diff --git a/packages/speech-generation/src/celeste_speech_generation/py.typed b/packages/speech-generation/src/celeste_speech_generation/py.typed
new file mode 100644
index 00000000..321d0ae1
--- /dev/null
+++ b/packages/speech-generation/src/celeste_speech_generation/py.typed
@@ -0,0 +1 @@
+# Marker file for PEP 561 - this package supports type checking
diff --git a/packages/speech-generation/src/celeste_speech_generation/streaming.py b/packages/speech-generation/src/celeste_speech_generation/streaming.py
new file mode 100644
index 00000000..84c91110
--- /dev/null
+++ b/packages/speech-generation/src/celeste_speech_generation/streaming.py
@@ -0,0 +1,44 @@
+"""Streaming for speech generation."""
+
+from abc import abstractmethod
+from typing import Unpack
+
+from celeste.artifacts import AudioArtifact
+from celeste.streaming import Stream
+from celeste_speech_generation.io import (
+ SpeechGenerationChunk,
+ SpeechGenerationOutput,
+ SpeechGenerationUsage,
+)
+from celeste_speech_generation.parameters import SpeechGenerationParameters
+
+
+class SpeechGenerationStream(
+ Stream[SpeechGenerationOutput, SpeechGenerationParameters, SpeechGenerationChunk]
+):
+ """Streaming for speech generation."""
+
+ def _parse_output( # type: ignore[override]
+ self,
+ chunks: list[SpeechGenerationChunk],
+ **parameters: Unpack[SpeechGenerationParameters],
+ ) -> SpeechGenerationOutput:
+ """Assemble chunks into final output."""
+ # Speech streaming: concatenate raw bytes
+ audio_bytes = b"".join(chunk.content for chunk in chunks)
+ usage = self._parse_usage(chunks)
+
+ return SpeechGenerationOutput(
+ content=AudioArtifact(data=audio_bytes),
+ usage=usage,
+ metadata={},
+ )
+
+ @abstractmethod
+ def _parse_usage(
+ self, chunks: list[SpeechGenerationChunk]
+ ) -> SpeechGenerationUsage:
+ """Parse usage from chunks (provider-specific)."""
+
+
+__all__ = ["SpeechGenerationStream"]
diff --git a/packages/speech-generation/src/celeste_speech_generation/voices.py b/packages/speech-generation/src/celeste_speech_generation/voices.py
new file mode 100644
index 00000000..6d95205c
--- /dev/null
+++ b/packages/speech-generation/src/celeste_speech_generation/voices.py
@@ -0,0 +1,17 @@
+"""Voice definitions for speech generation."""
+
+from pydantic import BaseModel, Field
+
+from celeste import Provider
+
+
+class Voice(BaseModel):
+ """Represents a voice for speech generation."""
+
+ id: str
+ provider: Provider
+ name: str
+ languages: set[str] = Field(default_factory=set)
+
+
+__all__ = ["Voice"]
diff --git a/packages/speech-generation/tests/integration_tests/__init__.py b/packages/speech-generation/tests/integration_tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/packages/speech-generation/tests/integration_tests/conftest.py b/packages/speech-generation/tests/integration_tests/conftest.py
new file mode 100644
index 00000000..81c92564
--- /dev/null
+++ b/packages/speech-generation/tests/integration_tests/conftest.py
@@ -0,0 +1,24 @@
+"""Pytest configuration and fixtures for integration tests."""
+
+from collections.abc import AsyncGenerator
+from typing import Any
+
+import pytest_asyncio
+
+from celeste.http import close_all_http_clients
+
+
+@pytest_asyncio.fixture(autouse=True)
+async def cleanup_http_clients() -> AsyncGenerator[None, None]:
+ """Ensure HTTP clients are closed after each test.
+
+ This fixture runs automatically after each test to ensure HTTP clients
+ are properly closed before pytest-asyncio closes the event loop.
+ This prevents "Event loop is closed" errors when using pytest-xdist.
+ """
+ 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")
diff --git a/packages/speech-generation/tests/integration_tests/test_speech_generation/__init__.py b/packages/speech-generation/tests/integration_tests/test_speech_generation/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/packages/speech-generation/tests/integration_tests/test_speech_generation/test_generate.py b/packages/speech-generation/tests/integration_tests/test_speech_generation/test_generate.py
new file mode 100644
index 00000000..4a82f36b
--- /dev/null
+++ b/packages/speech-generation/tests/integration_tests/test_speech_generation/test_generate.py
@@ -0,0 +1,73 @@
+"""Integration tests for speech generation across all providers."""
+
+import pytest
+
+from celeste import Capability, Provider, create_client
+
+
+@pytest.mark.parametrize(
+ ("provider", "model", "parameters"),
+ [
+ (Provider.OPENAI, "tts-1", {"voice": "alloy", "response_format": "mp3"}),
+ (
+ Provider.GOOGLE,
+ "gemini-2.5-flash-preview-tts",
+ {"voice": "Zephyr", "speed": 1.0},
+ ),
+ (
+ Provider.ELEVENLABS,
+ "eleven_flash_v2_5",
+ {"voice": "Laura", "response_format": "mp3_44100_128"},
+ ),
+ ],
+)
+@pytest.mark.integration
+@pytest.mark.asyncio
+async def test_generate(provider: Provider, model: str, parameters: dict) -> None:
+ """Test speech generation with voice parameter across all providers.
+
+ This test demonstrates that the unified API works identically across
+ all providers using the same code - proving the abstraction value.
+ Uses cheapest models to minimize costs.
+ """
+ # Import here to avoid circular import during pytest collection
+ from celeste_speech_generation import (
+ SpeechGenerationOutput,
+ SpeechGenerationUsage,
+ )
+
+ from celeste.artifacts import AudioArtifact
+
+ # Arrange
+ client = create_client(
+ capability=Capability.SPEECH_GENERATION,
+ provider=provider,
+ )
+ text = "Hello, this is a test of the Celeste speech generation capability."
+
+ # Act
+ response = await client.generate(
+ text=text,
+ model=model,
+ **parameters,
+ )
+
+ # Assert
+ assert isinstance(response, SpeechGenerationOutput), (
+ f"Expected SpeechGenerationOutput, got {type(response)}"
+ )
+ assert isinstance(response.content, AudioArtifact), (
+ f"Expected AudioArtifact content, got {type(response.content)}"
+ )
+ assert response.content.has_content, (
+ f"AudioArtifact has no content (data/path): {response.content}"
+ )
+ assert response.content.data is not None, "AudioArtifact data is None"
+ assert len(response.content.data) > 0, "Audio data is empty"
+
+ # Validate usage metrics
+ assert isinstance(response.usage, SpeechGenerationUsage), (
+ f"Expected SpeechGenerationUsage, got {type(response.usage)}"
+ )
+ # Note: Speech generation providers typically don't return usage metrics.
+ # Usage object exists for API consistency but fields are empty.
diff --git a/packages/speech-generation/tests/integration_tests/test_speech_generation/test_stream.py b/packages/speech-generation/tests/integration_tests/test_speech_generation/test_stream.py
new file mode 100644
index 00000000..437a295e
--- /dev/null
+++ b/packages/speech-generation/tests/integration_tests/test_speech_generation/test_stream.py
@@ -0,0 +1,59 @@
+"""Integration tests for speech generation streaming across all providers."""
+
+import pytest
+
+from celeste import Capability, Provider, create_client
+
+
+@pytest.mark.parametrize(
+ ("provider", "model", "parameters"),
+ [
+ # Only ElevenLabs currently supports streaming for speech generation
+ # OpenAI and Google TTS do not support streaming in the same way
+ (
+ Provider.ELEVENLABS,
+ "eleven_flash_v2_5",
+ {"voice": "Laura", "response_format": "mp3_44100_128"},
+ ),
+ ],
+)
+@pytest.mark.integration
+@pytest.mark.asyncio
+async def test_stream(provider: Provider, model: str, parameters: dict) -> None:
+ """Test speech generation streaming across supported providers.
+
+ Verifies that we receive audio chunks and can assemble them.
+ """
+ # Import here to avoid circular import during pytest collection
+ from celeste_speech_generation import (
+ SpeechGenerationChunk,
+ SpeechGenerationUsage,
+ )
+
+ # Arrange
+ client = create_client(
+ capability=Capability.SPEECH_GENERATION,
+ provider=provider,
+ )
+ text = "Hello, this is a streaming test."
+
+ # Act
+ chunks = []
+ async for chunk in client.stream(
+ text=text,
+ model=model,
+ **parameters,
+ ):
+ assert isinstance(chunk, SpeechGenerationChunk)
+ chunks.append(chunk)
+
+ # Assert
+ assert len(chunks) > 0, "No chunks received"
+ total_size = sum(len(chunk.content) for chunk in chunks)
+ assert total_size > 0, "Total audio size is 0"
+
+ # Verify usage if present in any chunk
+ has_usage = any(chunk.usage is not None for chunk in chunks)
+ if has_usage:
+ last_usage = next(c.usage for c in reversed(chunks) if c.usage)
+ assert isinstance(last_usage, SpeechGenerationUsage)
diff --git a/packages/text-generation/src/celeste_text_generation/client.py b/packages/text-generation/src/celeste_text_generation/client.py
index 30aeba88..4b08856c 100644
--- a/packages/text-generation/src/celeste_text_generation/client.py
+++ b/packages/text-generation/src/celeste_text_generation/client.py
@@ -45,12 +45,14 @@ def _parse_finish_reason(
"""Parse finish reason from provider response."""
def _create_inputs(
- self, *args: str, **parameters: Unpack[TextGenerationParameters]
+ self,
+ *args: str,
+ prompt: str | None = None,
+ **parameters: Unpack[TextGenerationParameters],
) -> TextGenerationInput:
"""Map positional arguments to Input type."""
if args:
return TextGenerationInput(prompt=args[0])
- prompt: str | None = parameters.get("prompt")
if prompt is None:
msg = (
"prompt is required (either as positional argument or keyword argument)"
diff --git a/packages/video-generation/src/celeste_video_generation/client.py b/packages/video-generation/src/celeste_video_generation/client.py
index 680c2329..85305136 100644
--- a/packages/video-generation/src/celeste_video_generation/client.py
+++ b/packages/video-generation/src/celeste_video_generation/client.py
@@ -38,12 +38,14 @@ def _parse_content(
"""Parse content from provider response."""
def _create_inputs(
- self, *args: str, **parameters: Unpack[VideoGenerationParameters]
+ self,
+ *args: str,
+ prompt: str | None = None,
+ **parameters: Unpack[VideoGenerationParameters],
) -> VideoGenerationInput:
"""Map positional arguments to Input type."""
if args:
return VideoGenerationInput(prompt=args[0])
- prompt: str | None = parameters.get("prompt")
if prompt is None:
msg = (
"prompt is required (either as positional argument or keyword argument)"
diff --git a/pyproject.toml b/pyproject.toml
index d40a1a91..be0d1b6a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "celeste-ai"
-version = "0.2.12"
+version = "0.2.13"
description = "Open source, type-safe primitives for multi-modal AI. All capabilities, all providers, one interface"
authors = [{name = "Kamilbenkirane", email = "kamil@withceleste.ai"}]
readme = "README.md"
@@ -17,7 +17,7 @@ classifiers = [
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Typing :: Typed",
]
-keywords = ["ai", "multimodal", "sdk", "openai", "anthropic", "claude", "gemini", "text-generation", "image-generation", "video-generation", "embeddings"]
+keywords = ["ai", "multimodal", "sdk", "openai", "anthropic", "claude", "gemini", "text-generation", "image-generation", "video-generation", "speech-generation", "embeddings"]
dependencies = [
"pydantic>=2.0",
"pydantic-settings>=2.0",
@@ -36,10 +36,12 @@ Issues = "https://github.com/withceleste/celeste-python/issues"
text-generation = ["celeste-text-generation>=0.2.10"]
image-generation = ["celeste-image-generation>=0.2.9"]
video-generation = ["celeste-video-generation>=0.2.8"]
+speech-generation = ["celeste-speech-generation>=0.2.8"]
all = [
"celeste-text-generation>=0.2.10",
"celeste-image-generation>=0.2.9",
"celeste-video-generation>=0.2.8",
+ "celeste-speech-generation>=0.2.8",
]
[dependency-groups]
@@ -63,6 +65,7 @@ members = ["packages/*"]
celeste-text-generation = { workspace = true }
celeste-image-generation = { workspace = true }
celeste-video-generation = { workspace = true }
+celeste-speech-generation = { workspace = true }
[build-system]
requires = ["hatchling"]
@@ -81,6 +84,7 @@ pythonpath = [
"packages/text-generation/src",
"packages/image-generation/src",
"packages/video-generation/src",
+ "packages/speech-generation/src",
]
markers = [
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
@@ -169,6 +173,8 @@ module = [
"celeste_image_generation.providers.*",
"celeste_video_generation.client",
"celeste_video_generation.providers.*",
+ "celeste_speech_generation.client",
+ "celeste_speech_generation.providers.*",
]
disable_error_code = ["override", "return-value", "arg-type", "call-arg", "assignment", "no-any-return"]