Skip to content

Commit 7c93359

Browse files
authored
Merge pull request #58 from seymourtang/feat/rime-provision-key
feat:enhance RimeTTS configuration with credential mode support
2 parents 6c5c2f8 + 8fb11f0 commit 7c93359

8 files changed

Lines changed: 188 additions & 14 deletions

File tree

docs/concepts/vendors.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ Used with `agent.with_tts()`. Each TTS vendor produces audio at a specific sampl
6262
| `GoogleTTS` | Google Cloud | `key`, `voice_name` ||
6363
| `AmazonTTS` | Amazon Polly | `access_key`, `secret_key`, `region`, `voice_id`, `engine` ||
6464
| `HumeAITTS` | Hume AI | `key`, `voice_id`, `provider` ||
65-
| `RimeTTS` | Rime | `key`, `speaker`, `model_id` ||
65+
| `RimeTTS` | Rime | `model_id`; BYOK also requires `key` and `speaker`, managed requires `base_url` ||
6666
| `FishAudioTTS` | Fish Audio | `key`, `reference_id`, `backend` ||
6767
| `MurfTTS` | Murf | `key`, `voice_id`, `model` ||
6868
| `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
7171
| `SarvamTTS` | Sarvam | `api_key` ||
7272
| `XaiTTS` | xAI | `api_key`, `language` | Configurable |
7373

74+
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.
75+
7476
### CN TTS Vendors
7577

7678
Used with `agent.with_tts()` when routing to `Area.CN`. Use `MiniMaxCNTTS` and `MicrosoftCNTTS` for CN-specific implementations that differ from the global classes.

docs/reference/vendors.md

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -318,14 +318,45 @@ The SDK also includes named helpers for the remaining Agora-supported LLM provid
318318

319319
### `RimeTTS`
320320

321+
`CredentialMode` is exported from `agora_agent` as the shared credential mode constants for provider integrations.
322+
321323
| Parameter | Type | Required | Default | Description |
322324
|---|---|---|---|---|
323-
| `key` | `str` | Yes | | Rime API key |
324-
| `speaker` | `str` | Yes | | Speaker ID |
325+
| `key` | `str` | BYOK: Yes; Managed: No | `None` | Rime API key |
326+
| `speaker` | `str` | BYOK: Yes; Managed: No | `None` | Speaker ID |
325327
| `model_id` | `str` | Yes || Model ID |
326-
| `base_url` | `str` | No | `None` | WebSocket URL |
328+
| `base_url` | `str` | Managed: Yes; BYOK: No | `None` | WebSocket URL |
329+
| `credential_mode` | `CredentialMode` | No | `None` | Shared credential mode (`"managed"` or `"byok"`); omission uses BYOK validation |
327330
| `skip_patterns` | `List[int]` | No | `None` | Skip patterns |
328331

332+
When `credential_mode` is omitted or set to `"byok"`, provide `key`, `speaker`, and `model_id`:
333+
334+
```python
335+
import os
336+
337+
from agora_agent import RimeTTS
338+
339+
tts = RimeTTS(
340+
key=os.environ["RIME_API_KEY"],
341+
speaker="your-speaker-id",
342+
model_id="mist",
343+
)
344+
```
345+
346+
For Agora-managed credentials, set `credential_mode=CredentialMode.MANAGED` and provide `base_url` and `model_id`:
347+
348+
```python
349+
from agora_agent import CredentialMode, RimeTTS
350+
351+
tts = RimeTTS(
352+
credential_mode=CredentialMode.MANAGED,
353+
base_url="wss://your-rime-endpoint.example.com",
354+
model_id="mist",
355+
)
356+
```
357+
358+
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.
359+
329360
### `FishAudioTTS`
330361

331362
| Parameter | Type | Required | Default | Description |

src/agora_agent/agentkit/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@
9797
validate_tts_sample_rate,
9898
)
9999
from .constants import (
100+
CredentialMode,
100101
DataChannel,
101102
AudioScenario,
102103
SilenceActionValues,
@@ -284,6 +285,7 @@
284285
"MllmTurnDetectionMode",
285286
"Labels",
286287
# Type-safe constants
288+
"CredentialMode",
287289
"DataChannel",
288290
"AudioScenario",
289291
"SilenceActionValues",

src/agora_agent/agentkit/constants.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
Use these instead of raw strings to avoid typos and get IDE autocomplete.
44
"""
55

6+
class CredentialMode:
7+
MANAGED = "managed"
8+
BYOK = "byok"
9+
610
# Data channel: "rtm" | "datastream"
711
class DataChannel:
812
RTM = "rtm"

src/agora_agent/agentkit/vendors/tts.py

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
from typing import Any, Dict, List, Optional
1+
from typing import Any, Dict, List, Literal, Optional
22
from urllib.parse import urlsplit
33

44
from pydantic import ConfigDict, Field, field_validator, model_validator
55

66
from .base import BaseTTS, CartesiaSampleRate, ElevenLabsSampleRate, GoogleTTSSampleRate, MicrosoftSampleRate
7+
from ..constants import CredentialMode
78
from ..presets import MiniMaxPresetModels, OpenAITtsPresetModels
89

910

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

300-
key: str = Field(..., description="Rime API key")
301-
speaker: str = Field(..., description="Speaker ID")
302-
model_id: str = Field(..., description="Model ID")
301+
key: Optional[str] = Field(default=None, description="Rime API key")
302+
speaker: Optional[str] = Field(default=None, description="Speaker ID")
303+
model_id: Optional[str] = Field(default=None, description="Model ID")
303304
base_url: Optional[str] = Field(default=None, description="WebSocket URL")
305+
credential_mode: Optional[Literal["managed", "byok"]] = Field(default=None, description="Credential mode")
304306
skip_patterns: Optional[List[int]] = Field(default=None)
305307

308+
@model_validator(mode="after")
309+
def _validate_credential_mode(self) -> "RimeTTS":
310+
required: Dict[str, Optional[str]]
311+
if self.credential_mode == CredentialMode.MANAGED:
312+
required = {"base_url": self.base_url, "model_id": self.model_id}
313+
mode = "credential_mode='managed'"
314+
else:
315+
required = {"key": self.key, "speaker": self.speaker, "model_id": self.model_id}
316+
mode = "credential_mode='byok' or when credential_mode is omitted"
317+
318+
missing = [name for name, value in required.items() if not value]
319+
if missing:
320+
raise ValueError(f"RimeTTS requires {', '.join(missing)} for {mode}")
321+
return self
322+
306323
@property
307324
def sample_rate(self) -> Optional[int]:
308325
return None
309326

310327
def to_config(self) -> Dict[str, Any]:
311-
params: Dict[str, Any] = {
312-
"api_key": self.key,
313-
"speaker": self.speaker,
314-
"modelId": self.model_id,
315-
}
328+
params: Dict[str, Any] = {"modelId": self.model_id}
329+
if self.key is not None:
330+
params["api_key"] = self.key
331+
if self.speaker is not None:
332+
params["speaker"] = self.speaker
316333
if self.base_url is not None:
317334
params["base_url"] = self.base_url
318335

319336
result: Dict[str, Any] = {"vendor": "rime", "params": params}
337+
if self.credential_mode is not None:
338+
result["credential_mode"] = self.credential_mode
320339
if self.skip_patterns is not None:
321340
result["skip_patterns"] = self.skip_patterns
322341
return result

tests/custom/test_request_body.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
AssemblyAISTT,
3131
AzureOpenAI,
3232
CartesiaTTS,
33+
CredentialMode,
3334
CustomLLM,
3435
DeepgramSTT,
3536
DeepgramTTS,
@@ -1179,6 +1180,25 @@ def test_start_session_rime_tts_preserves_wire_aliases() -> None:
11791180
assert "model_id" not in params
11801181

11811182

1183+
def test_start_session_rime_tts_managed_credentials() -> None:
1184+
agent = full_agent_with_tts(
1185+
RimeTTS(
1186+
credential_mode=CredentialMode.MANAGED,
1187+
base_url="wss://users.rime.ai/ws",
1188+
model_id="mist",
1189+
)
1190+
)
1191+
1192+
call = start_session(agent)
1193+
properties = dump_wire(call["properties"])
1194+
1195+
assert properties["tts"]["credential_mode"] == "managed"
1196+
assert properties["tts"]["params"] == {
1197+
"modelId": "mist",
1198+
"base_url": "wss://users.rime.ai/ws",
1199+
}
1200+
1201+
11821202
def test_start_session_murf_tts_preserves_wire_aliases() -> None:
11831203
agent = full_agent_with_tts(MurfTTS(key="murf-key", voice_id="Ariana"))
11841204

tests/custom/test_root_exports.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ def test_root_exports_match_agentkit_for_common_symbols() -> None:
1717
"SpatiusAvatar",
1818
"AgentPresets",
1919
"generate_rtc_token",
20+
"CredentialMode",
2021
"DataChannel",
2122
):
2223
assert getattr(agora_agent, name) is getattr(agentkit, name)
@@ -29,6 +30,11 @@ def test_root_exports_fern_client_symbols() -> None:
2930
assert agora_agent.AsyncAgora is not None
3031

3132

33+
def test_credential_mode_constants() -> None:
34+
assert agora_agent.CredentialMode.MANAGED == "managed"
35+
assert agora_agent.CredentialMode.BYOK == "byok"
36+
37+
3238
def test_unknown_root_export_raises_attribute_error() -> None:
3339
with pytest.raises(AttributeError):
3440
_ = agora_agent.NotARealExportName
@@ -43,6 +49,7 @@ def test_dir_includes_agentkit_vendor_exports() -> None:
4349

4450

4551
def test_all_includes_agentkit_vendor_exports() -> None:
52+
assert "CredentialMode" in agora_agent.__all__
4653
assert "DeepgramSTT" in agora_agent.__all__
4754
assert "MiniMaxCNTTS" in agora_agent.__all__
4855
assert "TencentSTT" in agora_agent.__all__

tests/custom/test_tts_vendors.py

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import pytest
22

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

172172

173+
def test_rime_tts_managed_credential_mode_params() -> None:
174+
config = RimeTTS(
175+
credential_mode=CredentialMode.MANAGED,
176+
base_url="wss://users.rime.ai/ws",
177+
model_id="mist",
178+
).to_config()
179+
assert config == {
180+
"vendor": "rime",
181+
"credential_mode": "managed",
182+
"params": {
183+
"modelId": "mist",
184+
"base_url": "wss://users.rime.ai/ws",
185+
},
186+
}
187+
188+
189+
@pytest.mark.parametrize("credential_mode", [None, CredentialMode.BYOK])
190+
def test_rime_tts_byok_credential_mode_params(credential_mode) -> None:
191+
config = RimeTTS(
192+
credential_mode=credential_mode,
193+
key="rime-key",
194+
speaker="speaker",
195+
model_id="mist",
196+
).to_config()
197+
expected = {
198+
"modelId": "mist",
199+
"api_key": "rime-key",
200+
"speaker": "speaker",
201+
}
202+
assert config["params"] == expected
203+
if credential_mode is None:
204+
assert "credential_mode" not in config
205+
else:
206+
assert config["credential_mode"] == credential_mode
207+
208+
209+
@pytest.mark.parametrize(
210+
("kwargs", "missing"),
211+
[
212+
({"model_id": "mist"}, "base_url"),
213+
({"base_url": "wss://users.rime.ai/ws"}, "model_id"),
214+
({}, "base_url, model_id"),
215+
],
216+
)
217+
def test_rime_tts_managed_mode_requires_base_url_and_model_id(kwargs: dict, missing: str) -> None:
218+
with pytest.raises(Exception, match=rf"RimeTTS requires {missing} for credential_mode='managed'"):
219+
RimeTTS(credential_mode=CredentialMode.MANAGED, **kwargs)
220+
221+
222+
@pytest.mark.parametrize("credential_mode", [None, CredentialMode.BYOK])
223+
@pytest.mark.parametrize(
224+
("kwargs", "missing"),
225+
[
226+
({"speaker": "speaker", "model_id": "mist"}, "key"),
227+
({"key": "rime-key", "model_id": "mist"}, "speaker"),
228+
({"key": "rime-key", "speaker": "speaker"}, "model_id"),
229+
],
230+
)
231+
def test_rime_tts_byok_mode_requires_key_speaker_and_model_id(
232+
credential_mode,
233+
kwargs: dict,
234+
missing: str,
235+
) -> None:
236+
with pytest.raises(Exception, match=rf"RimeTTS requires {missing}"):
237+
RimeTTS(credential_mode=credential_mode, **kwargs)
238+
239+
240+
def test_rime_tts_rejects_unknown_credential_mode() -> None:
241+
with pytest.raises(Exception, match="credential_mode"):
242+
RimeTTS(credential_mode="unknown", base_url="wss://users.rime.ai/ws", model_id="mist") # type: ignore[arg-type]
243+
244+
173245
def test_tts_wire_serialization_applies_fern_aliases() -> None:
174246
"""Verify alias-sensitive TTS params keep the exact provider wire keys."""
175247
_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:
192264
assert "modelId" in rime_params, f"wire missing modelId, got: {list(rime_params)}"
193265
assert "model_id" not in rime_params
194266

267+
managed_rime_config = RimeTTS(
268+
credential_mode=CredentialMode.MANAGED,
269+
base_url="wss://users.rime.ai/ws",
270+
model_id="mist",
271+
).to_config()
272+
managed_rime_wire = jsonable_encoder(
273+
parse_obj_as(StartAgentsRequestProperties, {**_BASE, "tts": managed_rime_config})
274+
)
275+
assert managed_rime_wire["tts"] == {
276+
"vendor": "rime",
277+
"credential_mode": "managed",
278+
"params": {
279+
"modelId": "mist",
280+
"base_url": "wss://users.rime.ai/ws",
281+
},
282+
}
283+
195284
murf_config = MurfTTS(key="murf-key", voice_id="Ariana").to_config()
196285
assert "voiceId" in murf_config["params"]
197286
murf_wire = jsonable_encoder(parse_obj_as(StartAgentsRequestProperties, {**_BASE, "tts": murf_config}))

0 commit comments

Comments
 (0)