Ctrl K
ComunidadGuías…

tts_providers/grok_provider.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""
Grok / xAI TTS Provider — api.x.ai text-to-speech.

POST https://api.x.ai/v1/tts with Bearer XAI_API_KEY.
JSON body: text, voice_id, language (and optional output_format, speed).

API key: XAI_API_KEY env var
Docs: https://docs.x.ai/developers/model-capabilities/audio/text-to-speech

Provenance: originally contributed in the Bartok9/OpenVoiceUI fork (MIT, same lineage
as this repo) and adopted upstream here. Thanks to @Bartok9.

Availability: registers unconditionally but reports is_available() == False when
XAI_API_KEY is unset, so it is inert on installs that do not use xAI.
"""

from __future__ import annotations

import asyncio
import base64
import json
import logging
import os
import time
from typing import Any, AsyncIterator, Dict, Iterator, List, Optional, Tuple
from urllib.parse import urlencode

import httpx

from .base_provider import TTSProvider

logger = logging.getLogger(__name__)

API_BASE = "https://api.x.ai/v1"
TTS_URL = f"{API_BASE}/tts"
TTS_WS_URL = "wss://api.x.ai/v1/tts"
VOICES_URL = f"{API_BASE}/tts/voices"

GENERATE_TIMEOUT = 30.0
STREAM_TIMEOUT = 60.0
LIST_TIMEOUT = 15.0
HEALTH_TIMEOUT = 10.0

# Documented public roster (case-insensitive IDs). Prefer these for agents;
# do not embed private Team-of-Light / tenant custom voice secrets here.
# Default product preference for agents: eve (xAI default) then ara / rex.
DOCUMENTED_VOICES: List[str] = [
    "eve",  # default — general agents & UI confirmations
    "ara",  # warm / support-style
    "rex",  # clearer / media narration
    "sal",
    "leo",
]

AGENT_VOICE_PREFERENCE_NOTES = (
    "Agents: default voice_id=eve (xAI default). Prefer documented IDs only; "
    "pass custom voice_ids via config/env if operators clone voices registry-side. "
    "Do not commit private custom voice secrets into this package."
)

MAX_CHARACTERS = 15000


class GrokProvider(TTSProvider):
    """
    TTS Provider using xAI Grok Text-to-Speech API.

    Output: MP3 audio bytes by default (24 kHz / 128 kbps).
    """

    def __init__(self) -> None:
        super().__init__()
        self.api_key = os.getenv("XAI_API_KEY", "")
        self._status = "active" if self.api_key else "error"
        self._init_error: Optional[str] = None if self.api_key else "XAI_API_KEY not set"
        self._voices_cache: Optional[List[str]] = None
        self._voices_cache_time = 0.0

    def _auth_headers(self) -> Dict[str, str]:
        return {
            "Authorization": f"Bearer {self.api_key}",
            "Content-Type": "application/json",
        }

    def generate_speech(self, text: str, voice: str = "eve", **kwargs) -> bytes:
        """
        Generate speech via xAI TTS.

        Args:
            text: Text to synthesize (max 15_000 chars). Supports speech tags.
            voice: voice_id (default ``eve``). Also accepts kwargs ``voice_id``.
            **kwargs:
                language: BCP-47 code or ``auto`` (default ``en``).
                speed: 0.7–1.5 (optional).
                output_format: optional dict ``{codec, sample_rate, bit_rate}``.

        Returns:
            Raw audio bytes (default MP3).
        """
        if not self.api_key:
            raise RuntimeError("XAI_API_KEY not set")

        self.validate_text(text)
        if len(text) > MAX_CHARACTERS:
            raise ValueError(f"Text exceeds max {MAX_CHARACTERS} characters")

        voice_id = kwargs.get("voice_id") or voice or "eve"
        language = kwargs.get("language") or kwargs.get("lang") or "en"

        body: Dict[str, Any] = {
            "text": text,
            "voice_id": voice_id,
            "language": language,
        }
        if "speed" in kwargs and kwargs["speed"] is not None:
            body["speed"] = float(kwargs["speed"])
        if kwargs.get("output_format"):
            body["output_format"] = kwargs["output_format"]
        if kwargs.get("text_normalization") is not None:
            body["text_normalization"] = bool(kwargs["text_normalization"])
        if kwargs.get("optimize_streaming_latency") is not None:
            body["optimize_streaming_latency"] = int(kwargs["optimize_streaming_latency"])

        t = time.time()
        logger.info("[Grok/xAI] TTS request: '%s' voice=%s lang=%s", text[:60], voice_id, language)

        try:
            with httpx.Client(timeout=httpx.Timeout(GENERATE_TIMEOUT, connect=10.0)) as client:
                resp = client.post(TTS_URL, headers=self._auth_headers(), json=body)
                if resp.status_code >= 400:
                    detail = (resp.text or "")[:400]
                    raise RuntimeError(f"[grok:{resp.status_code}] {detail}")
                audio_bytes = resp.content
        except httpx.TimeoutException as exc:
            raise RuntimeError(f"[grok:timeout] TTS after {GENERATE_TIMEOUT}s") from exc
        except httpx.RequestError as exc:
            raise RuntimeError(f"[grok:network] {exc}") from exc

        if not audio_bytes:
            raise RuntimeError("[grok:empty] Empty audio body from xAI TTS")

        elapsed = int((time.time() - t) * 1000)
        logger.info("[Grok/xAI] Generated %s bytes in %sms", len(audio_bytes), elapsed)
        return audio_bytes

    def _stream_ws_query(
        self,
        *,
        voice: str = "eve",
        language: str = "en",
        codec: str = "mp3",
        sample_rate: int = 24000,
        bit_rate: int = 128000,
        speed: Optional[float] = None,
        optimize_streaming_latency: int = 1,
        text_normalization: bool = False,
    ) -> str:
        params: Dict[str, Any] = {
            "language": language,
            "voice": voice or "eve",
            "codec": codec,
            "sample_rate": int(sample_rate),
            "bit_rate": int(bit_rate),
            "optimize_streaming_latency": int(optimize_streaming_latency),
            "text_normalization": "true" if text_normalization else "false",
        }
        if speed is not None:
            params["speed"] = float(speed)
        return f"{TTS_WS_URL}?{urlencode(params)}"

    def stream_speech_sync(
        self,
        text: str,
        voice: str = "eve",
        *,
        voice_id: Optional[str] = None,
        language: str = "en",
        codec: str = "mp3",
        sample_rate: int = 24000,
        bit_rate: int = 128000,
        optimize_streaming_latency: int = 1,
        speed: Optional[float] = None,
        text_normalization: bool = False,
        connect_fn=None,
    ) -> Iterator[bytes]:
        """
        Stream TTS via bidirectional WebSocket ``wss://api.x.ai/v1/tts`` (TTFA path).

        Yields raw audio chunks decoded from ``audio.delta`` events.
        ``connect_fn`` is injectable for unit tests (mock sockets).

        Official docs: Streaming TTS (WebSocket) on xAI TTS docs.
        """
        if not self.api_key:
            raise RuntimeError("XAI_API_KEY not set")

        # Honor voice_id alias for parity with unary generate_speech.
        voice = voice_id or voice
        self.validate_text(text)
        # WS path has no hard total length on the session; still guard unary-like
        # single-delta size
        if len(text) > MAX_CHARACTERS:
            # split into MAX chunks at sentence-ish boundaries would be nicer;
            # for now, one delta max per official client message cap.
            raise ValueError(
                f"stream_speech_sync text delta exceeds max {MAX_CHARACTERS} characters; "
                "split caller-side or use multi-delta helpers"
            )

        uri = self._stream_ws_query(
            voice=voice or "eve",
            language=language or "en",
            codec=codec,
            sample_rate=sample_rate,
            bit_rate=bit_rate,
            speed=speed,
            optimize_streaming_latency=optimize_streaming_latency,
            text_normalization=text_normalization,
        )
        headers = {"Authorization": f"Bearer {self.api_key}"}

        def _default_connect(url: str, hdrs: Dict[str, str]):
            try:
                import websockets
                from websockets.sync.client import connect as ws_connect
            except ImportError as exc:  # pragma: no cover
                raise RuntimeError(
                    "websockets package required for Grok streaming TTS"
                ) from exc
            return ws_connect(url, additional_headers=hdrs, open_timeout=15, close_timeout=5)

        connector = connect_fn or _default_connect

        t0 = time.time()
        first = True
        logger.info(
            "[Grok/xAI] TTS WS stream: '%s' voice=%s lang=%s osl=%s",
            text[:60],
            voice,
            language,
            optimize_streaming_latency,
        )

        deadline = t0 + STREAM_TIMEOUT
        with connector(uri, headers) as ws:
            ws.send(json.dumps({"type": "text.delta", "delta": text}))
            ws.send(json.dumps({"type": "text.done"}))
            while True:
                remaining = deadline - time.time()
                if remaining <= 0:
                    raise TimeoutError(
                        f"[grok:ws] no audio.done within {STREAM_TIMEOUT}s stream timeout"
                    )
                try:
                    raw = ws.recv(timeout=remaining)
                except TypeError:
                    # Older websockets.sync without timeout kwarg support.
                    raw = ws.recv()
                except TimeoutError as exc:
                    raise TimeoutError(
                        f"[grok:ws] recv timed out after {STREAM_TIMEOUT}s"
                    ) from exc
                if isinstance(raw, bytes):
                    # Some stacks may deliver binary audio frames — yield as-is
                    if first:
                        logger.info(
                            "[Grok/xAI] TTFA (binary) %sms",
                            int((time.time() - t0) * 1000),
                        )
                        first = False
                    yield raw
                    continue
                try:
                    event = json.loads(raw)
                except json.JSONDecodeError:
                    logger.warning("[Grok/xAI] non-JSON WS frame skipped")
                    continue
                et = event.get("type") or ""
                if et in ("audio.delta", "response.output_audio.delta"):
                    b64 = event.get("delta") or event.get("audio") or ""
                    if not b64:
                        continue
                    chunk = base64.b64decode(b64)
                    if first:
                        logger.info(
                            "[Grok/xAI] TTFA %sms (%s bytes first chunk)",
                            int((time.time() - t0) * 1000),
                            len(chunk),
                        )
                        first = False
                    yield chunk
                elif et in ("audio.done", "response.output_audio.done"):
                    break
                elif et == "error":
                    raise RuntimeError(
                        f"[grok:ws] {event.get('message') or event.get('error') or event}"
                    )
                # ignore other session metadata

    def stream_speech(
        self,
        text: str,
        voice: str = "eve",
        **kwargs: Any,
    ) -> Iterator[bytes]:
        """Alias for :meth:`stream_speech_sync` (iterator of audio bytes)."""
        # Accept unary-style aliases without TypeError; strip keys WS signature rejects.
        voice = kwargs.get("voice_id") or kwargs.get("voice") or voice
        language = kwargs.get("language") or kwargs.get("lang") or "en"
        allowed = {
            "codec",
            "sample_rate",
            "bit_rate",
            "optimize_streaming_latency",
            "speed",
            "text_normalization",
            "connect_fn",
        }
        clean = {k: v for k, v in kwargs.items() if k in allowed}
        return self.stream_speech_sync(text, voice=voice, language=language, **clean)

    async def stream_speech_async(
        self,
        text: str,
        voice: str = "eve",
        *,
        voice_id: Optional[str] = None,
        language: str = "en",
        codec: str = "mp3",
        sample_rate: int = 24000,
        bit_rate: int = 128000,
        optimize_streaming_latency: int = 1,
        speed: Optional[float] = None,
        text_normalization: bool = False,
        connect_fn=None,
    ) -> AsyncIterator[bytes]:
        """Async variant of WebSocket streaming TTS (prefer TTFA path)."""
        if not self.api_key:
            raise RuntimeError("XAI_API_KEY not set")
        voice = voice_id or voice
        self.validate_text(text)
        if len(text) > MAX_CHARACTERS:
            raise ValueError(f"Text exceeds max {MAX_CHARACTERS} characters per delta")

        uri = self._stream_ws_query(
            voice=voice or "eve",
            language=language or "en",
            codec=codec,
            sample_rate=sample_rate,
            bit_rate=bit_rate,
            speed=speed,
            optimize_streaming_latency=optimize_streaming_latency,
            text_normalization=text_normalization,
        )
        headers = {"Authorization": f"Bearer {self.api_key}"}

        async def _default_connect(url: str, hdrs: Dict[str, str]):
            import websockets

            return websockets.connect(
                url, additional_headers=hdrs, open_timeout=15, close_timeout=5
            )

        connector = connect_fn or _default_connect
        t0 = time.time()
        first = True

        async with connector(uri, headers) as ws:
            await ws.send(json.dumps({"type": "text.delta", "delta": text}))
            await ws.send(json.dumps({"type": "text.done"}))
            deadline = t0 + STREAM_TIMEOUT
            aiter = ws.__aiter__()
            while True:
                remaining = deadline - time.time()
                if remaining <= 0:
                    raise TimeoutError(
                        f"[grok:ws] no audio.done within {STREAM_TIMEOUT}s stream timeout"
                    )
                try:
                    raw = await asyncio.wait_for(aiter.__anext__(), timeout=remaining)
                except StopAsyncIteration:
                    raise RuntimeError(
                        "[grok:ws] WebSocket closed before audio.done"
                    )
                except asyncio.TimeoutError as exc:
                    raise TimeoutError(
                        f"[grok:ws] recv timed out after {STREAM_TIMEOUT}s"
                    ) from exc
                if isinstance(raw, bytes):
                    if first:
                        first = False
                    yield raw
                    continue
                try:
                    event = json.loads(raw)
                except json.JSONDecodeError:
                    continue
                et = event.get("type") or ""
                if et in ("audio.delta", "response.output_audio.delta"):
                    b64 = event.get("delta") or event.get("audio") or ""
                    if not b64:
                        continue
                    chunk = base64.b64decode(b64)
                    if first:
                        logger.info(
                            "[Grok/xAI] async TTFA %sms",
                            int((time.time() - t0) * 1000),
                        )
                        first = False
                    yield chunk
                elif et in ("audio.done", "response.output_audio.done"):
                    break
                elif et == "error":
                    raise RuntimeError(
                        f"[grok:ws] {event.get('message') or event.get('error') or event}"
                    )

    def list_voices(self) -> List[str]:
        """
        Return voice IDs: live GET /v1/tts/voices when keyed, else documented roster.
        """
        if not self.api_key:
            return DOCUMENTED_VOICES.copy()

        # Short TTL cache
        now = time.time()
        if self._voices_cache is not None and (now - self._voices_cache_time) < 300:
            return self._voices_cache.copy()

        try:
            with httpx.Client(timeout=httpx.Timeout(LIST_TIMEOUT, connect=5.0)) as client:
                resp = client.get(
                    VOICES_URL,
                    headers={"Authorization": f"Bearer {self.api_key}"},
                )
                if resp.status_code >= 400:
                    logger.warning(
                        "[Grok/xAI] list voices HTTP %s — using documented roster",
                        resp.status_code,
                    )
                    return DOCUMENTED_VOICES.copy()
                payload = resp.json()
                voices = payload.get("voices") or []
                ids = [
                    str(v.get("voice_id") or v.get("id") or "").strip()
                    for v in voices
                    if isinstance(v, dict)
                ]
                ids = [i for i in ids if i]
                if not ids:
                    return DOCUMENTED_VOICES.copy()
                self._voices_cache = ids
                self._voices_cache_time = now
                return ids.copy()
        except Exception as exc:
            logger.warning("[Grok/xAI] list_voices failed: %s — documented roster", exc)
            return DOCUMENTED_VOICES.copy()

    def get_default_voice(self) -> str:
        return "eve"

    def is_available(self) -> bool:
        return bool(self.api_key)

    def health_check(self) -> dict:
        if not self.api_key:
            return {"ok": False, "latency_ms": 0, "detail": "XAI_API_KEY not set"}
        t = time.time()
        try:
            with httpx.Client(timeout=httpx.Timeout(HEALTH_TIMEOUT, connect=5.0)) as client:
                resp = client.get(
                    VOICES_URL,
                    headers={"Authorization": f"Bearer {self.api_key}"},
                )
                latency_ms = int((time.time() - t) * 1000)
                if resp.status_code >= 400:
                    return {
                        "ok": False,
                        "latency_ms": latency_ms,
                        "detail": f"HTTP {resp.status_code}: {(resp.text or '')[:200]}",
                    }
                return {
                    "ok": True,
                    "latency_ms": latency_ms,
                    "detail": "xAI TTS reachable — /v1/tts/voices OK",
                }
        except Exception as exc:
            latency_ms = int((time.time() - t) * 1000)
            return {"ok": False, "latency_ms": latency_ms, "detail": str(exc)}

    def get_info(self) -> dict:
        return {
            "name": "Grok / xAI TTS",
            "provider_id": "grok",
            "status": self._status if self.api_key else "error",
            "description": "xAI Text-to-Speech (Grok voices) — cloud MP3 TTS with speech tags",
            "quality": "high",
            "latency": "fast",
            "cost_per_minute": 0.0,  # operators: check console.x.ai for current pricing
            "voices": DOCUMENTED_VOICES.copy(),
            "features": [
                "cloud",
                "multilingual",
                "speech-tags",
                "mp3-output",
                "custom-voices-api",
                "agent-friendly-defaults",
                "streaming-websocket",
                "ttfa-optimize-streaming-latency",
            ],
            "requires_api_key": True,
            "languages": [
                "en",
                "zh",
                "fr",
                "de",
                "hi",
                "id",
                "it",
                "ja",
                "ko",
                "pt-BR",
                "pt-PT",
                "ru",
                "es-MX",
                "es-ES",
                "tr",
                "vi",
                "bn",
                "ar-EG",
                "ar-SA",
                "ar-AE",
                "auto",
            ],
            "max_characters": MAX_CHARACTERS,
            "notes": (
                "XAI_API_KEY required. Default voice_id=eve. "
                + AGENT_VOICE_PREFERENCE_NOTES
            ),
            "default_voice": "eve",
            "audio_format": "mp3",
            "sample_rate": 24000,
            "documentation_url": (
                "https://docs.x.ai/developers/model-capabilities/audio/text-to-speech"
            ),
            "error": self._init_error,
            "agent_voice_preference": AGENT_VOICE_PREFERENCE_NOTES,
        }


__all__ = [
    "GrokProvider",
    "DOCUMENTED_VOICES",
    "AGENT_VOICE_PREFERENCE_NOTES",
    "TTS_URL",
    "TTS_WS_URL",
]