Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/concepts/vendors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 |

<!-- snippet: executable -->
```python
Expand Down
10 changes: 6 additions & 4 deletions docs/reference/vendors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
40 changes: 26 additions & 14 deletions src/agora_agent/agentkit/vendors/tts.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Expand All @@ -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:
Expand All @@ -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
Expand Down
25 changes: 23 additions & 2 deletions tests/custom/test_agentkit_vendors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down Expand Up @@ -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": {
Expand All @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions tests/custom/test_regional_vendors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading