From 71283b6ebfb980355d5b3af9b9c9b58563791032 Mon Sep 17 00:00:00 2001 From: seymourtang Date: Tue, 21 Jul 2026 10:44:28 +0800 Subject: [PATCH] fix:Update GenericTTS documentation and implementation to specify HTTP(S) support. Adjusted parameter requirements and added validation for URL format. Updated tests to reflect changes in vendor classification and serialization. --- docs/concepts/vendors.md | 4 +-- docs/reference/vendors.md | 10 ++++--- src/agora_agent/agentkit/vendors/tts.py | 40 ++++++++++++++++--------- tests/custom/test_agentkit_vendors.py | 25 ++++++++++++++-- tests/custom/test_regional_vendors.py | 4 +-- 5 files changed, 59 insertions(+), 24 deletions(-) diff --git a/docs/concepts/vendors.md b/docs/concepts/vendors.md index d74468b..6e3b22e 100644 --- a/docs/concepts/vendors.md +++ b/docs/concepts/vendors.md @@ -66,7 +66,7 @@ Used with `agent.with_tts()`. Each TTS vendor produces audio at a specific sampl | `FishAudioTTS` | Fish Audio | `key`, `reference_id`, `backend` | — | | `MurfTTS` | Murf | `key`, `voice_id`, `model` | — | | `MiniMaxTTS` | MiniMax | `model` for supported Agora-managed global models; `key`, `group_id`, `model`, `voice_id`, `url` for BYOK | — | -| `GenericTTS` | Generic OpenAI-compatible TTS | `url`, `headers`, `model`, `voice` | Configurable | +| `GenericTTS` | Generic OpenAI-compatible TTS over HTTP(S) | `url` | Configurable | | `DeepgramTTS` | Deepgram | `api_key`, `model` | Configurable | | `SarvamTTS` | Sarvam | `api_key` | — | | `XaiTTS` | xAI | `api_key`, `language` | Configurable | @@ -84,7 +84,7 @@ Used with `agent.with_tts()` when routing to `Area.CN`. Use `MiniMaxCNTTS` and ` | `CosyVoiceTTS` | CosyVoice | `api_key`, `model`, `voice` | — | | `BytedanceDuplexTTS` | ByteDance Duplex | `app_id`, `token`, `resource_id`, `speaker` | — | | `StepFunTTS` | StepFun | `api_key`, `model`, `voice_id` | — | -| `GenericTTS` | Generic OpenAI-compatible TTS | `url`, `headers`, `model`, `voice` | Configurable | +| `GenericTTS` | Generic OpenAI-compatible TTS over HTTP(S) | `url` | Configurable | ```python diff --git a/docs/reference/vendors.md b/docs/reference/vendors.md index ba70f00..d6287d6 100644 --- a/docs/reference/vendors.md +++ b/docs/reference/vendors.md @@ -387,12 +387,14 @@ The SDK also includes named helpers for the remaining Agora-supported LLM provid ### `GenericTTS` +`GenericTTS` currently supports HTTP and HTTPS endpoints. WebSocket endpoints are rejected until a WebSocket-backed generic TTS implementation is available. AgentKit serializes the current HTTP implementation with the internal vendor value `generic_http`. + | Parameter | Type | Required | Default | Description | |---|---|---|---|---| -| `url` | `str` | Yes | — | Callback address of the generic TTS service | -| `headers` | `Dict[str, str]` | Yes | — | Custom headers to include in requests to the generic TTS service | -| `model` | `str` | Yes | — | TTS model name | -| `voice` | `str` | Yes | — | Voice name | +| `url` | `str` | Yes | — | HTTP(S) endpoint of the generic TTS service | +| `headers` | `Dict[str, str]` | No | `None` | Custom headers to include in requests to the generic TTS service | +| `model` | `str` | No | `None` | TTS model name | +| `voice` | `str` | No | `None` | Voice name | | `api_key` | `str` | No | `None` | API key for the generic TTS service | | `speed` | `float` | No | `None` | Speech rate | | `sample_rate` | `int` | No | `None` | Output audio sample rate in Hz | diff --git a/src/agora_agent/agentkit/vendors/tts.py b/src/agora_agent/agentkit/vendors/tts.py index 054b6cd..0168aca 100644 --- a/src/agora_agent/agentkit/vendors/tts.py +++ b/src/agora_agent/agentkit/vendors/tts.py @@ -1,6 +1,7 @@ from typing import Any, Dict, List, Optional +from urllib.parse import urlsplit -from pydantic import ConfigDict, Field, model_validator +from pydantic import ConfigDict, Field, field_validator, model_validator from .base import BaseTTS, CartesiaSampleRate, ElevenLabsSampleRate, GoogleTTSSampleRate, MicrosoftSampleRate from ..presets import MiniMaxPresetModels, OpenAITtsPresetModels @@ -63,9 +64,9 @@ class MicrosoftTTS(BaseTTS): def to_config(self) -> Dict[str, Any]: params: Dict[str, Any] = dict(self.additional_params or {}) params.update({ - "key": self.key, - "region": self.region, - "voice_name": self.voice_name, + "key": self.key, + "region": self.region, + "voice_name": self.voice_name, }) if self.sample_rate is not None: @@ -241,8 +242,8 @@ class DeepgramTTS(BaseTTS): def to_config(self) -> Dict[str, Any]: params: Dict[str, Any] = dict(self.additional_params or {}) params.update({ - "api_key": self.api_key, - "model": self.model, + "api_key": self.api_key, + "model": self.model, }) if self.base_url is not None: @@ -516,10 +517,10 @@ def to_config(self) -> Dict[str, Any]: class GenericTTS(BaseTTS): model_config = ConfigDict(extra="forbid") - url: str = Field(..., description="Callback address of the generic TTS service") - headers: Dict[str, str] = Field(..., description="Custom headers to include in requests to the generic TTS service") - model: str = Field(..., description="TTS model name") - voice: str = Field(..., description="Voice name") + url: str = Field(..., description="HTTP(S) endpoint of the generic TTS service") + headers: Optional[Dict[str, str]] = Field(default=None, description="Custom request headers") + model: Optional[str] = Field(default=None, description="TTS model name") + voice: Optional[str] = Field(default=None, description="Voice name") api_key: Optional[str] = Field(default=None, description="API key for the generic TTS service") speed: Optional[float] = Field(default=None, description="Speech rate") sample_rate: Optional[int] = Field(default=None, description="Output audio sample rate in Hz") @@ -528,12 +529,22 @@ class GenericTTS(BaseTTS): additional_params: Optional[Dict[str, Any]] = Field(default=None, description="Additional generic TTS params") skip_patterns: Optional[List[int]] = Field(default=None) + @field_validator("url") + @classmethod + def validate_url(cls, value: str) -> str: + parsed = urlsplit(value) + if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc: + raise ValueError("GenericTTS url must be a valid HTTP(S) endpoint") + return value + def to_config(self) -> Dict[str, Any]: params: Dict[str, Any] = dict(self.additional_params or {}) if self.api_key is not None: params["api_key"] = self.api_key - params["model"] = self.model - params["voice"] = self.voice + if self.model is not None: + params["model"] = self.model + if self.voice is not None: + params["voice"] = self.voice if self.speed is not None: params["speed"] = self.speed if self.sample_rate is not None: @@ -544,11 +555,12 @@ def to_config(self) -> Dict[str, Any]: params["instruction"] = self.instruction result: Dict[str, Any] = { - "vendor": "generic", + "vendor": "generic_http", "url": self.url, - "headers": self.headers, "params": params, } + if self.headers is not None: + result["headers"] = self.headers if self.skip_patterns is not None: result["skip_patterns"] = self.skip_patterns return result diff --git a/tests/custom/test_agentkit_vendors.py b/tests/custom/test_agentkit_vendors.py index e74f70a..fb1bdd0 100644 --- a/tests/custom/test_agentkit_vendors.py +++ b/tests/custom/test_agentkit_vendors.py @@ -42,7 +42,6 @@ def test_xai_grok_emits_params_even_when_empty(): assert XaiGrok(api_key="xai-key").to_config()["params"] == {} - def test_mllm_rejects_fields_not_in_core_contract(): with pytest.raises(ValidationError): OpenAIRealtime(api_key="openai-key", predefined_tools=["_publish_message"]) @@ -224,7 +223,7 @@ def test_generic_tts_serializes() -> None: ).to_config() assert config == { - "vendor": "generic", + "vendor": "generic_http", "url": "https://tts.example.com/v1/audio", "headers": {"Authorization": "Bearer token"}, "params": { @@ -239,6 +238,28 @@ def test_generic_tts_serializes() -> None: } +def test_generic_tts_only_requires_url() -> None: + assert GenericTTS(url="https://tts.example.com/v1/audio").to_config() == { + "vendor": "generic_http", + "url": "https://tts.example.com/v1/audio", + "params": {}, + } + + +@pytest.mark.parametrize( + "url", + [ + "ws://tts.example.com/v1/audio", + "wss://tts.example.com/v1/audio", + "not-a-url", + "https:///missing-host", + ], +) +def test_generic_tts_rejects_non_http_urls(url: str) -> None: + with pytest.raises(ValidationError, match="valid HTTP"): + GenericTTS(url=url) + + def test_xai_tts_serializes() -> None: config = XaiTTS( api_key="xai-tts-key", diff --git a/tests/custom/test_regional_vendors.py b/tests/custom/test_regional_vendors.py index 3821040..9a67642 100644 --- a/tests/custom/test_regional_vendors.py +++ b/tests/custom/test_regional_vendors.py @@ -168,8 +168,8 @@ def test_generic_tts_is_classified_as_shared_vendor() -> None: assert cn_agent.__class__.__name__ == "CNAgent" assert global_agent.__class__.__name__ == "GlobalAgent" - assert cn_agent.tts is not None and cn_agent.tts["vendor"] == "generic" - assert global_agent.tts is not None and global_agent.tts["vendor"] == "generic" + assert cn_agent.tts is not None and cn_agent.tts["vendor"] == "generic_http" + assert global_agent.tts is not None and global_agent.tts["vendor"] == "generic_http" def test_xai_asr_and_tts_are_classified_as_global_vendors() -> None: