From 68ece25a80a324596d82d45f5bd3efa31bf378a9 Mon Sep 17 00:00:00 2001 From: AhmedIrfan Date: Mon, 27 Jul 2026 11:31:41 +0500 Subject: [PATCH] fix(mcp): add model_size parameter to voicebox.speak (#895) The MCP voicebox.speak tool built its GenerationRequest without a model_size, so every agent-triggered generation fell back to the schema default ("1.7B"). There was no way to reach the 0.6B Qwen variant (or TADA's 1B/3B) through MCP, and callers paid a model reload whenever the requested size differed from what was already loaded. Thread an optional model_size through voicebox.speak and the _speak helper into GenerationRequest, mirroring the REST /generate surface. Omitting it passes None, which generate_speech normalizes to the engine default, so existing callers are unaffected. Add backend/tests/test_mcp_speak.py covering the forwarded value, the omitted-default path, and rejection of an invalid size. Fixes #884 --- backend/mcp_server/tools.py | 15 +++++- backend/tests/test_mcp_speak.py | 91 +++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_mcp_speak.py diff --git a/backend/mcp_server/tools.py b/backend/mcp_server/tools.py index fcf3b6a2..093e57ae 100644 --- a/backend/mcp_server/tools.py +++ b/backend/mcp_server/tools.py @@ -12,7 +12,7 @@ import base64 as b64 import logging import tempfile from pathlib import Path -from typing import Any +from typing import Any, Literal from fastmcp import FastMCP @@ -49,6 +49,7 @@ def register_tools(mcp: FastMCP) -> None: engine: str | None = None, personality: bool | None = None, language: str | None = None, + model_size: Literal["1.7B", "0.6B", "1B", "3B"] | None = None, ) -> dict[str, Any]: """Speak ``text`` in a voice profile. @@ -61,6 +62,12 @@ def register_tools(mcp: FastMCP) -> None: LLM before TTS. When omitted, the per-client binding's ``default_personality`` flag decides; when that is unset, the default is plain TTS. + + ``model_size`` selects a model variant for engines that ship more + than one — ``qwen`` and ``qwen_custom_voice`` accept "1.7B" (default) + or "0.6B"; ``tada`` accepts "1B" or "3B". Other engines ignore it. + Omit to use the engine default. Requesting a smaller variant (e.g. + "0.6B") is faster and avoids reloading a heavier model between calls. """ from ..database.models import MCPClientBinding @@ -99,6 +106,7 @@ def register_tools(mcp: FastMCP) -> None: engine=resolved_engine, language=language, personality=use_persona, + model_size=model_size, db=db, ) finally: @@ -228,18 +236,23 @@ async def _speak( engine: str | None, language: str | None, personality: bool, + model_size: str | None = None, db, ) -> dict[str, Any]: """Delegate to POST /generate — the route handles personality-rewrite internally when ``personality=true`` and the profile has a prompt.""" from ..routes.generations import generate_speech + # model_size=None is intentional: generate_speech normalizes it to the + # engine default (see routes/generations.py), so an omitted size behaves + # exactly like the REST /generate endpoint with no model_size in the body. req = models.GenerationRequest( profile_id=profile_id, text=text, language=language or "en", engine=engine, personality=personality, + model_size=model_size, ) generation = await generate_speech(req, db) return _speak_response(generation, profile_name, source="mcp") diff --git a/backend/tests/test_mcp_speak.py b/backend/tests/test_mcp_speak.py new file mode 100644 index 00000000..ae79506c --- /dev/null +++ b/backend/tests/test_mcp_speak.py @@ -0,0 +1,91 @@ +"""Tests for the voicebox.speak MCP tool's ``model_size`` plumbing (issue #884). + +The MCP speak path used to build its ``GenerationRequest`` without a +``model_size``, so every agent-triggered generation silently fell back to the +schema default ("1.7B") — there was no way to reach 0.6B (or TADA's 1B/3B) +through MCP. These tests pin the fix: ``_speak`` now forwards ``model_size`` +straight into the request, matching the REST ``/generate`` surface. +""" + +import pytest +from pydantic import ValidationError + +import backend.routes.generations as generations +from backend.mcp_server import tools + + +class _FakeGeneration: + """Minimal stand-in for GenerationResponse consumed by ``_speak_response``.""" + + def model_dump(self, mode="json"): + return {"id": "gen-test", "status": "generating"} + + +@pytest.fixture +def captured_request(monkeypatch): + """Replace the real (torch-backed) generate_speech with a capturing stub. + + ``_speak`` imports ``generate_speech`` lazily from ``routes.generations``, + so patching the attribute on that module intercepts the call and lets us + inspect the ``GenerationRequest`` it would have run. + """ + captured = {} + + async def fake_generate_speech(req, db): + captured["req"] = req + return _FakeGeneration() + + monkeypatch.setattr(generations, "generate_speech", fake_generate_speech) + # Isolate the unit from the MCP event bus — _speak_response fires a + # speak-start event we don't care about here. + monkeypatch.setattr(tools.mcp_events, "publish", lambda *a, **k: None) + return captured + + +@pytest.mark.asyncio +async def test_speak_forwards_explicit_model_size(captured_request): + await tools._speak( + profile_id="p1", + profile_name="Morgan", + text="hello", + engine="qwen", + language="en", + personality=False, + model_size="0.6B", + db=None, + ) + assert captured_request["req"].model_size == "0.6B" + + +@pytest.mark.asyncio +async def test_speak_omitted_model_size_is_none(captured_request): + # Omitted → None; generate_speech normalizes None to the engine default, + # so this reproduces the pre-fix behaviour for callers that don't ask. + await tools._speak( + profile_id="p1", + profile_name="Morgan", + text="hello", + engine="qwen", + language="en", + personality=False, + db=None, + ) + assert captured_request["req"].model_size is None + + +@pytest.mark.asyncio +async def test_speak_rejects_invalid_model_size(captured_request): + # The GenerationRequest schema pattern is the single source of truth for + # valid sizes; a bad value is rejected before any generation runs. + with pytest.raises(ValidationError): + await tools._speak( + profile_id="p1", + profile_name="Morgan", + text="hello", + engine="qwen", + language="en", + personality=False, + model_size="9B", + db=None, + ) + assert "req" not in captured_request