fix(tts): bound upstream response bodies

This commit is contained in:
luyifan 2026-06-30 04:51:32 +08:00 committed by Teknium
parent 43b8eb84b3
commit 60b841bbfb
3 changed files with 220 additions and 23 deletions

View file

@ -0,0 +1,85 @@
"""Regression tests for bounded upstream TTS response reads."""
from __future__ import annotations
from unittest.mock import patch
import pytest
from tools import tts_tool
class StreamingResponse:
def __init__(self, chunks, *, status_code=200, headers=None):
self._chunks = list(chunks)
self.status_code = status_code
self.headers = headers or {}
self.closed = False
def iter_content(self, chunk_size=65536):
del chunk_size
yield from self._chunks
def close(self):
self.closed = True
def raise_for_status(self):
if self.status_code >= 400:
raise RuntimeError(f"HTTP {self.status_code}")
@pytest.fixture(autouse=True)
def small_tts_body_cap(monkeypatch):
monkeypatch.setattr(tts_tool, "TTS_RESPONSE_BODY_LIMIT_BYTES", 8)
def test_xai_tts_rejects_oversized_audio_response(tmp_path, monkeypatch):
monkeypatch.setenv("XAI_API_KEY", "test-xai-key")
response = StreamingResponse([b"12345", b"6789"], headers={"Content-Type": "audio/mpeg"})
output_path = tmp_path / "out.mp3"
with patch("requests.post", return_value=response) as post:
with pytest.raises(RuntimeError, match="xAI TTS response exceeds 8 bytes"):
tts_tool._generate_xai_tts("hello", str(output_path), {})
assert post.call_args.kwargs["stream"] is True
assert response.closed is True
assert not output_path.exists()
def test_minimax_t2a_rejects_oversized_json_response(tmp_path, monkeypatch):
monkeypatch.setenv("MINIMAX_API_KEY", "test-minimax-key")
response = StreamingResponse([b'{"data":', b'"too large"}'], headers={"Content-Type": "application/json"})
with patch("requests.post", return_value=response) as post:
with pytest.raises(RuntimeError, match="MiniMax TTS response exceeds 8 bytes"):
tts_tool._generate_minimax_tts("hello", str(tmp_path / "out.mp3"), {})
assert post.call_args.kwargs["stream"] is True
assert response.closed is True
def test_minimax_legacy_rejects_oversized_audio_response(tmp_path, monkeypatch):
monkeypatch.setenv("MINIMAX_API_KEY", "test-minimax-key")
response = StreamingResponse([b"12345", b"6789"], headers={"Content-Type": "audio/mpeg"})
config = {"minimax": {"base_url": "https://api.minimax.chat/v1/text_to_speech"}}
output_path = tmp_path / "out.mp3"
with patch("requests.post", return_value=response):
with pytest.raises(RuntimeError, match="MiniMax TTS response exceeds 8 bytes"):
tts_tool._generate_minimax_tts("hello", str(output_path), config)
assert response.closed is True
assert not output_path.exists()
def test_gemini_tts_rejects_oversized_json_response(tmp_path, monkeypatch):
monkeypatch.setenv("GEMINI_API_KEY", "test-gemini-key")
response = StreamingResponse([b'{"candidates":', b"[{}]}"], headers={"Content-Type": "application/json"})
with patch("requests.post", return_value=response) as post:
with pytest.raises(RuntimeError, match="Gemini TTS response exceeds 8 bytes"):
tts_tool._generate_gemini_tts("hello", str(tmp_path / "out.wav"), {})
assert post.call_args.kwargs["stream"] is True
assert response.closed is True

View file

