diff --git a/docs/concepts/vendors.md b/docs/concepts/vendors.md index 6e3b22e..9ece26f 100644 --- a/docs/concepts/vendors.md +++ b/docs/concepts/vendors.md @@ -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 | — | @@ -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. diff --git a/docs/reference/vendors.md b/docs/reference/vendors.md index d6287d6..73c17a8 100644 --- a/docs/reference/vendors.md +++ b/docs/reference/vendors.md @@ -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 | diff --git a/src/agora_agent/agentkit/__init__.py b/src/agora_agent/agentkit/__init__.py index 4ec59a6..3ab9d95 100644 --- a/src/agora_agent/agentkit/__init__.py +++ b/src/agora_agent/agentkit/__init__.py @@ -97,6 +97,7 @@ validate_tts_sample_rate, ) from .constants import ( + CredentialMode, DataChannel, AudioScenario, SilenceActionValues, @@ -284,6 +285,7 @@ "MllmTurnDetectionMode", "Labels", # Type-safe constants + "CredentialMode", "DataChannel", "AudioScenario", "SilenceActionValues", diff --git a/src/agora_agent/agentkit/constants.py b/src/agora_agent/agentkit/constants.py index eb186da..e8aa311 100644 --- a/src/agora_agent/agentkit/constants.py +++ b/src/agora_agent/agentkit/constants.py @@ -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" diff --git a/src/agora_agent/agentkit/vendors/tts.py b/src/agora_agent/agentkit/vendors/tts.py index 0168aca..3a0c256 100644 --- a/src/agora_agent/agentkit/vendors/tts.py +++ b/src/agora_agent/agentkit/vendors/tts.py @@ -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 @@ -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 diff --git a/tests/custom/test_request_body.py b/tests/custom/test_request_body.py index 5149244..f662f84 100644 --- a/tests/custom/test_request_body.py +++ b/tests/custom/test_request_body.py @@ -30,6 +30,7 @@ AssemblyAISTT, AzureOpenAI, CartesiaTTS, + CredentialMode, CustomLLM, DeepgramSTT, DeepgramTTS, @@ -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")) diff --git a/tests/custom/test_root_exports.py b/tests/custom/test_root_exports.py index 16b730a..2cc3f5f 100644 --- a/tests/custom/test_root_exports.py +++ b/tests/custom/test_root_exports.py @@ -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) @@ -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 @@ -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__ diff --git a/tests/custom/test_tts_vendors.py b/tests/custom/test_tts_vendors.py index 1ed76ae..ce99753 100644 --- a/tests/custom/test_tts_vendors.py +++ b/tests/custom/test_tts_vendors.py @@ -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 @@ -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"]) @@ -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}))