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
@@ -1,11 +1,24 @@
from typing import Any
import copy
import math
from pathlib import Path
from pydantic import Field
from ten_ai_base import utils
from ten_ai_base.tts2_http import AsyncTTS2HttpConfig


def _safe_float(value: Any, default: float) -> float:
if isinstance(value, bool):
return default
try:
converted = float(value)
except (TypeError, ValueError):
return default
if not math.isfinite(converted):
return default
return converted


class OpenAITTSConfig(AsyncTTS2HttpConfig):
"""OpenAI TTS Config"""

Expand Down Expand Up @@ -34,8 +47,12 @@ def update_params(self) -> None:
self.params["model"] = "gpt-4o-mini-tts"
if "voice" not in self.params:
self.params["voice"] = "coral"
if "speed" not in self.params:
self.params["speed"] = 1.0
speed = self.params["speed"] if "speed" in self.params else 1.0
self.params["speed"] = _safe_float(speed, 1.0)
if "api_key" in self.params and not isinstance(
self.params["api_key"], str
):
self.params["api_key"] = ""
if "instructions" not in self.params:
self.params["instructions"] = ""

Expand All @@ -46,20 +63,19 @@ def update_params(self) -> None:
# Use fixed value
self.params["response_format"] = "pcm"

# Set endpoint URL from base_url if url is not provided
# Remove endpoint-only params after selecting the endpoint.
param_url = self.params.pop("url", None) # pylint: disable=no-member
base_url = self.params.pop( # pylint: disable=no-member
"base_url", "https://api.openai.com/v1"
)
if not self.url:
if "url" in self.params:
self.url = self.params["url"]
self.params.pop("url", None) # pylint: disable=no-member
if isinstance(param_url, str) and param_url.strip():
self.url = param_url
else:
base_url = self.params.get( # pylint: disable=no-member
"base_url", "https://api.openai.com/v1"
)
# Remove trailing slash from base_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"
# Remove base_url from params since it's been used to set url
self.params.pop("base_url", None) # pylint: disable=no-member

def to_str(self, sensitive_handling: bool = True) -> str:
"""Convert config to string with optional sensitive data handling."""
Expand All @@ -81,6 +97,8 @@ def to_str(self, sensitive_handling: bool = True) -> str:

