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: 3 additions & 1 deletion docs/concepts/vendors.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ Used with `agent.with_tts()`. Each TTS vendor produces audio at a specific sampl
| `GoogleTTS` | Google Cloud | `key`, `voice_name` | — |
| `AmazonTTS` | Amazon Polly | `access_key`, `secret_key`, `region`, `voice_id`, `engine` | — |
| `HumeAITTS` | Hume AI | `key`, `voice_id`, `provider` | — |
| `RimeTTS` | Rime | `key`, `speaker`, `model_id` | — |
| `RimeTTS` | Rime | `model_id`; BYOK also requires `key` and `speaker`, managed requires `base_url` | — |
| `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 | — |
Expand All @@ -71,6 +71,8 @@ Used with `agent.with_tts()`. Each TTS vendor produces audio at a specific sampl
| `SarvamTTS` | Sarvam | `api_key` | — |
| `XaiTTS` | xAI | `api_key`, `language` | Configurable |

For `RimeTTS`, omitting `credential_mode` selects BYOK validation. Set `credential_mode=CredentialMode.MANAGED` to use managed credentials. See the [RimeTTS vendor reference](../reference/vendors.md#rimetts) for both configurations.

### CN TTS Vendors

Used with `agent.with_tts()` when routing to `Area.CN`. Use `MiniMaxCNTTS` and `MicrosoftCNTTS` for CN-specific implementations that differ from the global classes.
Expand Down
37 changes: 34 additions & 3 deletions docs/reference/vendors.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,14 +318,45 @@ The SDK also includes named helpers for the remaining Agora-supported LLM provid

### `RimeTTS`

`CredentialMode` is exported from `agora_agent` as the shared credential mode constants for provider integrations.

| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
| `key` | `str` | Yes | — | Rime API key |
| `speaker` | `str` | Yes | — | Speaker ID |
| `key` | `str` | BYOK: Yes; Managed: No | `None` | Rime API key |
| `speaker` | `str` | BYOK: Yes; Managed: No | `None` | Speaker ID |
| `model_id` | `str` | Yes | — | Model ID |
| `base_url` | `str` | No | `None` | WebSocket URL |
| `base_url` | `str` | Managed: Yes; BYOK: No | `None` | WebSocket URL |
| `credential_mode` | `CredentialMode` | No | `None` | Shared credential mode (`"managed"` or `"byok"`); omission uses BYOK validation |
| `skip_patterns` | `List[int]` | No | `None` | Skip patterns |

When `credential_mode` is omitted or set to `"byok"`, provide `key`, `speaker`, and `model_id`:

```python
import os

from agora_agent import RimeTTS

tts = RimeTTS(
key=os.environ["RIME_API_KEY"],
speaker="your-speaker-id",
model_id="mist",
)
```

For Agora-managed credentials, set `credential_mode=CredentialMode.MANAGED` and provide `base_url` and `model_id`:

```python
from agora_agent import CredentialMode, RimeTTS

