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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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."""
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"type": "extension",
"name": "openai_tts2_python",
"version": "0.6.10",
"version": "0.7.0",
"dependencies": [
{
"type": "system",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
[project]
name = "openai-tts2-python"
version = "0.6.10"
version = "0.7.0"
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",
]
Original file line number Diff line number Diff line change
@@ -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])
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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...")
Expand Down Expand Up @@ -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={
Expand All @@ -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")
Expand Down
Loading