diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 7ad51bad..284c9e87 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -192,7 +192,7 @@ The ASR tool (`qwen3-asr-flash-filetrans`) uses an async submit-poll pattern. Au > **Broad single-key coverage.** One API key unlocks image and video providers across multiple models. -**Tools unlocked:** `flux_image`, `recraft_image`, `kling_video`, `veo_video`, `minimax_video` +**Tools unlocked:** `flux_image`, `recraft_image`, `kling_video`, `veo_video`, `minimax_video`, `fal_elevenlabs_tts`, `fal_elevenlabs_music` **Env var:** `FAL_KEY` #### Setup @@ -225,6 +225,10 @@ No subscription — pure pay-as-you-go, no minimum spend. **Free tier:** None — but $0 to start, you only pay for what you use. +The same key can also access ElevenLabs speech and music through fal.ai. Use +`fal_elevenlabs_tts` when direct ElevenLabs credentials are unavailable, or +select it through `tts_selector` with `preferred_provider: "fal.ai"`. + --- ### Kling Official — Direct API diff --git a/tests/tools/test_fal_elevenlabs_tts.py b/tests/tools/test_fal_elevenlabs_tts.py index 81b2987c..e05c206f 100644 --- a/tests/tools/test_fal_elevenlabs_tts.py +++ b/tests/tools/test_fal_elevenlabs_tts.py @@ -37,6 +37,32 @@ def test_registry_discovers_fal_tts(monkeypatch): assert tool.get_status() == ToolStatus.AVAILABLE +def test_tts_selector_routes_to_fal_provider(monkeypatch): + from tools.audio.tts_selector import TTSSelector + from tools.base_tool import ToolResult + + monkeypatch.setenv("FAL_KEY", "test-key") + tool = FalElevenLabsTTS() + selector = TTSSelector() + monkeypatch.setattr(selector, "_providers", lambda: [tool]) + monkeypatch.setattr( + selector, + "_select_best_tool", + lambda _inputs, _candidates, _context: (tool, None), + ) + monkeypatch.setattr( + tool, + "execute", + lambda inputs: ToolResult(success=True, data={"received": inputs}), + ) + result = selector.execute( + {"text": "hello", "preferred_provider": "fal.ai", "voice_id": "Rachel"} + ) + assert result.success + assert result.data["selected_tool"] == "fal_elevenlabs_tts" + assert result.data["selected_provider"] == "fal.ai" + + def test_execute_submits_once_and_downloads_audio(tmp_path, monkeypatch): monkeypatch.setenv("FAL_KEY", "test-key") output_path = tmp_path / "speech.mp3" diff --git a/tests/tools/test_google_tts_scoped_key.py b/tests/tools/test_google_tts_scoped_key.py index 36ea45ac..fbc15af2 100644 --- a/tests/tools/test_google_tts_scoped_key.py +++ b/tests/tools/test_google_tts_scoped_key.py @@ -34,26 +34,3 @@ 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 diff --git a/tools/audio/google_tts.py b/tools/audio/google_tts.py index 67fcbb97..f47dd4d6 100644 --- a/tools/audio/google_tts.py +++ b/tools/audio/google_tts.py @@ -8,10 +8,7 @@ from __future__ import annotations import base64 import os -import socket -import threading import time -from contextlib import contextmanager from pathlib import Path from typing import Any @@ -33,32 +30,6 @@ from tools.google_credentials import ( ) -_IPV4_REQUEST_LOCK = threading.Lock() - - -@contextmanager -def _google_tts_network_family(): - """Use IPv4 when the deployment key is restricted to the server's IPv4.""" - force_ipv4 = os.environ.get("GOOGLE_TTS_FORCE_IPV4", "").lower() in { - "1", - "true", - "yes", - } - if not force_ipv4: - yield - return - - import urllib3.util.connection - - with _IPV4_REQUEST_LOCK: - original = urllib3.util.connection.allowed_gai_family - urllib3.util.connection.allowed_gai_family = lambda: socket.AF_INET - try: - yield - finally: - urllib3.util.connection.allowed_gai_family = original - - class GoogleTTS(BaseTool): name = "google_tts" version = "0.1.0" @@ -76,7 +47,6 @@ class GoogleTTS(BaseTool): " GOOGLE_API_KEY or GEMINI_API_KEY remain supported for broader Google setups.\n" " Google Cloud API key with Text-to-Speech enabled.\n" " Enable the API at https://console.cloud.google.com/apis/library/texttospeech.googleapis.com\n" - " Set GOOGLE_TTS_FORCE_IPV4=1 only when the key is restricted to the caller's IPv4.\n" "Auth option B — service account: set GOOGLE_APPLICATION_CREDENTIALS to the\n" " path of a service-account JSON key (needs the 'google-auth' package)." ) @@ -309,13 +279,12 @@ class GoogleTTS(BaseTool): elif api_key: headers["x-goog-api-key"] = api_key - with _google_tts_network_family(): - response = requests.post( - url, - headers=headers, - json=payload, - timeout=120, - ) + response = requests.post( + url, + headers=headers, + json=payload, + timeout=120, + ) response.raise_for_status() audio_content = base64.b64decode(response.json()["audioContent"])