From d0d653a0a6ca69be120bd75d9845fce6a677a5a1 Mon Sep 17 00:00:00 2001 From: Jay Zhang Date: Fri, 13 Nov 2026 00:20:55 +0000 Subject: [PATCH 1/3] fix(openai-tts): prefer params url over top-level url --- .../extension/openai_tts2_python/README.md | 17 +- .../extension/openai_tts2_python/config.py | 17 +- .../openai_tts2_python/tests/test_params.py | 164 ++++++++++++++++-- 3 files changed, 178 insertions(+), 20 deletions(-) diff --git a/ai_agents/agents/ten_packages/extension/openai_tts2_python/README.md b/ai_agents/agents/ten_packages/extension/openai_tts2_python/README.md index a220618ff7..4598620d93 100644 --- a/ai_agents/agents/ten_packages/extension/openai_tts2_python/README.md +++ b/ai_agents/agents/ten_packages/extension/openai_tts2_python/README.md @@ -15,19 +15,34 @@ OpenAI TTS Extension for TEN Framework - provides text-to-speech synthesis using ### Top-level Properties +- `url` (string, optional): Complete endpoint URL used when `params.url` is missing, blank, or not a string - `dump` (bool): Enable audio dumping for debugging (default: false) - `dump_path` (string): Path to save dumped audio files (default: extension directory + "openai_tts_in.pcm") ### TTS Parameters (under `params`) -All OpenAI TTS-specific settings are configured within the `params` object: +Configure OpenAI TTS settings in the `params` object: +- `url` (string, optional): Complete endpoint URL, with priority over the top-level `url` +- `base_url` (string, optional): Base URL used when neither endpoint URL is set (default: `https://api.openai.com/v1`) - `api_key` (string, required): OpenAI API key - `model` (string, required): TTS model to use (e.g., "gpt-4o-mini-tts") - `voice` (string, required): Voice identifier (e.g., "coral", "alloy", "echo", "fable", "onyx", "nova", "shimmer") - `speed` (float, optional): Speech speed (0.25 to 4.0, default: 1.0) - `instructions` (string, optional): Additional instructions for the TTS model +### Endpoint Selection + +The endpoint precedence is: + +1. `params.url` +2. Top-level `url` +3. `params.base_url` without trailing slashes, plus `/audio/speech` +4. `https://api.openai.com/v1/audio/speech` + +An empty, whitespace-only, or non-string `params.url` falls back to the top-level +`url`. The extension removes `url` and `base_url` from the synthesis request body. + ### Example Configuration ```json diff --git a/ai_agents/agents/ten_packages/extension/openai_tts2_python/config.py b/ai_agents/agents/ten_packages/extension/openai_tts2_python/config.py index d908cc6d21..d6d4608d10 100644 --- a/ai_agents/agents/ten_packages/extension/openai_tts2_python/config.py +++ b/ai_agents/agents/ten_packages/extension/openai_tts2_python/config.py @@ -31,7 +31,7 @@ class OpenAITTSConfig(AsyncTTS2HttpConfig): ) url: str | None = Field( default=None, - description="Direct endpoint URL (takes precedence over base_url)", + description="Fallback endpoint URL after params.url", ) headers: dict[str, Any] = Field( default_factory=dict, description="OpenAI TTS headers" @@ -68,14 +68,13 @@ def update_params(self) -> None: base_url = self.params.pop( # pylint: disable=no-member "base_url", "https://api.openai.com/v1" ) - if not self.url: - if isinstance(param_url, str) and param_url.strip(): - self.url = param_url - else: - if not isinstance(base_url, str) or not base_url.strip(): - base_url = "https://api.openai.com/v1" - base_url = base_url.rstrip("/") - self.url = f"{base_url}/audio/speech" + if isinstance(param_url, str) and param_url.strip(): + self.url = param_url + elif not self.url: + if not isinstance(base_url, str) or not base_url.strip(): + base_url = "https://api.openai.com/v1" + base_url = base_url.rstrip("/") + self.url = f"{base_url}/audio/speech" def to_str(self, sensitive_handling: bool = True) -> str: """Convert config to string with optional sensitive data handling.""" diff --git a/ai_agents/agents/ten_packages/extension/openai_tts2_python/tests/test_params.py b/ai_agents/agents/ten_packages/extension/openai_tts2_python/tests/test_params.py index fdc2fdaa92..b00b9d8412 100644 --- a/ai_agents/agents/ten_packages/extension/openai_tts2_python/tests/test_params.py +++ b/ai_agents/agents/ten_packages/extension/openai_tts2_python/tests/test_params.py @@ -1,6 +1,10 @@ +import asyncio import sys from pathlib import Path +import httpx +import pytest + # Add project root to sys.path to allow running tests from this directory # The project root is 6 levels up from the parent directory of this file. project_root = str(Path(__file__).resolve().parents[6]) @@ -251,6 +255,147 @@ def test_invalid_param_values_use_safe_defaults(): assert "base_url" not in config.params +@pytest.mark.parametrize( + "properties,expected_url", + [ + pytest.param( + { + "url": "https://top-level.example/tts", + "params": { + "url": "https://params.example/tts", + "base_url": "https://base.example/v1", + }, + }, + "https://params.example/tts", + id="params-url-over-top-level-and-base-url", + ), + pytest.param( + { + "url": "https://top-level.example/tts", + "params": {"url": "https://params.example/tts"}, + }, + "https://params.example/tts", + id="params-url-over-top-level-url", + ), + pytest.param( + { + "params": { + "url": "https://params.example/tts", + "base_url": "https://base.example/v1", + } + }, + "https://params.example/tts", + id="params-url-over-base-url", + ), + pytest.param( + { + "url": "https://top-level.example/tts", + "params": {"base_url": "https://base.example/v1"}, + }, + "https://top-level.example/tts", + id="top-level-url-over-base-url", + ), + pytest.param( + {"url": "https://top-level.example/tts"}, + "https://top-level.example/tts", + id="top-level-url-only", + ), + pytest.param( + {"params": {"base_url": "https://base.example/v1/"}}, + "https://base.example/v1/audio/speech", + id="base-url-with-trailing-slash", + ), + pytest.param( + {}, + "https://api.openai.com/v1/audio/speech", + id="default-url", + ), + *[ + pytest.param( + { + "url": "https://top-level.example/tts", + "params": { + "url": value, + "base_url": "https://base.example/v1", + }, + }, + "https://top-level.example/tts", + id=f"invalid-params-url-{name}", + ) + for name, value in ( + ("empty", ""), + ("whitespace", " \t "), + ("null", None), + ("number", 8080), + ) + ], + pytest.param( + { + "url": "", + "params": {"url": "https://params.example/tts"}, + }, + "https://params.example/tts", + id="empty-top-level-url", + ), + ], +) +def test_endpoint_url_precedence(properties, expected_url): + """Send synthesis to the selected endpoint without endpoint-only params.""" + from openai_tts2_python.config import OpenAITTSConfig + from openai_tts2_python.openai_tts import OpenAITTSClient + from ten_ai_base.struct import TTS2HttpResponseEventType + + requests = [] + audio = b"\x01\x00" * 160 + + def handle_request(request): + requests.append(request) + return httpx.Response(200, content=audio) + + async def synthesize(): + config = OpenAITTSConfig( + **{ + **properties, + "params": { + "api_key": "test_api_key", + **properties.get("params", {}), + }, + } + ) + config.update_params() + config.validate() + http_client = httpx.AsyncClient( + transport=httpx.MockTransport(handle_request) + ) + with patch( + "openai_tts2_python.openai_tts.AsyncClient", + return_value=http_client, + ): + client = OpenAITTSClient(config, MagicMock()) + try: + events = [event async for event in client.get("Hello", "url-test")] + finally: + await client.clean() + + assert events == [ + (audio, TTS2HttpResponseEventType.RESPONSE), + (None, TTS2HttpResponseEventType.END), + ] + + asyncio.run(synthesize()) + + assert len(requests) == 1 + request = requests[0] + assert request.method == "POST" + assert str(request.url) == expected_url + assert request.headers["Authorization"] == "Bearer test_api_key" + payload = json.loads(request.content) + assert payload["input"] == "Hello" + assert "url" not in payload + assert "base_url" not in payload + assert "api_key" not in payload + + def test_speed_rejects_bool_and_non_finite_values(): """Speed must be a finite numeric value.""" from openai_tts2_python.config import OpenAITTSConfig @@ -497,7 +642,7 @@ def test_url_in_params(MockLimits, MockTimeout, MockAsyncClient): Test cases: 1. When 'url' is in params (and not in top-level), it should be extracted to config.url 2. When 'url' is in params, it should be removed from params after extraction - 3. When both top-level 'url' and params 'url' exist, top-level takes precedence + 3. When both top-level 'url' and params 'url' exist, params takes precedence 4. When 'url' is in params but top-level url is None, params url should be used """ print("Starting test_url_in_params with mock...") @@ -573,8 +718,8 @@ def test_url_in_params(MockLimits, MockTimeout, MockAsyncClient): ), "Expected other param values to be preserved" print(" ✓ URL correctly removed from params, other params preserved") - # Test Case 3: Top-level 'url' takes precedence over params 'url' - print(" → Test Case 3: Top-level 'url' takes precedence over params 'url'") + # Test Case 3: Params 'url' takes precedence over top-level 'url' + print(" → Test Case 3: Params 'url' takes precedence over top-level 'url'") config_precedence = OpenAITTSConfig( url="https://top-level-url.com/tts", params={ @@ -584,15 +729,14 @@ def test_url_in_params(MockLimits, MockTimeout, MockAsyncClient): ) config_precedence.update_params() assert ( - config_precedence.url == "https://top-level-url.com/tts" - ), "Expected top-level url to take precedence" - # When top-level url exists, params url should not be extracted - # (it may or may not be removed, but top-level takes precedence) + config_precedence.url == "https://params-url.com/tts" + ), "Expected params url to take precedence" + assert "url" not in config_precedence.params client_precedence = OpenAITTSClient(config_precedence, mock_ten_env) assert ( - client_precedence.config.url == "https://top-level-url.com/tts" - ), "Expected client to use top-level url" - print(" ✓ Top-level url correctly takes precedence") + client_precedence.config.url == "https://params-url.com/tts" + ), "Expected client to use params url" + print(" ✓ Params url correctly takes precedence") # Test Case 4: 'url' in params when top-level url is None print(" → Test Case 4: 'url' in params when top-level url is None") From 4c60333ce57fe40e0d205ffbcb07506debfe7a36 Mon Sep 17 00:00:00 2001 From: Jay Zhang Date: Fri, 13 Nov 2026 01:20:08 +0000 Subject: [PATCH 2/3] fix(openai-tts): update version and dependencies (#2402) --- .../extension/openai_tts2_python/manifest.json | 2 +- .../extension/openai_tts2_python/pyproject.toml | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/ai_agents/agents/ten_packages/extension/openai_tts2_python/manifest.json b/ai_agents/agents/ten_packages/extension/openai_tts2_python/manifest.json index 3446477eaa..9c927cd6b3 100644 --- a/ai_agents/agents/ten_packages/extension/openai_tts2_python/manifest.json +++ b/ai_agents/agents/ten_packages/extension/openai_tts2_python/manifest.json @@ -1,7 +1,7 @@ { "type": "extension", "name": "openai_tts2_python", - "version": "0.6.10", + "version": "0.6.11", "dependencies": [ { "type": "system", diff --git a/ai_agents/agents/ten_packages/extension/openai_tts2_python/pyproject.toml b/ai_agents/agents/ten_packages/extension/openai_tts2_python/pyproject.toml index 13ed37a25d..af102dd182 100644 --- a/ai_agents/agents/ten_packages/extension/openai_tts2_python/pyproject.toml +++ b/ai_agents/agents/ten_packages/extension/openai_tts2_python/pyproject.toml @@ -1,7 +1,16 @@ [project] name = "openai-tts2-python" -version = "0.6.10" +version = "0.6.11" requires-python = ">=3.10" dependencies = [ - "httpx>=0.28.1", + "certifi", + "httpcore>=1.0.0", + "httpx[http2]>=0.28.1", + "pydantic>=2.0.0", +] + +[project.optional-dependencies] +test = [ + "pytest", + "typing-extensions", ] From 07faeafbef18c7be61ebb5dac183ba068dec26ee Mon Sep 17 00:00:00 2001 From: Jay Zhang Date: Fri, 13 Nov 2026 01:26:11 +0000 Subject: [PATCH 3/3] chore(openai-tts)!: bump version to 0.7.0 (#2402) BREAKING CHANGE: params.url now takes precedence over the top-level url. --- .../ten_packages/extension/openai_tts2_python/manifest.json | 2 +- .../ten_packages/extension/openai_tts2_python/pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ai_agents/agents/ten_packages/extension/openai_tts2_python/manifest.json b/ai_agents/agents/ten_packages/extension/openai_tts2_python/manifest.json index 9c927cd6b3..73107d8a3d 100644 --- a/ai_agents/agents/ten_packages/extension/openai_tts2_python/manifest.json +++ b/ai_agents/agents/ten_packages/extension/openai_tts2_python/manifest.json @@ -1,7 +1,7 @@ { "type": "extension", "name": "openai_tts2_python", - "version": "0.6.11", + "version": "0.7.0", "dependencies": [ { "type": "system", diff --git a/ai_agents/agents/ten_packages/extension/openai_tts2_python/pyproject.toml b/ai_agents/agents/ten_packages/extension/openai_tts2_python/pyproject.toml index af102dd182..60bbefb31b 100644 --- a/ai_agents/agents/ten_packages/extension/openai_tts2_python/pyproject.toml +++ b/ai_agents/agents/ten_packages/extension/openai_tts2_python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openai-tts2-python" -version = "0.6.11" +version = "0.7.0" requires-python = ">=3.10" dependencies = [ "certifi",