tts = RimeTTS(
credential_mode=CredentialMode.MANAGED,
base_url="wss://your-rime-endpoint.example.com",
model_id="mist",
)
```

AgentKit serializes `credential_mode` at the top level of the Rime TTS configuration, alongside `vendor` and `params`. It omits the field when the option is not provided, preserving the existing BYOK request shape.

### `FishAudioTTS`

| Parameter | Type | Required | Default | Description |
Expand Down
2 changes: 2 additions & 0 deletions src/agora_agent/agentkit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
validate_tts_sample_rate,
)
from .constants import (
CredentialMode,
DataChannel,
AudioScenario,
SilenceActionValues,
Expand Down Expand Up @@ -284,6 +285,7 @@
"MllmTurnDetectionMode",
"Labels",
# Type-safe constants
"CredentialMode",
"DataChannel",
"AudioScenario",
"SilenceActionValues",
Expand Down
4 changes: 4 additions & 0 deletions src/agora_agent/agentkit/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
Use these instead of raw strings to avoid typos and get IDE autocomplete.
"""

class CredentialMode:
MANAGED = "managed"
BYOK = "byok"

# Data channel: "rtm" | "datastream"
class DataChannel:
RTM = "rtm"
Expand Down
37 changes: 28 additions & 9 deletions src/agora_agent/agentkit/vendors/tts.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Literal, Optional
from urllib.parse import urlsplit

from pydantic import ConfigDict, Field, field_validator, model_validator

from .base import BaseTTS, CartesiaSampleRate, ElevenLabsSampleRate, GoogleTTSSampleRate, MicrosoftSampleRate
from ..constants import CredentialMode
from ..presets import MiniMaxPresetModels, OpenAITtsPresetModels


Expand Down Expand Up @@ -297,26 +298,44 @@ def to_config(self) -> Dict[str, Any]:
class RimeTTS(BaseTTS):
model_config = ConfigDict(extra="forbid")

key: str = Field(..., description="Rime API key")
speaker: str = Field(..., description="Speaker ID")
model_id: str = Field(..., description="Model ID")
key: Optional[str] = Field(default=None, description="Rime API key")
speaker: Optional[str] = Field(default=None, description="Speaker ID")
model_id: Optional[str] = Field(default=None, description="Model ID")
base_url: Optional[str] = Field(default=None, description="WebSocket URL")
credential_mode: Optional[Literal["managed", "byok"]] = Field(default=None, description="Credential mode")
skip_patterns: Optional[List[int]] = Field(default=None)

@model_validator(mode="after")
def _validate_credential_mode(self) -> "RimeTTS":
required: Dict[str, Optional[str]]
if self.credential_mode == CredentialMode.MANAGED:
required = {"base_url": self.base_url, "model_id": self.model_id}
mode = "credential_mode='managed'"
else:
required = {"key": self.key, "speaker": self.speaker, "model_id": self.model_id}
mode = "credential_mode='byok' or when credential_mode is omitted"

missing = [name for name, value in required.items() if not value]
if missing:
raise ValueError(f"RimeTTS requires {', '.join(missing)} for {mode}")
return self

@property
def sample_rate(self) -> Optional[int]:
return None

def to_config(self) -> Dict[str, Any]:
params: Dict[str, Any] = {
"api_key": self.key,
"speaker": self.speaker,
"modelId": self.model_id,
}
params: Dict[str, Any] = {"modelId": self.model_id}
if self.key is not None:
params["api_key"] = self.key
if self.speaker is not None:
params["speaker"] = self.speaker
if self.base_url is not None:
params["base_url"] = self.base_url

result: Dict[str, Any] = {"vendor": "rime", "params": params}
if self.credential_mode is not None:
result["credential_mode"] = self.credential_mode
if self.skip_patterns is not None:
result["skip_patterns"] = self.skip_patterns
return result
Expand Down
20 changes: 20 additions & 0 deletions tests/custom/test_request_body.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
AssemblyAISTT,
AzureOpenAI,
CartesiaTTS,
CredentialMode,
CustomLLM,
DeepgramSTT,
DeepgramTTS,
Expand Down Expand Up @@ -1179,6 +1180,25 @@ def test_start_session_rime_tts_preserves_wire_aliases() -> None:
assert "model_id" not in params


def test_start_session_rime_tts_managed_credentials() -> None:
agent = full_agent_with_tts(
RimeTTS(
credential_mode=CredentialMode.MANAGED,
base_url="wss://users.rime.ai/ws",
model_id="mist",
)
)

call = start_session(agent)
properties = dump_wire(call["properties"])

assert properties["tts"]["credential_mode"] == "managed"
assert properties["tts"]["params"] == {
"modelId": "mist",
"base_url": "wss://users.rime.ai/ws",
}


def test_start_session_murf_tts_preserves_wire_aliases() -> None:
agent = full_agent_with_tts(MurfTTS(key="murf-key", voice_id="Ariana"))

Expand Down
7 changes: 7 additions & 0 deletions tests/custom/test_root_exports.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ def test_root_exports_match_agentkit_for_common_symbols() -> None:
"SpatiusAvatar",
"AgentPresets",
"generate_rtc_token",
"CredentialMode",
"DataChannel",
):
assert getattr(agora_agent, name) is getattr(agentkit, name)
Expand All @@ -29,6 +30,11 @@ def test_root_exports_fern_client_symbols() -> None:
assert agora_agent.AsyncAgora is not None


def test_credential_mode_constants() -> None:
assert agora_agent.CredentialMode.MANAGED == "managed"
assert agora_agent.CredentialMode.BYOK == "byok"


def test_unknown_root_export_raises_attribute_error() -> None:
with pytest.raises(AttributeError):
_ = agora_agent.NotARealExportName
Expand All @@ -43,6 +49,7 @@ def test_dir_includes_agentkit_vendor_exports() -> None:


def test_all_includes_agentkit_vendor_exports() -> None:
assert "CredentialMode" in agora_agent.__all__
assert "DeepgramSTT" in agora_agent.__all__
assert "MiniMaxCNTTS" in agora_agent.__all__
assert "TencentSTT" in agora_agent.__all__
Expand Down
91 changes: 90 additions & 1 deletion tests/custom/test_tts_vendors.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import pytest

from agora_agent import AmazonTTS, CartesiaTTS, DeepgramTTS, ElevenLabsTTS, FishAudioTTS, GoogleTTS, HumeAITTS, MicrosoftTTS, MiniMaxTTS, MurfTTS, OpenAITTS, RimeTTS, SarvamTTS
from agora_agent import AmazonTTS, CartesiaTTS, CredentialMode, DeepgramTTS, ElevenLabsTTS, FishAudioTTS, GoogleTTS, HumeAITTS, MicrosoftTTS, MiniMaxTTS, MurfTTS, OpenAITTS, RimeTTS, SarvamTTS
from agora_agent.agents.types.start_agents_request_properties import StartAgentsRequestProperties
from agora_agent.core.jsonable_encoder import jsonable_encoder
from agora_agent.core.pydantic_utilities import parse_obj_as
Expand Down Expand Up @@ -170,6 +170,78 @@ def test_tts_managed_mode_validation_matches_core_shapes() -> None:
)


def test_rime_tts_managed_credential_mode_params() -> None:
config = RimeTTS(
credential_mode=CredentialMode.MANAGED,
base_url="wss://users.rime.ai/ws",
model_id="mist",
).to_config()
assert config == {
"vendor": "rime",
"credential_mode": "managed",
"params": {
"modelId": "mist",
"base_url": "wss://users.rime.ai/ws",
},
}


@pytest.mark.parametrize("credential_mode", [None, CredentialMode.BYOK])
def test_rime_tts_byok_credential_mode_params(credential_mode) -> None:
config = RimeTTS(
credential_mode=credential_mode,
key="rime-key",
speaker="speaker",
model_id="mist",
).to_config()
expected = {
"modelId": "mist",
"api_key": "rime-key",
"speaker": "speaker",
}
assert config["params"] == expected
if credential_mode is None:
assert "credential_mode" not in config
else:
assert config["credential_mode"] == credential_mode


@pytest.mark.parametrize(
("kwargs", "missing"),
[
({"model_id": "mist"}, "base_url"),
({"base_url": "wss://users.rime.ai/ws"}, "model_id"),
({}, "base_url, model_id"),
],
)
def test_rime_tts_managed_mode_requires_base_url_and_model_id(kwargs: dict, missing: str) -> None:
with pytest.raises(Exception, match=rf"RimeTTS requires {missing} for credential_mode='managed'"):
RimeTTS(credential_mode=CredentialMode.MANAGED, **kwargs)


@pytest.mark.parametrize("credential_mode", [None, CredentialMode.BYOK])
@pytest.mark.parametrize(
("kwargs", "missing"),
[
({"speaker": "speaker", "model_id": "mist"}, "key"),
({"key": "rime-key", "model_id": "mist"}, "speaker"),
({"key": "rime-key", "speaker": "speaker"}, "model_id"),
],
)
def test_rime_tts_byok_mode_requires_key_speaker_and_model_id(
credential_mode,
kwargs: dict,
missing: str,
) -> None:
with pytest.raises(Exception, match=rf"RimeTTS requires {missing}"):
RimeTTS(credential_mode=credential_mode, **kwargs)


def test_rime_tts_rejects_unknown_credential_mode() -> None:
with pytest.raises(Exception, match="credential_mode"):
RimeTTS(credential_mode="unknown", base_url="wss://users.rime.ai/ws", model_id="mist") # type: ignore[arg-type]


def test_tts_wire_serialization_applies_fern_aliases() -> None:
"""Verify alias-sensitive TTS params keep the exact provider wire keys."""
_BASE = dict(channel="ch", token="tok", agent_rtc_uid="1", remote_rtc_uids=["100"])
Expand All @@ -192,6 +264,23 @@ def test_tts_wire_serialization_applies_fern_aliases() -> None:
assert "modelId" in rime_params, f"wire missing modelId, got: {list(rime_params)}"
assert "model_id" not in rime_params

managed_rime_config = RimeTTS(
credential_mode=CredentialMode.MANAGED,
base_url="wss://users.rime.ai/ws",
model_id="mist",
).to_config()
managed_rime_wire = jsonable_encoder(
parse_obj_as(StartAgentsRequestProperties, {**_BASE, "tts": managed_rime_config})
)
assert managed_rime_wire["tts"] == {
"vendor": "rime",
"credential_mode": "managed",
"params": {
"modelId": "mist",
"base_url": "wss://users.rime.ai/ws",
},
}

murf_config = MurfTTS(key="murf-key", voice_id="Ariana").to_config()
assert "voiceId" in murf_config["params"]
murf_wire = jsonable_encoder(parse_obj_as(StartAgentsRequestProperties, {**_BASE, "tts": murf_config}))
Expand Down
Loading