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 6e38c0a4..0deba91f 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 @@ -3,29 +3,25 @@ import base64 from typing import Any, Unpack -import httpx -from celeste_google.generate_content import config +from celeste_google.cloud_tts.client import GoogleCloudTTSClient from celeste.artifacts import AudioArtifact -from celeste.mime_types import ApplicationMimeType, AudioMimeType +from celeste.mime_types import 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 celeste_speech_generation.parameters import ( + SpeechGenerationParameter, + SpeechGenerationParameters, +) 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): +class GoogleSpeechGenerationClient(GoogleCloudTTSClient, SpeechGenerationClient): """Google client for speech generation.""" @classmethod @@ -33,29 +29,17 @@ 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": {}, - } - + """Initialize request from Cloud TTS API format.""" return { - "contents": contents, - "generationConfig": generation_config, + "input": {"text": inputs.text}, + "voice": {"modelName": self.model.id}, + "audioConfig": {}, } 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() + """Parse usage from response.""" + usage = super()._parse_usage(response_data) + return SpeechGenerationUsage(**usage) def _parse_content( self, @@ -63,69 +47,25 @@ def _parse_content( **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) + audio_b64 = super()._parse_content(response_data) + audio_bytes = base64.b64decode(audio_b64) - # Decode base64 to get raw PCM bytes - # Google returns PCM audio (24kHz, 16-bit, mono) - pcm_bytes = base64.b64decode(base64_audio) + # Get output_format from parameters, default to MP3 + output_format = parameters.get(SpeechGenerationParameter.OUTPUT_FORMAT) + mime_type = self._get_mime_type(output_format) return AudioArtifact( - data=pcm_bytes, - mime_type=AudioMimeType.PCM, - metadata={ - "sample_rate": PCM_SAMPLE_RATE, - "channels": PCM_CHANNELS, - "sample_width": PCM_SAMPLE_WIDTH, - }, + data=audio_bytes, + mime_type=mime_type, + metadata={"format": str(mime_type)}, ) - 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.GoogleGenerateContentEndpoint.GENERATE_CONTENT.format( - model_id=self.model.id - ) - - headers = { - **self.auth.get_headers(), - "Content-Type": ApplicationMimeType.JSON, - } + def _get_mime_type(self, output_format: str | None) -> AudioMimeType: + """Get AudioMimeType from output_format parameter.""" + if output_format is None: + return AudioMimeType.MP3 - return await self.http_client.post( - f"{config.BASE_URL}{endpoint}", - headers=headers, - json_body=request_body, - ) + return AudioMimeType(output_format) __all__ = ["GoogleSpeechGenerationClient"] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/mappings.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/mappings.py new file mode 100644 index 00000000..2b8701a6 --- /dev/null +++ b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/mappings.py @@ -0,0 +1,37 @@ +"""Google TTS parameter mappings.""" + +from celeste.mime_types import AudioMimeType +from celeste_speech_generation.languages import Language + +# Map Language enum values (ISO 639-1) to Google BCP-47 locale codes +LOCALE_MAP: dict[str, str] = { + Language.ARABIC: "ar-EG", + Language.GERMAN: "de-DE", + Language.ENGLISH: "en-US", + Language.SPANISH: "es-US", + Language.FRENCH: "fr-FR", + Language.HINDI: "hi-IN", + Language.INDONESIAN: "id-ID", + Language.ITALIAN: "it-IT", + Language.JAPANESE: "ja-JP", + Language.KOREAN: "ko-KR", + Language.PORTUGUESE: "pt-BR", + Language.RUSSIAN: "ru-RU", + Language.DUTCH: "nl-NL", + Language.POLISH: "pl-PL", + Language.THAI: "th-TH", + Language.TURKISH: "tr-TR", + Language.VIETNAMESE: "vi-VN", + Language.ROMANIAN: "ro-RO", + Language.UKRAINIAN: "uk-UA", + Language.TAMIL: "ta-IN", +} + +ENCODING_MAP: dict[AudioMimeType, str] = { + AudioMimeType.MP3: "MP3", + AudioMimeType.WAV: "LINEAR16", + AudioMimeType.OGG: "OGG_OPUS", + AudioMimeType.PCM: "PCM", +} + +__all__ = ["ENCODING_MAP", "LOCALE_MAP"] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/models.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/models.py index 6e8dcca1..c2c9cdde 100644 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/models.py +++ b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/models.py @@ -1,31 +1,77 @@ -"""Google models for speech generation.""" +"""Google TTS models for speech generation.""" from celeste import Model, Provider -from celeste.constraints import Str +from celeste.constraints import Choice, Str +from celeste.mime_types import AudioMimeType from celeste_speech_generation.constraints import VoiceConstraint +from celeste_speech_generation.languages import Language from celeste_speech_generation.parameters import SpeechGenerationParameter from .voices import GOOGLE_VOICES +# Supported output formats for Google TTS +GOOGLE_SUPPORTED_FORMATS = [ + AudioMimeType.MP3, + AudioMimeType.WAV, + AudioMimeType.OGG, + AudioMimeType.PCM, +] + +# Supported languages for Google TTS (subset of Language enum) +GOOGLE_SUPPORTED_LANGUAGES = [ + Language.ARABIC, + Language.GERMAN, + Language.ENGLISH, + Language.SPANISH, + Language.FRENCH, + Language.HINDI, + Language.INDONESIAN, + Language.ITALIAN, + Language.JAPANESE, + Language.KOREAN, + Language.PORTUGUESE, + Language.RUSSIAN, + Language.DUTCH, + Language.POLISH, + Language.THAI, + Language.TURKISH, + Language.VIETNAMESE, + Language.ROMANIAN, + Language.UKRAINIAN, + Language.TAMIL, +] + MODELS: list[Model] = [ Model( - id="gemini-2.5-flash-preview-tts", + id="gemini-2.5-flash-tts", provider=Provider.GOOGLE, - display_name="Gemini 2.5 Flash TTS (Preview)", + display_name="Google TTS Gemini 2.5 Flash", streaming=False, parameter_constraints={ SpeechGenerationParameter.VOICE: VoiceConstraint(voices=GOOGLE_VOICES), + SpeechGenerationParameter.LANGUAGE: Choice( + options=GOOGLE_SUPPORTED_LANGUAGES + ), SpeechGenerationParameter.PROMPT: Str(), + SpeechGenerationParameter.OUTPUT_FORMAT: Choice( + options=GOOGLE_SUPPORTED_FORMATS + ), }, ), Model( - id="gemini-2.5-pro-preview-tts", + id="gemini-2.5-pro-tts", provider=Provider.GOOGLE, - display_name="Gemini 2.5 Pro TTS (Preview)", + display_name="Google TTS Gemini 2.5 Pro", streaming=False, parameter_constraints={ SpeechGenerationParameter.VOICE: VoiceConstraint(voices=GOOGLE_VOICES), + SpeechGenerationParameter.LANGUAGE: Choice( + options=GOOGLE_SUPPORTED_LANGUAGES + ), SpeechGenerationParameter.PROMPT: Str(), + SpeechGenerationParameter.OUTPUT_FORMAT: Choice( + options=GOOGLE_SUPPORTED_FORMATS + ), }, ), ] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/parameters.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/parameters.py index 0f4dc189..71e0b44b 100644 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/parameters.py +++ b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/parameters.py @@ -1,64 +1,47 @@ -"""Google parameter mappers for speech generation.""" +"""Google Cloud TTS parameter mappers for speech generation.""" + +from celeste_google.cloud_tts.parameters import ( + AudioEncodingMapper as _AudioEncodingMapper, +) +from celeste_google.cloud_tts.parameters import ( + LanguageMapper as _LanguageMapper, +) +from celeste_google.cloud_tts.parameters import ( + PromptMapper as _PromptMapper, +) +from celeste_google.cloud_tts.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 +from .mappings import ENCODING_MAP, LOCALE_MAP -class VoiceMapper(ParameterMapper): - """Map voice parameter to Google speechConfig.voiceConfig.prebuiltVoiceConfig.voiceName.""" +class VoiceMapper(_VoiceMapper): name = SpeechGenerationParameter.VOICE - def map( - self, - request: dict[str, Any], - value: object, - model: Model, - ) -> dict[str, Any]: - """Transform voice into provider request.""" - 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 LanguageMapper(_LanguageMapper): + name = SpeechGenerationParameter.LANGUAGE + locale_map = LOCALE_MAP -class PromptMapper(ParameterMapper): - """Map prompt parameter to style instructions in text.""" +class PromptMapper(_PromptMapper): 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 +class OutputFormatMapper(_AudioEncodingMapper): + name = SpeechGenerationParameter.OUTPUT_FORMAT + encoding_map = ENCODING_MAP GOOGLE_PARAMETER_MAPPERS: list[ParameterMapper] = [ VoiceMapper(), + LanguageMapper(), PromptMapper(), + OutputFormatMapper(), ] __all__ = ["GOOGLE_PARAMETER_MAPPERS"] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/voices.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/voices.py index 789a9f48..4a3262b7 100644 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/voices.py +++ b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/google/voices.py @@ -1,177 +1,212 @@ -"""Google voice definitions for speech generation.""" +"""Google TTS voice definitions for speech generation.""" from celeste import Provider +from celeste_speech_generation.languages import Language 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", -} +# All supported languages for Google Gemini TTS (24 languages, auto-detected) +_SUPPORTED_LANGUAGES = [ + Language.ARABIC, + Language.GERMAN, + Language.ENGLISH, + Language.FRENCH, + Language.HINDI, + Language.INDONESIAN, + Language.ITALIAN, + Language.JAPANESE, + Language.KOREAN, + Language.PORTUGUESE, + Language.RUSSIAN, + Language.DUTCH, + Language.POLISH, + Language.THAI, + Language.TURKISH, + Language.VIETNAMESE, + Language.ROMANIAN, + Language.UKRAINIAN, + Language.TAMIL, +] +# Google Gemini TTS voices (30 voices, all support all languages) GOOGLE_VOICES = [ Voice( - id="Zephyr", provider=Provider.GOOGLE, name="Zephyr", languages=GOOGLE_LANGUAGES + id="Zephyr", + provider=Provider.GOOGLE, + name="Zephyr (Bright)", + languages=_SUPPORTED_LANGUAGES, + ), + Voice( + id="Puck", + provider=Provider.GOOGLE, + name="Puck (Upbeat)", + languages=_SUPPORTED_LANGUAGES, ), - Voice(id="Puck", provider=Provider.GOOGLE, name="Puck", languages=GOOGLE_LANGUAGES), Voice( - id="Charon", provider=Provider.GOOGLE, name="Charon", languages=GOOGLE_LANGUAGES + id="Charon", + provider=Provider.GOOGLE, + name="Charon (Informative)", + languages=_SUPPORTED_LANGUAGES, ), - Voice(id="Kore", provider=Provider.GOOGLE, name="Kore", languages=GOOGLE_LANGUAGES), Voice( - id="Fenrir", provider=Provider.GOOGLE, name="Fenrir", languages=GOOGLE_LANGUAGES + id="Kore", + provider=Provider.GOOGLE, + name="Kore (Firm)", + languages=_SUPPORTED_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 + id="Fenrir", + provider=Provider.GOOGLE, + name="Fenrir (Excitable)", + languages=_SUPPORTED_LANGUAGES, + ), + Voice( + id="Leda", + provider=Provider.GOOGLE, + name="Leda (Youthful)", + languages=_SUPPORTED_LANGUAGES, + ), + Voice( + id="Orus", + provider=Provider.GOOGLE, + name="Orus (Firm)", + languages=_SUPPORTED_LANGUAGES, + ), + Voice( + id="Aoede", + provider=Provider.GOOGLE, + name="Aoede (Breezy)", + languages=_SUPPORTED_LANGUAGES, ), Voice( id="Callirrhoe", provider=Provider.GOOGLE, - name="Callirrhoe", - languages=GOOGLE_LANGUAGES, + name="Callirrhoe (Easy-going)", + languages=_SUPPORTED_LANGUAGES, ), Voice( id="Autonoe", provider=Provider.GOOGLE, - name="Autonoe", - languages=GOOGLE_LANGUAGES, + name="Autonoe (Bright)", + languages=_SUPPORTED_LANGUAGES, ), Voice( id="Enceladus", provider=Provider.GOOGLE, - name="Enceladus", - languages=GOOGLE_LANGUAGES, + name="Enceladus (Breathy)", + languages=_SUPPORTED_LANGUAGES, ), Voice( id="Iapetus", provider=Provider.GOOGLE, - name="Iapetus", - languages=GOOGLE_LANGUAGES, + name="Iapetus (Clear)", + languages=_SUPPORTED_LANGUAGES, ), Voice( id="Umbriel", provider=Provider.GOOGLE, - name="Umbriel", - languages=GOOGLE_LANGUAGES, + name="Umbriel (Easy-going)", + languages=_SUPPORTED_LANGUAGES, ), Voice( id="Algieba", provider=Provider.GOOGLE, - name="Algieba", - languages=GOOGLE_LANGUAGES, + name="Algieba (Smooth)", + languages=_SUPPORTED_LANGUAGES, ), Voice( id="Despina", provider=Provider.GOOGLE, - name="Despina", - languages=GOOGLE_LANGUAGES, + name="Despina (Smooth)", + languages=_SUPPORTED_LANGUAGES, ), Voice( id="Erinome", provider=Provider.GOOGLE, - name="Erinome", - languages=GOOGLE_LANGUAGES, + name="Erinome (Clear)", + languages=_SUPPORTED_LANGUAGES, ), Voice( id="Algenib", provider=Provider.GOOGLE, - name="Algenib", - languages=GOOGLE_LANGUAGES, + name="Algenib (Gravelly)", + languages=_SUPPORTED_LANGUAGES, ), Voice( id="Rasalgethi", provider=Provider.GOOGLE, - name="Rasalgethi", - languages=GOOGLE_LANGUAGES, + name="Rasalgethi (Informative)", + languages=_SUPPORTED_LANGUAGES, ), Voice( id="Laomedeia", provider=Provider.GOOGLE, - name="Laomedeia", - languages=GOOGLE_LANGUAGES, + name="Laomedeia (Upbeat)", + languages=_SUPPORTED_LANGUAGES, ), Voice( id="Achernar", provider=Provider.GOOGLE, - name="Achernar", - languages=GOOGLE_LANGUAGES, + name="Achernar (Soft)", + languages=_SUPPORTED_LANGUAGES, ), Voice( id="Alnilam", provider=Provider.GOOGLE, - name="Alnilam", - languages=GOOGLE_LANGUAGES, + name="Alnilam (Firm)", + languages=_SUPPORTED_LANGUAGES, ), Voice( id="Schedar", provider=Provider.GOOGLE, - name="Schedar", - languages=GOOGLE_LANGUAGES, + name="Schedar (Even)", + languages=_SUPPORTED_LANGUAGES, ), Voice( - id="Gacrux", provider=Provider.GOOGLE, name="Gacrux", languages=GOOGLE_LANGUAGES + id="Gacrux", + provider=Provider.GOOGLE, + name="Gacrux (Mature)", + languages=_SUPPORTED_LANGUAGES, ), Voice( id="Pulcherrima", provider=Provider.GOOGLE, - name="Pulcherrima", - languages=GOOGLE_LANGUAGES, + name="Pulcherrima (Forward)", + languages=_SUPPORTED_LANGUAGES, ), Voice( - id="Achird", provider=Provider.GOOGLE, name="Achird", languages=GOOGLE_LANGUAGES + id="Achird", + provider=Provider.GOOGLE, + name="Achird (Friendly)", + languages=_SUPPORTED_LANGUAGES, ), Voice( id="Zubenelgenubi", provider=Provider.GOOGLE, - name="Zubenelgenubi", - languages=GOOGLE_LANGUAGES, + name="Zubenelgenubi (Casual)", + languages=_SUPPORTED_LANGUAGES, ), Voice( id="Vindemiatrix", provider=Provider.GOOGLE, - name="Vindemiatrix", - languages=GOOGLE_LANGUAGES, + name="Vindemiatrix (Gentle)", + languages=_SUPPORTED_LANGUAGES, ), Voice( id="Sadachbia", provider=Provider.GOOGLE, - name="Sadachbia", - languages=GOOGLE_LANGUAGES, + name="Sadachbia (Lively)", + languages=_SUPPORTED_LANGUAGES, ), Voice( id="Sadaltager", provider=Provider.GOOGLE, - name="Sadaltager", - languages=GOOGLE_LANGUAGES, + name="Sadaltager (Knowledgeable)", + languages=_SUPPORTED_LANGUAGES, ), Voice( id="Sulafat", provider=Provider.GOOGLE, - name="Sulafat", - languages=GOOGLE_LANGUAGES, + name="Sulafat (Warm)", + languages=_SUPPORTED_LANGUAGES, ), ] diff --git a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/client.py b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/client.py index d2dafb12..9521e858 100644 --- a/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/client.py +++ b/packages/capabilities/speech-generation/src/celeste_speech_generation/providers/gradium/client.py @@ -2,6 +2,7 @@ from typing import Any, Unpack +import httpx from celeste_gradium.text_to_speech.client import GradiumTextToSpeechClient from celeste.artifacts import AudioArtifact @@ -46,6 +47,19 @@ def _parse_content( msg = "Gradium TTS uses WebSocket, use generate() override" raise NotImplementedError(msg) + async def _make_request( + self, + request_body: dict[str, Any], + **parameters: Unpack[SpeechGenerationParameters], + ) -> httpx.Response: + """Make HTTP request. + + Note: This method is not used for Gradium TTS since we override generate() + to use WebSocket. Kept for interface compliance. + """ + msg = "Gradium TTS uses WebSocket, use generate() override" + raise NotImplementedError(msg) + async def generate( self, *args: str, diff --git a/packages/capabilities/speech-generation/tests/integration_tests/test_speech_generation/test_generate.py b/packages/capabilities/speech-generation/tests/integration_tests/test_speech_generation/test_generate.py index 55252691..57f0276d 100644 --- a/packages/capabilities/speech-generation/tests/integration_tests/test_speech_generation/test_generate.py +++ b/packages/capabilities/speech-generation/tests/integration_tests/test_speech_generation/test_generate.py @@ -11,8 +11,8 @@ (Provider.OPENAI, "tts-1", {"voice": "alloy", "output_format": "mp3"}), ( Provider.GOOGLE, - "gemini-2.5-flash-preview-tts", - {"voice": "Zephyr"}, + "gemini-2.5-flash-tts", + {"voice": "Zephyr", "speed": 1.0}, ), ( Provider.ELEVENLABS, diff --git a/packages/providers/google/pyproject.toml b/packages/providers/google/pyproject.toml index e6b7a8cb..dd12936d 100644 --- a/packages/providers/google/pyproject.toml +++ b/packages/providers/google/pyproject.toml @@ -5,7 +5,7 @@ description = "Google (Gemini API) 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", "google-auth"] +dependencies = ["celeste-ai", "httpx", "google-auth", "requests"] [tool.uv.sources] celeste-ai = { workspace = true } @@ -16,3 +16,6 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/celeste_google"] + +[project.entry-points."celeste.providers"] +google = "celeste_google:register_provider" diff --git a/packages/providers/google/src/celeste_google/__init__.py b/packages/providers/google/src/celeste_google/__init__.py index ec93ff5d..f1e9bd17 100644 --- a/packages/providers/google/src/celeste_google/__init__.py +++ b/packages/providers/google/src/celeste_google/__init__.py @@ -1 +1,12 @@ """Google provider package for Celeste AI.""" + + +def register_provider() -> None: + """Register Google provider auth types.""" + from celeste.auth import register_auth + from celeste_google.auth import GoogleADC + + register_auth("google_adc", GoogleADC) + + +__all__ = ["register_provider"] diff --git a/packages/providers/google/src/celeste_google/auth.py b/packages/providers/google/src/celeste_google/auth.py new file mode 100644 index 00000000..75d7253e --- /dev/null +++ b/packages/providers/google/src/celeste_google/auth.py @@ -0,0 +1,51 @@ +"""Google Cloud authentication using ADC.""" + +from typing import Any, ClassVar + +from pydantic import ConfigDict + +from celeste.auth import Authentication + + +class GoogleADC(Authentication): + """Google Application Default Credentials authentication. + + Uses google-auth library for automatic credential discovery from: + 1. GOOGLE_APPLICATION_CREDENTIALS env var + 2. gcloud auth application-default login + 3. Attached service account (on GCP) + """ + + scopes: ClassVar[list[str]] = ["https://www.googleapis.com/auth/cloud-platform"] + project_id: str | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(arbitrary_types_allowed=True) + + _credentials: Any = None + _auth_request: Any = None + _project: str | None = None + + def get_headers(self) -> dict[str, str]: + """Return OAuth Bearer token header with quota project.""" + token, project = self._get_access_token() + headers = {"Authorization": f"Bearer {token}"} + if project: + headers["x-goog-user-project"] = project + return headers + + def _get_access_token(self) -> tuple[str, str | None]: + """Get OAuth access token using Application Default Credentials.""" + import google.auth + import google.auth.transport.requests + + if self._credentials is None: + self._credentials, self._project = google.auth.default(scopes=self.scopes) + self._auth_request = google.auth.transport.requests.Request() + + if not self._credentials.valid: + self._credentials.refresh(self._auth_request) + + return self._credentials.token, self.project_id or self._project + + +__all__ = ["GoogleADC"] diff --git a/packages/providers/google/src/celeste_google/cloud_tts/__init__.py b/packages/providers/google/src/celeste_google/cloud_tts/__init__.py new file mode 100644 index 00000000..b15943e9 --- /dev/null +++ b/packages/providers/google/src/celeste_google/cloud_tts/__init__.py @@ -0,0 +1 @@ +"""Google Cloud TTS API provider package.""" diff --git a/packages/providers/google/src/celeste_google/cloud_tts/client.py b/packages/providers/google/src/celeste_google/cloud_tts/client.py new file mode 100644 index 00000000..23ee5770 --- /dev/null +++ b/packages/providers/google/src/celeste_google/cloud_tts/client.py @@ -0,0 +1,82 @@ +"""Google Cloud TTS API client with shared implementation.""" + +from typing import Any + +import httpx + +from celeste.auth import get_auth_class +from celeste.client import APIMixin +from celeste.io import FinishReason +from celeste.mime_types import ApplicationMimeType + +from . import config + + +class GoogleCloudTTSClient(APIMixin): + """Mixin for Cloud TTS API capabilities. + + Provides shared implementation for speech generation using Cloud TTS API: + - model_post_init() - Override auth to use GoogleADC (Cloud TTS requires OAuth) + - _make_request() - HTTP POST to text:synthesize endpoint + - _parse_content() - Extract base64 audio content (generic) + + Capability clients extend via super() to wrap results in artifacts: + class GoogleSpeechGenerationClient(GoogleCloudTTSClient, SpeechGenerationClient): + def _parse_content(self, response_data, **params): + audio_b64 = super()._parse_content(response_data) # Get base64 string + audio_bytes = base64.b64decode(audio_b64) + return AudioArtifact(data=audio_bytes, mime_type=..., ...) + """ + + def model_post_init(self, __context: object) -> None: + """Override auth to use OAuth/ADC (Cloud TTS requires it).""" + super().model_post_init(__context) # type: ignore[misc] + GoogleADC = get_auth_class("google_adc") + object.__setattr__(self, "auth", GoogleADC()) + + async def _make_request( + self, + request_body: dict[str, Any], + **parameters: Any, + ) -> httpx.Response: + """Make HTTP request to Cloud TTS synthesize endpoint.""" + headers = { + **self.auth.get_headers(), + "Content-Type": ApplicationMimeType.JSON, + } + + return await self.http_client.post( + f"{config.BASE_URL}{config.GoogleCloudTTSEndpoint.CREATE_SPEECH}", + headers=headers, + json_body=request_body, + ) + + def _parse_content(self, response_data: dict[str, Any]) -> Any: + """Extract base64 audio content from response. + + Returns base64 string that capability clients decode and wrap in artifacts. + """ + audio_content = response_data.get("audioContent") + if not audio_content: + msg = "No audioContent in response" + raise ValueError(msg) + return audio_content + + def _parse_usage(self, response_data: dict[str, Any]) -> dict[str, int | None]: + """Cloud TTS API doesn't provide usage metadata.""" + return {} + + def _parse_finish_reason(self, response_data: dict[str, Any]) -> FinishReason: + """Cloud TTS API doesn't provide finish reasons.""" + return FinishReason(reason=None) + + def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]: + """Build metadata, filtering out audio content.""" + content_fields = {"audioContent"} + filtered_data = { + k: v for k, v in response_data.items() if k not in content_fields + } + return super()._build_metadata(filtered_data) + + +__all__ = ["GoogleCloudTTSClient"] diff --git a/packages/providers/google/src/celeste_google/cloud_tts/config.py b/packages/providers/google/src/celeste_google/cloud_tts/config.py new file mode 100644 index 00000000..5ba43d34 --- /dev/null +++ b/packages/providers/google/src/celeste_google/cloud_tts/config.py @@ -0,0 +1,16 @@ +"""Configuration for Google CloudTTS API.""" + +from enum import StrEnum + + +class GoogleCloudTTSEndpoint(StrEnum): + """Endpoints for CloudTTS API.""" + + CREATE_SPEECH = "/v1/text:synthesize" + LIST_VOICES = "/v1/voices" + CREATE_LONG_AUDIO = "/v1/{parent=projects/*/locations/*}:synthesizeLongAudio" + GET_OPERATION = "/v1/{name=projects/*/locations/*/operations/*}" + LIST_OPERATIONS = "/v1/{name=projects/*/locations/*}/operations" + + +BASE_URL = "https://texttospeech.googleapis.com" diff --git a/packages/providers/google/src/celeste_google/cloud_tts/parameters.py b/packages/providers/google/src/celeste_google/cloud_tts/parameters.py new file mode 100644 index 00000000..047b81e9 --- /dev/null +++ b/packages/providers/google/src/celeste_google/cloud_tts/parameters.py @@ -0,0 +1,105 @@ +"""Google Cloud TTS API parameter mappers.""" + +from typing import Any, ClassVar + +from celeste.mime_types import AudioMimeType +from celeste.models import Model +from celeste.parameters import ParameterMapper + + +class VoiceMapper(ParameterMapper): + """Map voice to Google Cloud TTS voice.name field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform voice into provider request.""" + validated_value = self._validate_value(value, model) + if validated_value is None: + return request + + request.setdefault("voice", {})["name"] = validated_value + return request + + +class LanguageMapper(ParameterMapper): + """Map language to Google Cloud TTS voice.languageCode field.""" + + locale_map: ClassVar[dict[str, str]] = {} + + 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: + request.setdefault("voice", {})["languageCode"] = "fr-FR" + return request + + # Convert to string (handles both str and StrEnum) + lang_code = str(validated_value) + locale_code = self.locale_map.get(lang_code) + + if locale_code: + request.setdefault("voice", {})["languageCode"] = locale_code + + return request + + +class PromptMapper(ParameterMapper): + """Map prompt to Google Cloud TTS input.prompt field.""" + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform prompt into provider request.""" + if not value or not isinstance(value, str): + return request + + request.setdefault("input", {})["prompt"] = value + return request + + +class AudioEncodingMapper(ParameterMapper): + """Map audio_encoding to Google Cloud TTS audioConfig.audioEncoding field.""" + + encoding_map: ClassVar[dict[AudioMimeType, str]] = {} + + def map( + self, + request: dict[str, Any], + value: object, + model: Model, + ) -> dict[str, Any]: + """Transform audio_encoding into provider request.""" + validated_value = self._validate_value(value, model) + + if validated_value is None: + request.setdefault("audioConfig", {})["audioEncoding"] = "MP3" + return request + + # Convert to AudioMimeType if string (fail fast if invalid) + if isinstance(validated_value, str): + validated_value = AudioMimeType(validated_value) + + # Default to MP3 if mapping fails + encoding = self.encoding_map.get(validated_value, "MP3") + request.setdefault("audioConfig", {})["audioEncoding"] = encoding + return request + + +__all__ = [ + "AudioEncodingMapper", + "LanguageMapper", + "PromptMapper", + "VoiceMapper", +] diff --git a/packages/providers/openai/src/celeste_openai/__init__.py b/packages/providers/openai/src/celeste_openai/__init__.py index f25385ae..ca08362a 100644 --- a/packages/providers/openai/src/celeste_openai/__init__.py +++ b/packages/providers/openai/src/celeste_openai/__init__.py @@ -1,3 +1 @@ """OpenAI provider package for Celeste AI.""" - -__all__: list[str] = [] diff --git a/packages/providers/xai/src/celeste_xai/__init__.py b/packages/providers/xai/src/celeste_xai/__init__.py index 6e887224..825b7690 100644 --- a/packages/providers/xai/src/celeste_xai/__init__.py +++ b/packages/providers/xai/src/celeste_xai/__init__.py @@ -1,3 +1 @@ """XAI provider package for Celeste AI.""" - -__all__: list[str] = []