fix: a speech model is added with its voices, and Orpheus is sent where it works

An Orpheus id (groq-orpheus-english) did not start with "local-", so
generate_tts_audio sent it down the OpenAI path and every call failed. It
also could be added with no voice, and the "local-%" filters in /tts/voices
and the default lookup hid any non-local voice from learners even when it
was added and marked default.

services/tts_voices.py is the one table of which voices belong to which
model (Kokoro, Orpheus English/Arabic, Fish), the same table the scribe app
keeps. Anything the table knows, or anything local-*, goes through the
LiteLLM gateway with the options its family needs (Orpheus: wav). Adding a
bare model id creates one row per voice with friendly names, so an
administrator adds "groq-orpheus-english" and six voices appear to test one
by one; a voice from another family is refused, naming the ones that work.
Learners are offered every active voice, each saying which model serves it,
and /tts/speak answers with the media type the model actually returned.
Kitten and Supertonic tables go — those models left the gateway.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-13 05:04:37 +02:00
parent 6312bf7a00
commit 954b13e7b3
5 changed files with 383 additions and 56 deletions

View file

@ -14,7 +14,7 @@ from app.database import get_db
from app.models.user import User
from app.models.ai_model_config import AIModelConfig
from app.models.invite import InviteCode
from app.services import ai_service, invites, site_settings
from app.services import ai_service, invites, site_settings, tts_voices
from app.schemas.auth import UserResponse, UserUpdateRole, UserCreate
from app.schemas.admin import AIModelConfigCreate, AIModelConfigResponse, AIModelConfigUpdate
from app.utils.auth import require_admin, get_current_user, get_password_hash
@ -244,6 +244,20 @@ def create_model(
if data.task not in valid_tasks:
raise HTTPException(status_code=400, detail=f"Task must be one of: {', '.join(valid_tasks)}")
if data.task == "tts":
if not tts_voices.accepts(data.model_id):
model_part, voice_part = tts_voices.split(data.model_id)
raise HTTPException(
status_code=400,
detail=f"{model_part} does not accept the voice {voice_part}. It accepts: "
+ ", ".join(v for v, _ in tts_voices.voices_for(model_part)))
# A speech model is added with its voices: "groq-orpheus-english"
# becomes one row per voice the model accepts, so the administrator
# adds a model and the voices appear, ready to be tested one by one.
rows = tts_voices.expand(data.model_id, data.name if data.name != data.model_id else None)
if len(rows) > 1:
return _add_voice_rows(db, data, rows)
if data.is_default:
db.query(AIModelConfig).filter(
AIModelConfig.task == data.task,
@ -273,6 +287,32 @@ def create_model(
return model
def _add_voice_rows(db: Session, data: AIModelConfigCreate, rows: list[tuple[str, str]]) -> AIModelConfig:
"""One row per voice; the first becomes default only if the task had none.
Returns the first row, which is what the caller shows. Voices already
present are left as they are rather than refused, so adding a model twice
fills in whatever was missing.
"""
existing = {m.model_id for m in db.query(AIModelConfig).filter(AIModelConfig.task == "tts").all()}
has_default = db.query(AIModelConfig).filter(
AIModelConfig.task == "tts", AIModelConfig.is_active == True, AIModelConfig.is_default == True,
).count() > 0
first = None
for model_id, name in rows:
if model_id in existing:
continue
row = AIModelConfig(name=name, model_id=model_id, task="tts", api_key=data.api_key,
is_active=data.is_active, is_default=not has_default and first is None)
db.add(row)
first = first or row
if first is None:
raise HTTPException(status_code=409, detail=f"Every voice of '{data.model_id}' is already added")
db.commit()
db.refresh(first)
return first
@router.put("/models/{model_id}", response_model=AIModelConfigResponse)
def update_model(
model_id: int,
@ -423,44 +463,10 @@ class TTSVoiceSearchRequest(BaseModel):
region: str | None = None
KOKORO_VOICE_FALLBACKS = [
("am_adam", "Kokoro Adam"),
("am_michael", "Kokoro Michael"),
("af_bella", "Kokoro Bella"),
("af_nicole", "Kokoro Nicole"),
("bf_emma", "Kokoro Emma"),
("bm_lewis", "Kokoro Lewis"),
]
KITTEN_VOICE_FALLBACKS = [
("Bella", "Kitten Bella"),
("Jasper", "Kitten Jasper"),
("Luna", "Kitten Luna"),
("Bruno", "Kitten Bruno"),
("Rosie", "Kitten Rosie"),
("Hugo", "Kitten Hugo"),
("Kiki", "Kitten Kiki"),
("Leo", "Kitten Leo"),
]
SUPERTONIC_STYLE_FALLBACKS = [
("F1", "Supertonic F1"),
("F2", "Supertonic F2"),
("F3", "Supertonic F3"),
("F4", "Supertonic F4"),
("F5", "Supertonic F5"),
("M1", "Supertonic M1"),
("M2", "Supertonic M2"),
("M3", "Supertonic M3"),
("M4", "Supertonic M4"),
("M5", "Supertonic M5"),
]
def _kokoro_voice_options(model_name: str) -> list[dict]:
base = settings.LOCAL_SPEECH_GATEWAY_URL.rstrip("/")
voices = []
friendly_names = {voice_id: name for voice_id, name in KOKORO_VOICE_FALLBACKS}
friendly_names = {voice_id: name for voice_id, name in tts_voices.KOKORO_VOICES}
if base:
try:
resp = httpx.get(f"{base}/v1/audio/voices", timeout=10)
@ -475,7 +481,7 @@ def _kokoro_voice_options(model_name: str) -> list[dict]:
if not voices:
voices = [
{"voice": voice_id, "name": name, "profile": "kokoro"}
for voice_id, name in KOKORO_VOICE_FALLBACKS
for voice_id, name in tts_voices.KOKORO_VOICES
]
return [
@ -532,11 +538,12 @@ def search_tts_voices(
if model_name == "local-kokoro-tts":
voices.extend(_kokoro_voice_options(model_name))
continue
if model_name == "local-kitten-tts":
voices.extend(_static_voice_options(model_name, KITTEN_VOICE_FALLBACKS))
continue
if model_name == "local-supertonic-tts":
voices.extend(_static_voice_options(model_name, SUPERTONIC_STYLE_FALLBACKS))
# Kitten and Supertonic were retired from the gateway; Orpheus
# and Fish are what it proxies now, and their voices are the
# family table's.
known = tts_voices.voices_for(model_name)
if known:
voices.extend(_static_voice_options(model_name, known))
continue
voices.append({
"model_id": model_name,

View file

@ -6,7 +6,7 @@ from sqlalchemy.orm import Session
from app.database import get_db
from app.models.user import User
from app.models.ai_model_config import AIModelConfig
from app.services import ai_service
from app.services import ai_service, tts_voices
from app.utils.auth import get_current_user, check_rate_limit
router = APIRouter()
@ -32,11 +32,13 @@ def _task_model(db: Session, task: str, fallback: str) -> tuple[str, str | None]
def _default_tts_model(db: Session) -> tuple[str, str | None]:
# Whatever the administrator marked default. The "local-%" filter that
# used to sit here meant an Orpheus default was ignored in favour of the
# hard-coded Kokoro voice below, silently.
config = db.query(AIModelConfig).filter(
AIModelConfig.task == "tts",
AIModelConfig.is_active == True,
AIModelConfig.is_default == True,
AIModelConfig.model_id.like("local-%"),
).first()
if config:
return config.model_id, config.api_key or None
@ -48,14 +50,18 @@ def get_voices(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return LiteLLM-routed TTS models only."""
"""Every active voice, each saying which model serves it.
All of them, not only the locally served ones: a learner is offered every
voice an administrator added, whichever model it belongs to.
"""
query = db.query(AIModelConfig).filter(
AIModelConfig.task == "tts",
AIModelConfig.is_active == True,
AIModelConfig.model_id.like("local-%"),
)
db_models = query.order_by(AIModelConfig.is_default.desc(), AIModelConfig.name).all()
return [{"id": m.model_id, "name": m.name, "is_default": m.is_default} for m in db_models]
return [{"id": m.model_id, "name": m.name, "is_default": m.is_default,
"model": tts_voices.split(m.model_id)[0]} for m in db_models]
@router.post("/speak")
@ -101,7 +107,7 @@ def text_to_speech(
if audio is None:
raise HTTPException(status_code=500, detail="TTS generation failed. Check model configuration.")
return Response(content=audio, media_type="audio/mpeg")
return Response(content=audio, media_type=tts_voices.media_type(model_id))
@router.post("/transcribe")

View file

@ -5,6 +5,7 @@ import os
import httpx
from app.config import settings
from app.services import tts_voices
logger = logging.getLogger(__name__)
@ -504,12 +505,14 @@ def generate_tts_audio(
use_model = model_id or "tts-1:alloy"
# ── Local LiteLLM TTS models ─────────────────────────────────
if use_model.startswith("local-"):
local_model = use_model
local_voice = "am_adam" if use_model.startswith("local-kokoro-tts") else "alloy"
if ":" in use_model:
local_model, local_voice = use_model.split(":", 1)
# ── LiteLLM TTS models ───────────────────────────────────────
# local-* and every family tts_voices knows (Orpheus, Fish). Orpheus used
# to miss this branch for not starting with "local-" and was sent down the
# OpenAI path, where it failed on every call.
if tts_voices.is_litellm_tts(use_model):
local_model, local_voice = tts_voices.split(use_model)
if not local_voice:
local_voice = tts_voices.default_voice(local_model)
key = api_key or settings.LITELLM_API_KEY
base = (settings.LITELLM_API_BASE or "").rstrip("/").removesuffix("/v1")
if not base:
@ -519,13 +522,13 @@ def generate_tts_audio(
resp = httpx.post(
f"{base}/v1/audio/speech",
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"} if key else {"Content-Type": "application/json"},
json={"model": local_model, "input": text, "voice": local_voice, "response_format": "mp3"},
json={"model": local_model, "input": text, "voice": local_voice, **tts_voices.request_options(local_model)},
timeout=90,
)
resp.raise_for_status()
return resp.content
except Exception as e:
logger.error(f"Local LiteLLM TTS failed: {e}")
logger.error(f"LiteLLM TTS failed for {local_model}:{local_voice}: {e}")
return None
# ── Local Sherpa gateway ───────────────────────────────────

View file

@ -0,0 +1,146 @@
"""Which voices belong to which LiteLLM speech model.
A voice is a property of a model, not of the gateway, and the two used to be
treated as interchangeable: a model id like ``groq-orpheus-english`` could be
added with no voice at all, and ``generate_tts_audio`` then sent it down the
OpenAI path because it did not start with ``local-``. Every one of those calls
failed, which is what "voice is not set up properly" was.
The gateway cannot help: ``/model/info`` reports ``audio_speech`` for all of
these and carries no voice field. So the mapping lives here, keyed by family,
and every list was taken from the provider rather than from documentation
Groq names its own when refused ("voice must be one of the following voices:
[...]"), Fish accepts ``alloy`` and refuses the other OpenAI names, and
Kokoro's come from the speech gateway's ``/v1/audio/voices`` (admin.py asks it
directly and prefers that answer whenever it arrives).
The same table lives in the scribe app (src/utils/ttsProvider.js). Keep the
two in step when a model is added to the gateway.
Model ids keep the app's ``model:voice`` convention (``local-kokoro-tts:am_adam``).
"""
KOKORO_VOICES = [
("am_adam", "Kokoro Adam"), ("am_michael", "Kokoro Michael"),
("af_bella", "Kokoro Bella"), ("af_nicole", "Kokoro Nicole"),
("bf_emma", "Kokoro Emma"), ("bm_lewis", "Kokoro Lewis"),
]
ORPHEUS_ENGLISH_VOICES = [
("autumn", "Orpheus Autumn"), ("diana", "Orpheus Diana"), ("hannah", "Orpheus Hannah"),
("austin", "Orpheus Austin"), ("daniel", "Orpheus Daniel"), ("troy", "Orpheus Troy"),
]
ORPHEUS_ARABIC_VOICES = [
("abdullah", "Orpheus Abdullah"), ("fahad", "Orpheus Fahad"), ("sultan", "Orpheus Sultan"),
("lulwa", "Orpheus Lulwa"), ("noura", "Orpheus Noura"), ("aisha", "Orpheus Aisha"),
]
FISH_VOICES = [("alloy", "Fish Alloy")]
#: family → (gateway ids that belong to it, its voices, options the request needs)
FAMILIES = {
"kokoro": {
"ids": ("local-kokoro-tts",),
"voices": KOKORO_VOICES,
"request": {"response_format": "mp3"},
"media_type": "audio/mpeg",
},
"orpheus-english": {
"ids": ("groq-orpheus-english", "canopylabs/orpheus-v1-english"),
"voices": ORPHEUS_ENGLISH_VOICES,
# Groq refuses mp3 for Orpheus; wav is what it returns.
"request": {"response_format": "wav"},
"media_type": "audio/wav",
},
"orpheus-arabic": {
"ids": ("groq-orpheus-arabic-saudi", "canopylabs/orpheus-arabic-saudi"),
"voices": ORPHEUS_ARABIC_VOICES,
"request": {"response_format": "wav"},
"media_type": "audio/wav",
},
"fish": {
"ids": ("openrouter-fish-s2.1-pro-tts", "fish-audio/s2.1-pro"),
"voices": FISH_VOICES,
"request": {"response_format": "mp3"},
"media_type": "audio/mpeg",
},
}
def split(model_id: str | None) -> tuple[str, str]:
"""``local-kokoro-tts:am_adam`` → (``local-kokoro-tts``, ``am_adam``); no colon → (id, '')."""
text = (model_id or "").strip()
if ":" in text:
model, voice = text.split(":", 1)
return model.strip(), voice.strip()
return text, ""
def family(model: str | None) -> str | None:
model, _ = split(model)
key = model.lower()
for name, spec in FAMILIES.items():
if key in spec["ids"]:
return name
return None
def is_litellm_tts(model_id: str | None) -> bool:
"""Routed through the LiteLLM gateway's /v1/audio/speech.
``local-*`` is the gateway's own convention for models it serves itself;
a known family is one it proxies. Both take the same request.
"""
model, _ = split(model_id)
return model.startswith("local-") or family(model) is not None
def voices_for(model: str | None) -> list[tuple[str, str]]:
"""(voice, friendly name) pairs the model accepts, or [] when unknown."""
spec = FAMILIES.get(family(model) or "")
return list(spec["voices"]) if spec else []
def default_voice(model: str | None) -> str:
voices = voices_for(model)
if voices:
return voices[0][0]
# A local model we have no list for still needs something the gateway
# will take; alloy is what an OpenAI-shaped server defaults to.
return "alloy"
def request_options(model_id: str | None) -> dict:
spec = FAMILIES.get(family(model_id) or "")
return dict(spec["request"]) if spec else {"response_format": "mp3"}
def media_type(model_id: str | None) -> str:
spec = FAMILIES.get(family(model_id) or "")
return spec["media_type"] if spec else "audio/mpeg"
def accepts(model_id: str) -> bool:
"""A ``model:voice`` pair the model will not refuse.
A bare id whose family we know is fine (it gets its first voice); a voice
from another family is the one thing this says no to.
"""
model, voice = split(model_id)
known = voices_for(model)
if not voice or not known:
return True
return voice in {v for v, _ in known}
def expand(model_id: str, name: str | None = None) -> list[tuple[str, str]]:
"""The rows adding ``model_id`` should create: one per voice.
Adding ``groq-orpheus-english`` yields six ``groq-orpheus-english:<voice>``
rows with friendly names, so the admin's "add a model" is what populates
the voices nobody types six ids. An id that already names a voice, or a
model with no known list, is one row as given.
"""
model, voice = split(model_id)
known = voices_for(model)
if voice or not known:
return [(model_id, name or model_id)]
return [(f"{model}:{v}", label) for v, label in known]

View file

@ -0,0 +1,165 @@
"""A voice belongs to a model, and a model is added with its voices.
Disposable SQLite. The rules worth pinning: an Orpheus id is routed through
the LiteLLM gateway (it used to fall through to the OpenAI path for not
starting with "local-"), a bare model id becomes one row per voice, a voice
from another family is refused with the list that would work, and learners
are offered every active voice not only the locally served ones.
"""
import unittest
from unittest.mock import Mock, patch
import test_quiz_builder as fixtures # noqa: F401 (sets DATABASE_URL before app imports)
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.database import Base, get_db
from app.models.ai_model_config import AIModelConfig
from app.models.user import User
from app.routers import admin, tts
from app.services import ai_service, tts_voices
from app.utils.auth import get_current_user, require_admin
class VoiceTableTests(unittest.TestCase):
def test_families_and_their_voices(self):
self.assertEqual(tts_voices.family("groq-orpheus-english:hannah"), "orpheus-english")
self.assertEqual(tts_voices.family("canopylabs/orpheus-arabic-saudi"), "orpheus-arabic")
self.assertEqual(tts_voices.family("openrouter-fish-s2.1-pro-tts"), "fish")
self.assertEqual(tts_voices.family("local-kokoro-tts:am_adam"), "kokoro")
self.assertIsNone(tts_voices.family("tts-1:alloy"))
self.assertEqual([v for v, _ in tts_voices.voices_for("groq-orpheus-english")],
["autumn", "diana", "hannah", "austin", "daniel", "troy"])
self.assertEqual(tts_voices.voices_for("openrouter-fish-s2.1-pro-tts"), [("alloy", "Fish Alloy")])
self.assertEqual(tts_voices.voices_for("something-new"), [])
def test_litellm_routing_and_request_shape(self):
self.assertTrue(tts_voices.is_litellm_tts("groq-orpheus-english:daniel"))
self.assertTrue(tts_voices.is_litellm_tts("local-anything:x"))
self.assertFalse(tts_voices.is_litellm_tts("tts-1:alloy"))
self.assertFalse(tts_voices.is_litellm_tts("elevenlabs/rachel"))
# Groq refuses mp3 for Orpheus; the gateway's own models take mp3.
self.assertEqual(tts_voices.request_options("groq-orpheus-english"), {"response_format": "wav"})
self.assertEqual(tts_voices.media_type("groq-orpheus-english:troy"), "audio/wav")
self.assertEqual(tts_voices.request_options("local-kokoro-tts:af_bella"), {"response_format": "mp3"})
self.assertEqual(tts_voices.media_type("local-kokoro-tts"), "audio/mpeg")
def test_accepts_refuses_only_another_familys_voice(self):
self.assertTrue(tts_voices.accepts("groq-orpheus-english:hannah"))
self.assertFalse(tts_voices.accepts("groq-orpheus-english:am_adam"))
self.assertFalse(tts_voices.accepts("local-kokoro-tts:hannah"))
self.assertTrue(tts_voices.accepts("groq-orpheus-english")) # gets its first voice
self.assertTrue(tts_voices.accepts("local-newmodel-tts:whatever")) # no list: nothing to refuse
def test_expand_makes_one_row_per_voice(self):
rows = tts_voices.expand("groq-orpheus-arabic-saudi")
self.assertEqual(len(rows), 6)
self.assertEqual(rows[0], ("groq-orpheus-arabic-saudi:abdullah", "Orpheus Abdullah"))
# An id that already names a voice, or a model we have no list for, is one row as given.
self.assertEqual(tts_voices.expand("local-kokoro-tts:am_adam", "Kokoro Adam"), [("local-kokoro-tts:am_adam", "Kokoro Adam")])
self.assertEqual(tts_voices.expand("local-newmodel-tts"), [("local-newmodel-tts", "local-newmodel-tts")])
class GenerateAudioRoutingTests(unittest.TestCase):
def _post(self, model_id):
calls = []
def fake_post(url, **kw):
calls.append((url, kw.get("json")))
resp = Mock(); resp.content = b"audio"; resp.raise_for_status = Mock()
return resp
with patch.object(ai_service.settings, "LITELLM_API_BASE", "http://gateway:4000/v1"), \
patch.object(ai_service.settings, "LITELLM_API_KEY", "k"), \
patch.object(ai_service.httpx, "post", side_effect=fake_post):
out = ai_service.generate_tts_audio("Stridor.", model_id=model_id)
return out, calls
def test_orpheus_goes_to_the_gateway_as_wav(self):
out, calls = self._post("groq-orpheus-english:hannah")
self.assertEqual(out, b"audio")
url, body = calls[0]
self.assertEqual(url, "http://gateway:4000/v1/audio/speech")
self.assertEqual(body, {"model": "groq-orpheus-english", "input": "Stridor.", "voice": "hannah", "response_format": "wav"})
def test_bare_model_gets_its_first_voice(self):
_, calls = self._post("groq-orpheus-english")
self.assertEqual(calls[0][1]["voice"], "autumn")
_, calls = self._post("local-kokoro-tts")
self.assertEqual(calls[0][1]["voice"], "am_adam")
self.assertEqual(calls[0][1]["response_format"], "mp3")
class AdminAddsAModelWithItsVoicesTests(unittest.TestCase):
def setUp(self):
self.engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(self.engine)
self.db = Session(self.engine)
self.admin = User(id=1, name="Admin", email="admin@example.test", hashed_password="x", role="admin")
self.db.add(self.admin); self.db.commit()
app = FastAPI()
app.include_router(admin.router, prefix="/admin")
app.include_router(tts.router, prefix="/tts")
app.dependency_overrides[get_db] = lambda: self.db
app.dependency_overrides[require_admin] = lambda: self.admin
app.dependency_overrides[get_current_user] = lambda: self.admin
self.client = TestClient(app)
def tearDown(self):
self.client.close()
def _add(self, model_id, **extra):
return self.client.post("/admin/models", json={"name": model_id, "model_id": model_id, "task": "tts", **extra})
def _rows(self):
return [(m.model_id, m.name, m.is_default) for m in
self.db.query(AIModelConfig).filter(AIModelConfig.task == "tts").order_by(AIModelConfig.id).all()]
def test_a_bare_speech_model_becomes_its_voices(self):
res = self._add("groq-orpheus-english")
self.assertEqual(res.status_code, 200, res.text)
rows = self._rows()
self.assertEqual([r[0] for r in rows], [f"groq-orpheus-english:{v}" for v in ["autumn", "diana", "hannah", "austin", "daniel", "troy"]])
self.assertEqual(rows[2][1], "Orpheus Hannah")
# The first voice is default only because the task had none.
self.assertEqual([r[2] for r in rows], [True, False, False, False, False, False])
self.assertEqual(res.json()["model_id"], "groq-orpheus-english:autumn")
def test_adding_a_second_model_keeps_the_existing_default(self):
self._add("local-kokoro-tts:am_adam", is_default=True)
self._add("groq-orpheus-english")
rows = self._rows()
self.assertEqual([r[0] for r in rows if r[2]], ["local-kokoro-tts:am_adam"])
self.assertEqual(len(rows), 7)
# Again: nothing new to add.
self.assertEqual(self._add("groq-orpheus-english").status_code, 409)
def test_a_voice_from_another_family_is_refused_with_the_right_list(self):
res = self._add("local-kokoro-tts:hannah")
self.assertEqual(res.status_code, 400)
self.assertIn("It accepts: am_adam, am_michael", res.json()["detail"])
self.assertEqual(self._rows(), [])
def test_learners_are_offered_every_active_voice(self):
self._add("local-kokoro-tts:am_adam")
self._add("groq-orpheus-english")
offered = self.client.get("/tts/voices").json()
self.assertEqual(len(offered), 7)
self.assertEqual({v["model"] for v in offered}, {"local-kokoro-tts", "groq-orpheus-english"})
self.assertEqual(offered[0], {"id": "local-kokoro-tts:am_adam", "name": "local-kokoro-tts:am_adam", "is_default": True, "model": "local-kokoro-tts"})
def test_speak_answers_with_the_media_type_the_model_returns(self):
self._add("groq-orpheus-english")
with patch.object(tts.ai_service, "generate_tts_audio", return_value=b"RIFF") as gen, \
patch.object(tts, "check_rate_limit", lambda **kw: None):
res = self.client.post("/tts/speak", json={"text": "Stridor.", "voice": "groq-orpheus-english:troy"})
self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers["content-type"], "audio/wav")
self.assertEqual(gen.call_args.kwargs["model_id"], "groq-orpheus-english:troy")
if __name__ == "__main__":
unittest.main()