@ -101,11 +101,12 @@ def test_generate_xai_tts_sends_auxiliary_rewriter_output_to_api(
def raise_for_status(self):
pass
def fake_post(url, headers, json, timeout):
def fake_post(url, headers, json, timeout, stream=False):
captured["url"] = url
captured["headers"] = headers
captured["json"] = json
captured["timeout"] = timeout
captured["stream"] = stream
return FakeResponse()
fake_response = SimpleNamespace(
@ -352,8 +353,9 @@ def test_generate_xai_tts_leaves_text_plain_by_default(tmp_path, monkeypatch):
fake_response.content = b"mp3"
fake_response.raise_for_status.return_value = None
def fake_post(url, headers, json, timeout):
def fake_post(url, headers, json, timeout, stream=False):
captured["json"] = json
captured["stream"] = stream
return fake_response
monkeypatch.setenv("XAI_API_KEY", "test-xai-key")
@ -379,8 +381,9 @@ def test_generate_xai_tts_omits_speed_and_latency_by_default(tmp_path, monkeypat
fake_response.content = b"mp3"
fake_response.raise_for_status.return_value = None
def fake_post(url, headers, json, timeout):
def fake_post(url, headers, json, timeout, stream=False):
captured["json"] = json
captured["stream"] = stream
return fake_response
monkeypatch.setenv("XAI_API_KEY", "test-xai-key")
@ -404,8 +407,9 @@ def test_generate_xai_tts_sends_speed_when_set(tmp_path, monkeypatch):
fake_response.content = b"mp3"
fake_response.raise_for_status.return_value = None
def fake_post(url, headers, json, timeout):
def fake_post(url, headers, json, timeout, stream=False):
captured["json"] = json
captured["stream"] = stream
return fake_response
monkeypatch.setenv("XAI_API_KEY", "test-xai-key")
@ -428,8 +432,9 @@ def test_generate_xai_tts_speed_clamped_to_valid_range(tmp_path, monkeypatch):
fake_response.content = b"mp3"
fake_response.raise_for_status.return_value = None
def fake_post(url, headers, json, timeout):
def fake_post(url, headers, json, timeout, stream=False):
captured["json"] = json
captured["stream"] = stream
return fake_response
monkeypatch.setenv("XAI_API_KEY", "test-xai-key")
@ -460,8 +465,9 @@ def test_generate_xai_tts_omits_speed_when_exactly_default(tmp_path, monkeypatch
fake_response.content = b"mp3"
fake_response.raise_for_status.return_value = None
def fake_post(url, headers, json, timeout):
def fake_post(url, headers, json, timeout, stream=False):
captured["json"] = json
captured["stream"] = stream
return fake_response
monkeypatch.setenv("XAI_API_KEY", "test-xai-key")
@ -484,8 +490,9 @@ def test_generate_xai_tts_sends_optimize_streaming_latency_when_set(tmp_path, mo
fake_response.content = b"mp3"
fake_response.raise_for_status.return_value = None
def fake_post(url, headers, json, timeout):
def fake_post(url, headers, json, timeout, stream=False):
captured["json"] = json
captured["stream"] = stream
return fake_response
monkeypatch.setenv("XAI_API_KEY", "test-xai-key")
@ -508,8 +515,9 @@ def test_generate_xai_tts_optimize_streaming_latency_omitted_at_default(tmp_path
fake_response.content = b"mp3"
fake_response.raise_for_status.return_value = None
def fake_post(url, headers, json, timeout):
def fake_post(url, headers, json, timeout, stream=False):
captured["json"] = json
captured["stream"] = stream
return fake_response
monkeypatch.setenv("XAI_API_KEY", "test-xai-key")
@ -532,8 +540,9 @@ def test_generate_xai_tts_global_speed_used_as_fallback(tmp_path, monkeypatch):
fake_response.content = b"mp3"
fake_response.raise_for_status.return_value = None
def fake_post(url, headers, json, timeout):
def fake_post(url, headers, json, timeout, stream=False):
captured["json"] = json
captured["stream"] = stream
return fake_response
monkeypatch.setenv("XAI_API_KEY", "test-xai-key")
@ -556,8 +565,9 @@ def test_generate_xai_tts_provider_speed_overrides_global(tmp_path, monkeypatch)
fake_response.content = b"mp3"
fake_response.raise_for_status.return_value = None
def fake_post(url, headers, json, timeout):
def fake_post(url, headers, json, timeout, stream=False):
captured["json"] = json
captured["stream"] = stream
return fake_response
monkeypatch.setenv("XAI_API_KEY", "test-xai-key")

View file

@ -246,6 +246,8 @@ DEFAULT_DEEPINFRA_TTS_VOICE = "default"
GEMINI_TTS_SAMPLE_RATE = 24000
GEMINI_TTS_CHANNELS = 1
GEMINI_TTS_SAMPLE_WIDTH = 2 # 16-bit PCM (L16)
TTS_RESPONSE_BODY_LIMIT_BYTES = 16 * 1024 * 1024
TTS_RESPONSE_BODY_CHUNK_BYTES = 64 * 1024
def _get_default_output_dir() -> str:
from hermes_constants import get_hermes_dir
@ -302,6 +304,93 @@ def _config_bool(value: Any, default: bool = False) -> bool:
return False
return default
def _response_has_explicit_stream(response: Any) -> bool:
iter_content = getattr(response, "iter_content", None)
if not callable(iter_content):
return False
response_type = type(response)
if response_type.__module__.startswith("requests."):
return True
return "iter_content" in vars(response_type)
def _close_response(response: Any) -> None:
close = getattr(response, "close", None)
if callable(close):
try:
close()
except Exception:
pass
def _read_tts_response_bytes(
response: Any,
*,
label: str,
limit: Optional[int] = None,
) -> bytes:
"""Read an upstream TTS response with a hard byte cap."""
limit = TTS_RESPONSE_BODY_LIMIT_BYTES if limit is None else limit
chunks: list[bytes] = []
total = 0
try:
if _response_has_explicit_stream(response):
iterator = response.iter_content(chunk_size=TTS_RESPONSE_BODY_CHUNK_BYTES)
else:
content = vars(response).get("content", getattr(type(response), "content", b""))
if isinstance(content, str):
content = content.encode("utf-8", errors="replace")
iterator = (content,) if isinstance(content, (bytes, bytearray)) else ()
for chunk in iterator:
if not chunk:
continue
if isinstance(chunk, str):
chunk = chunk.encode("utf-8", errors="replace")
chunk = bytes(chunk)
total += len(chunk)
if total > limit:
_close_response(response)
raise RuntimeError(f"{label} response exceeds {limit} bytes")
chunks.append(chunk)
return b"".join(chunks)
finally:
_close_response(response)
def _read_tts_response_json(
response: Any,
*,
label: str,
limit: Optional[int] = None,
) -> Dict[str, Any]:
raw = _read_tts_response_bytes(response, label=label, limit=limit)
if raw:
return json.loads(raw.decode("utf-8"))
# Unit-test doubles often only provide `.json()`. Real requests.Response
# objects use the streaming path above, so this fallback does not re-open
# the production eager-buffering behavior.
if not _response_has_explicit_stream(response):
json_reader = getattr(response, "json", None)
if callable(json_reader):
parsed = json_reader()
return parsed if isinstance(parsed, dict) else {}
return {}
def _write_tts_response_to_file(
response: Any,
output_path: str,
*,
label: str,
limit: Optional[int] = None,
) -> None:
audio_bytes = _read_tts_response_bytes(response, label=label, limit=limit)
with open(output_path, "wb") as f:
f.write(audio_bytes)
# Final fallback when provider isn't recognised at all.
FALLBACK_MAX_TEXT_LENGTH = 4000
@ -1550,11 +1639,11 @@ def _generate_xai_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -
},
json=payload,
timeout=60,
stream=True,
)
response.raise_for_status()
with open(output_path, "wb") as f:
f.write(response.content)
_write_tts_response_to_file(response, output_path, label="xAI TTS")
return output_path
@ -1642,12 +1731,18 @@ def _generate_minimax_tts(text: str, output_path: str, tts_config: Dict[str, Any
"voice_id": voice_id,
}
response = requests.post(base_url, json=payload, headers=headers, timeout=60)
response = requests.post(
base_url,
json=payload,
headers=headers,
timeout=60,
stream=True,
)
if is_t2a_v2:
# t2a_v2 returns JSON with hex-encoded audio
response.raise_for_status()
result = response.json()
result = _read_tts_response_json(response, label="MiniMax TTS")
base_resp = result.get("base_resp", {})
status_code = base_resp.get("status_code", -1)
@ -1669,23 +1764,23 @@ def _generate_minimax_tts(text: str, output_path: str, tts_config: Dict[str, Any
content_type = response.headers.get("Content-Type", "")
if "audio/" in content_type:
with open(output_path, "wb") as f:
f.write(response.content)
_write_tts_response_to_file(response, output_path, label="MiniMax TTS")
return output_path
# Fallback: try parsing as JSON
try:
result = response.json()
raw_body = _read_tts_response_bytes(response, label="MiniMax TTS")
result = json.loads(raw_body.decode("utf-8")) if raw_body else {}
base_resp = result.get("base_resp", {})
status_code = base_resp.get("status_code", -1)
if status_code != 0:
status_msg = base_resp.get("status_msg", "unknown error")
raise RuntimeError(f"MiniMax TTS API error (code {status_code}): {status_msg}")
except Exception:
except (json.JSONDecodeError, UnicodeDecodeError, TypeError):
response.raise_for_status()
raise RuntimeError(
f"MiniMax TTS returned unexpected Content-Type '{content_type}' "
f"({len(response.content)} bytes)"
f"({len(raw_body) if 'raw_body' in locals() else 0} bytes)"
)
raise RuntimeError("MiniMax TTS returned no audio data")
@ -2023,20 +2118,27 @@ def _generate_gemini_tts(text: str, output_path: str, tts_config: Dict[str, Any]
headers=headers,
json=payload,
timeout=60,
stream=True,
)
if response.status_code != 200:
# Surface the API error message when present
raw_body = _read_tts_response_bytes(response, label="Gemini TTS")
try:
err = response.json().get("error", {})
detail = err.get("message") or response.text[:300]
if raw_body:
err = json.loads(raw_body.decode("utf-8")).get("error", {})
elif not _response_has_explicit_stream(response) and callable(getattr(response, "json", None)):
err = response.json().get("error", {})
else:
err = {}
detail = err.get("message") or raw_body.decode("utf-8", errors="replace")[:300]
except Exception:
detail = response.text[:300]
detail = raw_body.decode("utf-8", errors="replace")[:300]
raise RuntimeError(
f"Gemini TTS API error (HTTP {response.status_code}): {detail}"
)
try:
data = response.json()
data = _read_tts_response_json(response, label="Gemini TTS")
parts = data["candidates"][0]["content"]["parts"]
audio_part = next((p for p in parts if "inlineData" in p or "inline_data" in p), None)
if audio_part is None: