Skip to content

Commit 82430cf

Browse files
committed
fix(mcp): add model_size parameter to voicebox.speak
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
1 parent f2cf2a7 commit 82430cf

2 files changed

Lines changed: 105 additions & 1 deletion

File tree

backend/mcp_server/tools.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import logging
1313
import tempfile
1414
from pathlib import Path
15-
from typing import Any
15+
from typing import Any, Literal
1616

1717
from fastmcp import FastMCP
1818

@@ -49,6 +49,7 @@ async def voicebox_speak(
4949
engine: str | None = None,
5050
personality: bool | None = None,
5151
language: str | None = None,
52+
model_size: Literal["1.7B", "0.6B", "1B", "3B"] | None = None,
5253
) -> dict[str, Any]:
5354
"""Speak ``text`` in a voice profile.
5455
@@ -61,6 +62,12 @@ async def voicebox_speak(
6162
LLM before TTS. When omitted, the per-client binding's
6263
``default_personality`` flag decides; when that is unset, the
6364
default is plain TTS.
65+
66+
``model_size`` selects a model variant for engines that ship more
67+
than one — ``qwen`` and ``qwen_custom_voice`` accept "1.7B" (default)
68+
or "0.6B"; ``tada`` accepts "1B" or "3B". Other engines ignore it.
69+
Omit to use the engine default. Requesting a smaller variant (e.g.
70+
"0.6B") is faster and avoids reloading a heavier model between calls.
6471
"""
6572
from ..database.models import MCPClientBinding
6673

@@ -99,6 +106,7 @@ async def voicebox_speak(
99106
engine=resolved_engine,
100107
language=language,
101108
personality=use_persona,
109+
model_size=model_size,
102110
db=db,
103111
)
104112
finally:
@@ -228,18 +236,23 @@ async def _speak(
228236
engine: str | None,
229237
language: str | None,
230238
personality: bool,
239+
model_size: str | None = None,
231240
db,
232241
) -> dict[str, Any]:
233242
"""Delegate to POST /generate — the route handles personality-rewrite
234243
internally when ``personality=true`` and the profile has a prompt."""
235244
from ..routes.generations import generate_speech
236245

246+
# model_size=None is intentional: generate_speech normalizes it to the
247+
# engine default (see routes/generations.py), so an omitted size behaves
248+
# exactly like the REST /generate endpoint with no model_size in the body.
237249
req = models.GenerationRequest(
238250
profile_id=profile_id,
239251
text=text,
240252
language=language or "en",
241253
engine=engine,
242254
personality=personality,
255+
model_size=model_size,
243256
)
244257
generation = await generate_speech(req, db)
245258
return _speak_response(generation, profile_name, source="mcp")

backend/tests/test_mcp_speak.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"""Tests for the voicebox.speak MCP tool's ``model_size`` plumbing (issue #884).
2+
3+
The MCP speak path used to build its ``GenerationRequest`` without a
4+
``model_size``, so every agent-triggered generation silently fell back to the
5+
schema default ("1.7B") — there was no way to reach 0.6B (or TADA's 1B/3B)
6+
through MCP. These tests pin the fix: ``_speak`` now forwards ``model_size``
7+
straight into the request, matching the REST ``/generate`` surface.
8+
"""
9+
10+
import pytest
11+
from pydantic import ValidationError
12+
13+
import backend.routes.generations as generations
14+
from backend.mcp_server import tools
15+
16+
17+
class _FakeGeneration:
18+
"""Minimal stand-in for GenerationResponse consumed by ``_speak_response``."""
19+
20+
def model_dump(self, mode="json"):
21+
return {"id": "gen-test", "status": "generating"}
22+
23+
24+
@pytest.fixture
25+
def captured_request(monkeypatch):
26+
"""Replace the real (torch-backed) generate_speech with a capturing stub.
27+
28+
``_speak`` imports ``generate_speech`` lazily from ``routes.generations``,
29+
so patching the attribute on that module intercepts the call and lets us
30+
inspect the ``GenerationRequest`` it would have run.
31+
"""
32+
captured = {}
33+
34+
async def fake_generate_speech(req, db):
35+
captured["req"] = req
36+
return _FakeGeneration()
37+
38+
monkeypatch.setattr(generations, "generate_speech", fake_generate_speech)
39+
# Isolate the unit from the MCP event bus — _speak_response fires a
40+
# speak-start event we don't care about here.
41+
monkeypatch.setattr(tools.mcp_events, "publish", lambda *a, **k: None)
42+
return captured
43+
44+
45+
@pytest.mark.asyncio
46+
async def test_speak_forwards_explicit_model_size(captured_request):
47+
await tools._speak(
48+
profile_id="p1",
49+
profile_name="Morgan",
50+
text="hello",
51+
engine="qwen",
52+
language="en",
53+
personality=False,
54+
model_size="0.6B",
55+
db=None,
56+
)
57+
assert captured_request["req"].model_size == "0.6B"
58+
59+
60+
@pytest.mark.asyncio
61+
async def test_speak_omitted_model_size_is_none(captured_request):
62+
# Omitted → None; generate_speech normalizes None to the engine default,
63+
# so this reproduces the pre-fix behaviour for callers that don't ask.
64+
await tools._speak(
65+
profile_id="p1",
66+
profile_name="Morgan",
67+
text="hello",
68+
engine="qwen",
69+
language="en",
70+
personality=False,
71+
db=None,
72+
)
73+
assert captured_request["req"].model_size is None
74+
75+
76+
@pytest.mark.asyncio
77+
async def test_speak_rejects_invalid_model_size(captured_request):
78+
# The GenerationRequest schema pattern is the single source of truth for
79+
# valid sizes; a bad value is rejected before any generation runs.
80+
with pytest.raises(ValidationError):
81+
await tools._speak(
82+
profile_id="p1",
83+
profile_name="Morgan",
84+
text="hello",
85+
engine="qwen",
86+
language="en",
87+
personality=False,
88+
model_size="9B",
89+
db=None,
90+
)
91+
assert "req" not in captured_request

0 commit comments

Comments
 (0)