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
This commit is contained in:
AhmedIrfan
2026-07-26 23:31:41 -07:00
committed by GitHub
parent 1db0fdf645
commit 68ece25a80
2 changed files with 105 additions and 1 deletions
+14 -1
View File
@@ -12,7 +12,7 @@ import base64 as b64
import logging import logging
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Literal
from fastmcp import FastMCP from fastmcp import FastMCP
@@ -49,6 +49,7 @@ def register_tools(mcp: FastMCP) -> None:
engine: str | None = None, engine: str | None = None,
personality: bool | None = None, personality: bool | None = None,
language: str | None = None, language: str | None = None,
model_size: Literal["1.7B", "0.6B", "1B", "3B"] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Speak ``text`` in a voice profile. """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 LLM before TTS. When omitted, the per-client binding's
``default_personality`` flag decides; when that is unset, the ``default_personality`` flag decides; when that is unset, the
default is plain TTS. 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 from ..database.models import MCPClientBinding
@@ -99,6 +106,7 @@ def register_tools(mcp: FastMCP) -> None:
engine=resolved_engine, engine=resolved_engine,
language=language, language=language,
personality=use_persona, personality=use_persona,
model_size=model_size,
db=db, db=db,
) )
finally: finally:
@@ -228,18 +236,23 @@ async def _speak(
engine: str | None, engine: str | None,
language: str | None, language: str | None,
personality: bool, personality: bool,
model_size: str | None = None,
db, db,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Delegate to POST /generate — the route handles personality-rewrite """Delegate to POST /generate — the route handles personality-rewrite
internally when ``personality=true`` and the profile has a prompt.""" internally when ``personality=true`` and the profile has a prompt."""
from ..routes.generations import generate_speech 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( req = models.GenerationRequest(
profile_id=profile_id, profile_id=profile_id,
text=text, text=text,
language=language or "en", language=language or "en",
engine=engine, engine=engine,
personality=personality, personality=personality,
model_size=model_size,
) )
generation = await generate_speech(req, db) generation = await generate_speech(req, db)
return _speak_response(generation, profile_name, source="mcp") return _speak_response(generation, profile_name, source="mcp")
+91
View File
@@ -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