def validate(self) -> None:
"""Validate OpenAI-specific configuration."""
if not isinstance(self.url, str) or not self.url.strip():
raise ValueError("URL is required for OpenAI TTS")
# Check if API key is provided in params or Authorization header
has_api_key_in_params = (
"api_key" in self.params and self.params["api_key"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,12 @@
AsyncTTS2HttpConfig,
AsyncTTS2HttpClient,
)
from ten_ai_base import utils
from ten_runtime import AsyncTenEnv

from .config import OpenAITTSConfig
from .openai_tts import OpenAITTSClient


def _mask_metadata_secret(value: str) -> str:
return utils.encrypt(value) if value else value


class OpenAITTSExtension(AsyncTTS2HttpExtension):
"""
OpenAI TTS Extension implementation.
Expand Down Expand Up @@ -62,9 +57,7 @@ def vendor_metadata(self) -> dict:
return {
"url": self.config.url or "",
"model": self.config.params.get("model", ""),
"api_key": _mask_metadata_secret(
self.config.params.get("api_key", "")
),
"api_key": self.config.params.get("api_key", ""),
"authorization": authorization,
"voice": self.config.params.get("voice", ""),
}
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.9",
"version": "0.6.10",
"dependencies": [
{
"type": "system",
Expand Down Expand Up @@ -36,26 +36,7 @@
"properties": {
"params": {
"type": "object",
"properties": {
"api_key": {
"type": "string"
},
"base_url": {
"type": "string"
},
"model": {
"type": "string"
},
"voice": {
"type": "string"
},
"speed": {
"type": "float64"
},
"instructions": {
"type": "string"
}
}
"properties": {}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "openai-tts2-python"
version = "0.6.9"
version = "0.6.10"
requires-python = ">=3.10"
dependencies = [
"httpx>=0.28.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,61 @@ def test_url_and_base_url_configuration(
print("✅ URL and base_url configuration test passed successfully.")


def test_invalid_param_values_use_safe_defaults():
"""Invalid free-form params must not leave the client unusable."""
from openai_tts2_python.config import OpenAITTSConfig

config = OpenAITTSConfig(
params={
"api_key": "test_key",
"model": "gpt-4o-mini-tts",
"voice": "coral",
"speed": "not-a-number",
"url": 8080,
"base_url": "https://api.custom.com/v1/",
}
)
config.update_params()

assert config.params["speed"] == 1.0
assert config.url == "https://api.custom.com/v1/audio/speech"
assert "url" not in config.params
assert "base_url" not in config.params


def test_speed_rejects_bool_and_non_finite_values():
"""Speed must be a finite numeric value."""
from openai_tts2_python.config import OpenAITTSConfig

for value in (False, "nan", "inf"):
config = OpenAITTSConfig(params={"speed": value})
config.update_params()
assert config.params["speed"] == 1.0


def test_validate_rejects_empty_url():
"""An empty top-level URL must fail before the first request."""
from openai_tts2_python.config import OpenAITTSConfig

config = OpenAITTSConfig(
url="",
params={
"api_key": "test_key",
"model": "gpt-4o-mini-tts",
"voice": "coral",
},
)
config.update_params()
config.url = ""

try:
config.validate()
except ValueError as exc:
assert str(exc) == "URL is required for OpenAI TTS"
else:
raise AssertionError("Expected empty URL validation to fail")


@patch("openai_tts2_python.openai_tts.AsyncClient")
@patch("openai_tts2_python.openai_tts.Timeout")
@patch("openai_tts2_python.openai_tts.Limits")
Expand Down Expand Up @@ -391,8 +446,7 @@ def test_vendor_metadata_does_not_convert_api_key_to_authorization():
metadata = extension.vendor_metadata()

assert metadata["authorization"] == ""
assert metadata["api_key"]
assert metadata["api_key"] != "test_api_key_123"
assert metadata["api_key"] == "test_api_key_123"
assert "key" not in metadata


Expand All @@ -415,8 +469,7 @@ def test_vendor_metadata_returns_raw_config_authorization_header():
metadata = extension.vendor_metadata()

assert metadata["authorization"] == "Bearer header_key"
assert metadata["api_key"]
assert metadata["api_key"] != "test_api_key_123"
assert metadata["api_key"] == "test_api_key_123"
assert "key" not in metadata


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
from ..config import OpenAITTSConfig
from ..extension import OpenAITTSExtension


def _set_config(ext: OpenAITTSExtension, config: dict) -> None:
ext.config = OpenAITTSConfig.model_validate(config)
ext.config.update_params()


def test_vendor_metadata_builds_authorization_from_api_key():
ext = OpenAITTSExtension("test")
_set_config(
ext,
{
"url": "https://api.openai.com/v1/audio/speech",
"params": {
"api_key": "api-secret",
"model": "gpt-4o-mini-tts",
"voice": "coral",
},
},
)

assert ext.vendor_metadata() == {
"url": "https://api.openai.com/v1/audio/speech",
"model": "gpt-4o-mini-tts",
"api_key": "api-secret",
"authorization": "",
"voice": "coral",
}


def test_vendor_metadata_prefers_authorization_header():
ext = OpenAITTSExtension("test")
_set_config(
ext,
{
"headers": {"Authorization": "Bearer header-secret"},
"params": {"api_key": "api-secret"},
},
)

metadata = ext.vendor_metadata()

assert "key" not in metadata
assert metadata["api_key"] == "api-secret"
assert metadata["authorization"] == "Bearer header-secret"


def test_vendor_metadata_handles_none_api_key():
ext = OpenAITTSExtension("test")
_set_config(ext, {"params": {"api_key": None}})

metadata = ext.vendor_metadata()

assert "key" not in metadata
assert metadata["api_key"] == ""
assert metadata["authorization"] == ""


def test_vendor_metadata_without_config():
ext = OpenAITTSExtension("test")
ext.config = None

assert ext.vendor_metadata() == {}
Loading