add fal ElevenLabs speech and secure audio routing

This commit is contained in:
Codex
2026-08-08 13:02:07 +00:00
parent 6b4c73c6df
commit 21d51ab9c8
13 changed files with 906 additions and 10 deletions

View File

@@ -694,6 +694,7 @@ class TestCapabilityMetadata:
"dashscope",
"doubao",
"elevenlabs",
"fal.ai",
"google_tts",
"kling_official",
"openai",

View File

@@ -0,0 +1,103 @@
from __future__ import annotations
from unittest.mock import MagicMock, patch
from tools.audio.fal_elevenlabs_music import FalElevenLabsMusic
from tools.base_tool import ToolStatus
from tools.tool_registry import ToolRegistry
def _response(*, json_data=None, content=b""):
response = MagicMock()
response.json.return_value = json_data
response.content = content
response.raise_for_status.return_value = None
return response
def test_contract_and_rounded_cost(monkeypatch):
tool = FalElevenLabsMusic()
monkeypatch.setenv("FAL_KEY", "test-key")
assert tool.get_status() == ToolStatus.AVAILABLE
assert tool.estimate_cost({"duration_seconds": 20}) == 0.80
assert tool.estimate_cost({"duration_seconds": 61}) == 1.60
assert tool.get_info()["capability"] == "music_generation"
assert tool.get_info()["provider"] == "fal.ai"
def test_registry_discovers_provider(monkeypatch):
monkeypatch.setenv("FAL_KEY", "test-key")
registry = ToolRegistry()
registry.discover()
tool = registry.get("fal_elevenlabs_music")
assert tool is not None
assert tool.get_status() == ToolStatus.AVAILABLE
def test_execute_submits_once_and_downloads_audio(tmp_path, monkeypatch):
monkeypatch.setenv("FAL_KEY", "test-key")
output_path = tmp_path / "music.mp3"
tool = FalElevenLabsMusic()
tool._POLL_INTERVAL_SECONDS = 0
post_response = _response(
json_data={
"status_url": "https://queue.example/status",
"response_url": "https://queue.example/result",
}
)
status_response = _response(json_data={"status": "COMPLETED"})
result_response = _response(
json_data={"audio": {"url": "https://media.example/music.mp3"}}
)
audio_response = _response(content=b"fake-mp3")
with (
patch("requests.post", return_value=post_response) as mock_post,
patch(
"requests.get",
side_effect=[status_response, result_response, audio_response],
) as mock_get,
):
result = tool.execute(
{
"prompt": "gentle felt piano",
"duration_seconds": 20,
"force_instrumental": True,
"output_path": str(output_path),
}
)
assert result.success is True
assert result.cost_usd == 0.80
assert result.model == "fal-ai/elevenlabs/music"
assert output_path.read_bytes() == b"fake-mp3"
assert mock_post.call_count == 1
assert mock_post.call_args.kwargs["json"]["music_length_ms"] == 20000
assert mock_post.call_args.kwargs["json"]["force_instrumental"] is True
assert mock_get.call_count == 3
def test_execute_rejects_missing_duration_without_request(monkeypatch):
monkeypatch.setenv("FAL_KEY", "test-key")
with patch("requests.post") as mock_post:
result = FalElevenLabsMusic().execute({"prompt": "gentle piano"})
assert result.success is False
assert result.error == "duration_seconds is required"
mock_post.assert_not_called()
def test_error_redacts_fal_key(monkeypatch):
secret = "test-production-shaped-fal-key"
monkeypatch.setenv("FAL_KEY", secret)
with patch("requests.post", side_effect=RuntimeError(f"request used {secret}")):
result = FalElevenLabsMusic().execute(
{"prompt": "gentle piano", "duration_seconds": 20}
)
assert result.success is False
assert secret not in result.error
assert "[REDACTED]" in result.error

View File

