mirror of
https://github.com/calesthio/OpenMontage.git
synced 2026-09-08 12:44:06 +08:00
Merge remote-tracking branch 'origin/main' into codex/repair-pr-458
# Conflicts: # tools/graphics/image_selector.py
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,132 @@
|
||||
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_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"
|
||||
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
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Tests for the Gemini image backend in google_imagen.
|
||||
|
||||
Models named `gemini-*` (e.g. gemini-2.5-flash-image) are not served by the
|
||||
Imagen `:predict` endpoint — they generate images through generate_content
|
||||
with an image_config. This backend matters on Vertex projects that have no
|
||||
Imagen catalog access, where it is the only working Google image path.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import types as pytypes
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
|
||||
class _FakeInline:
|
||||
def __init__(self, data: bytes):
|
||||
self.data = data
|
||||
|
||||
|
||||
class _FakePart:
|
||||
def __init__(self, data: bytes):
|
||||
self.inline_data = _FakeInline(data)
|
||||
|
||||
|
||||
class _FakeContent:
|
||||
def __init__(self, parts):
|
||||
self.parts = parts
|
||||
|
||||
|
||||
class _FakeCandidate:
|
||||
def __init__(self, parts):
|
||||
self.content = _FakeContent(parts)
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, parts):
|
||||
self.candidates = [_FakeCandidate(parts)]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def imagen_tool(monkeypatch):
|
||||
monkeypatch.setenv("GOOGLE_API_KEY", "test-key")
|
||||
|
||||
calls: list[dict] = []
|
||||
|
||||
class _FakeModels:
|
||||
def generate_content(self, model=None, contents=None, config=None):
|
||||
calls.append({"model": model, "contents": contents, "config": config})
|
||||
return _FakeResponse([_FakePart(b"GEMINI_IMG")])
|
||||
|
||||
class _FakeClient:
|
||||
models = _FakeModels()
|
||||
|
||||
import tools.google_credentials as gc
|
||||
|
||||
monkeypatch.setattr(
|
||||
gc, "get_genai_client", lambda http_options=None, location=None: _FakeClient()
|
||||
)
|
||||
|
||||
from tools.graphics.google_imagen import GoogleImagen
|
||||
|
||||
return GoogleImagen(), calls
|
||||
|
||||
|
||||
def test_gemini_model_routes_to_generate_content(imagen_tool, tmp_path):
|
||||
tool, calls = imagen_tool
|
||||
out = tmp_path / "img.png"
|
||||
|
||||
result = tool.execute(
|
||||
{
|
||||
"prompt": "a flower",
|
||||
"model": "gemini-2.5-flash-image",
|
||||
"aspect_ratio": "16:9",
|
||||
"output_path": str(out),
|
||||
}
|
||||
)
|
||||
|
||||
assert result.success
|
||||
assert result.data["model"] == "gemini-2.5-flash-image"
|
||||
assert out.read_bytes() == b"GEMINI_IMG"
|
||||
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["model"] == "gemini-2.5-flash-image"
|
||||
# Aspect ratio must reach the API through image_config, not be dropped.
|
||||
assert calls[0]["config"].image_config.aspect_ratio == "16:9"
|
||||
|
||||
|
||||
def test_image_selector_maps_model_name_to_google_model(
|
||||
imagen_tool, monkeypatch, tmp_path
|
||||
):
|
||||
"""The governed selector must be able to reach the Gemini backend."""
|
||||
from tools.graphics.image_selector import ImageSelector
|
||||
|
||||
tool, calls = imagen_tool
|
||||
selector = ImageSelector()
|
||||
monkeypatch.setattr(selector, "_providers", lambda: [tool])
|
||||
|
||||
result = selector.execute(
|
||||
{
|
||||
"prompt": "a flower",
|
||||
"preferred_provider": "google_imagen",
|
||||
"model_name": "gemini-2.5-flash-image",
|
||||
"output_path": str(tmp_path / "selected.png"),
|
||||
}
|
||||
)
|
||||
|
||||
assert result.success, result.error
|
||||
assert calls[0]["model"] == "gemini-2.5-flash-image"
|
||||
assert result.data["selected_tool"] == "google_imagen"
|
||||
assert result.data["model"] == "gemini-2.5-flash-image"
|
||||
|
||||
|
||||
def test_gemini_cost_estimate_is_per_image():
|
||||
from tools.graphics.google_imagen import GoogleImagen
|
||||
|
||||
tool = GoogleImagen()
|
||||
assert tool.estimate_cost(
|
||||
{"model": "gemini-2.5-flash-image", "number_of_images": 2}
|
||||
) == pytest.approx(0.039 * 2)
|
||||
|
||||
|
||||
def test_text_only_response_is_a_clear_error(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("GOOGLE_API_KEY", "test-key")
|
||||
|
||||
class _TextPart:
|
||||
inline_data = None
|
||||
|
||||
class _FakeModels:
|
||||
def generate_content(self, model=None, contents=None, config=None):
|
||||
return _FakeResponse([_TextPart()])
|
||||
|
||||
class _FakeClient:
|
||||
models = _FakeModels()
|
||||
|
||||
import tools.google_credentials as gc
|
||||
|
||||
monkeypatch.setattr(
|
||||
gc, "get_genai_client", lambda http_options=None, location=None: _FakeClient()
|
||||
)
|
||||
|
||||
from tools.graphics.google_imagen import GoogleImagen
|
||||
|
||||
result = GoogleImagen().execute(
|
||||
{
|
||||
"prompt": "a flower",
|
||||
"model": "gemini-2.5-flash-image",
|
||||
"output_path": str(tmp_path / "img.png"),
|
||||
}
|
||||
)
|
||||
|
||||
assert not result.success
|
||||
assert "No image data" in result.error
|
||||
@@ -0,0 +1,36 @@
|
||||
from tools.audio.google_tts import GoogleTTS
|
||||
from tools.google_credentials import has_google_credentials
|
||||
|
||||
|
||||
def test_tts_only_key_does_not_enable_shared_google_providers(monkeypatch):
|
||||
monkeypatch.setenv("GOOGLE_TTS_API_KEY", "test-tts-only-key")
|
||||
monkeypatch.delenv("GOOGLE_API_KEY", raising=False)
|
||||
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("GOOGLE_APPLICATION_CREDENTIALS", raising=False)
|
||||
|
||||
assert GoogleTTS().get_status().value == "available"
|
||||
assert has_google_credentials() is False
|
||||
|
||||
|
||||
def test_tts_key_uses_header_and_is_redacted_from_errors(monkeypatch, tmp_path):
|
||||
import requests
|
||||
|
||||
secret = "test-production-shaped-tts-key"
|
||||
monkeypatch.setenv("GOOGLE_TTS_API_KEY", secret)
|
||||
monkeypatch.delenv("GOOGLE_API_KEY", raising=False)
|
||||
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("GOOGLE_APPLICATION_CREDENTIALS", raising=False)
|
||||
|
||||
def fail_request(url, **kwargs):
|
||||
assert kwargs["headers"]["x-goog-api-key"] == secret
|
||||
assert "params" not in kwargs
|
||||
raise requests.HTTPError(f"403 for {url}?key={secret}")
|
||||
|
||||
monkeypatch.setattr(requests, "post", fail_request)
|
||||
result = GoogleTTS().execute(
|
||||
{"text": "safe test sentence", "output_path": str(tmp_path / "speech.mp3")}
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert secret not in result.error
|
||||
assert "[REDACTED]" in result.error
|
||||
Reference in New Issue
Block a user