@@ -0,0 +1,106 @@
from __future__ import annotations
from unittest.mock import MagicMock, patch
from tools.audio.fal_elevenlabs_tts import FalElevenLabsTTS
from tools.base_tool import ToolStatus
from tools.tool_registry import ToolRegistry
def _response(*, json_data=None, content=b""):
response = MagicMock()
response.json.return_value = json_data
response.content = content
response.raise_for_status.return_value = None
return response
def test_contract_models_and_cost(monkeypatch):
monkeypatch.setenv("FAL_KEY", "test-key")
tool = FalElevenLabsTTS()
assert tool.get_status() == ToolStatus.AVAILABLE
assert tool.get_info()["capability"] == "tts"
assert tool.get_info()["provider"] == "fal.ai"
assert tool.estimate_cost({"text": "a" * 1000, "model_id": "eleven-v3"}) == 0.1
assert tool.estimate_cost({"text": "a" * 1000, "model_id": "multilingual-v2"}) == 0.1
assert tool.estimate_cost({"text": "a" * 1000, "model_id": "turbo-v2.5"}) == 0.05
def test_registry_discovers_fal_tts(monkeypatch):
monkeypatch.setenv("FAL_KEY", "test-key")
registry = ToolRegistry()
registry.discover()
tool = registry.get("fal_elevenlabs_tts")
assert tool is not None
assert tool.get_status() == ToolStatus.AVAILABLE
def test_execute_submits_once_and_downloads_audio(tmp_path, monkeypatch):
monkeypatch.setenv("FAL_KEY", "test-key")
output_path = tmp_path / "speech.mp3"
tool = FalElevenLabsTTS()
tool._POLL_INTERVAL_SECONDS = 0
post_response = _response(
json_data={
"status_url": "https://queue.example/status",
"response_url": "https://queue.example/result",
}
)
status_response = _response(json_data={"status": "COMPLETED"})
result_response = _response(
json_data={"audio": {"url": "https://media.example/speech.mp3"}}
)
audio_response = _response(content=b"fake-mp3")
with (
patch("requests.post", return_value=post_response) as mock_post,
patch(
"requests.get",
side_effect=[status_response, result_response, audio_response],
) as mock_get,
):
result = tool.execute(
{
"text": "A calm, measured test.",
"voice_id": "Rachel",
"model_id": "eleven-v3",
"stability": 0.65,
"language_code": "en",
"output_path": str(output_path),
}
)
assert result.success is True
assert result.model == "fal-ai/elevenlabs/tts/eleven-v3"
assert output_path.read_bytes() == b"fake-mp3"
assert mock_post.call_count == 1
assert mock_post.call_args.args[0].endswith("/fal-ai/elevenlabs/tts/eleven-v3")
assert mock_post.call_args.kwargs["json"]["voice"] == "Rachel"
assert mock_post.call_args.kwargs["json"]["stability"] == 0.65
assert mock_get.call_count == 3
def test_invalid_model_does_not_submit(monkeypatch):
monkeypatch.setenv("FAL_KEY", "test-key")
with patch("requests.post") as mock_post:
result = FalElevenLabsTTS().execute(
{"text": "hello", "model_id": "not-a-model"}
)
assert result.success is False
assert "model_id must be one of" in result.error
mock_post.assert_not_called()
def test_error_redacts_fal_key(monkeypatch):
secret = "test-production-shaped-fal-key"
monkeypatch.setenv("FAL_KEY", secret)
with patch("requests.post", side_effect=RuntimeError(f"request used {secret}")):
result = FalElevenLabsTTS().execute({"text": "hello"})
assert result.success is False
assert secret not in result.error
assert "[REDACTED]" in result.error

View File

@@ -34,3 +34,26 @@ def test_tts_key_uses_header_and_is_redacted_from_errors(monkeypatch, tmp_path):
assert result.success is False
assert secret not in result.error
assert "[REDACTED]" in result.error
def test_production_adapter_can_force_google_tts_to_ipv4(monkeypatch, tmp_path):
import socket
import requests
import urllib3.util.connection
monkeypatch.setenv("GOOGLE_TTS_API_KEY", "test-key")
monkeypatch.setenv("GOOGLE_TTS_FORCE_IPV4", "1")
original = urllib3.util.connection.allowed_gai_family
def inspect_request(url, **kwargs):
assert urllib3.util.connection.allowed_gai_family() == socket.AF_INET
raise requests.HTTPError("safe expected failure")
monkeypatch.setattr(requests, "post", inspect_request)
result = GoogleTTS().execute(
{"text": "safe test sentence", "output_path": str(tmp_path / "speech.mp3")}
)
assert result.success is False
assert urllib3.util.connection.allowed_gai_family is original