Quick reference
Create, and only these:model-server/models.yaml (same id as the folder), then <SLOT>_MODEL=<id> in .env.
Never touch: compose.model-server.yml, gateway/, scripts/start-model-server.sh — the model picker menu is built by listing folders, not hand-maintained.
To also make it selectable by an agent (STT/TTS/LLM vendor, not just the raw container): see Adding an AI provider — Local providers. The model-server folder and the apps/providers/local/<name>/ folder are two separate steps — this page only covers the former.
The two steps
- A folder,
<slot>/<id>/, containing aDockerfile. - An entry in
models.yamlwith the sameid.
<SLOT>_MODEL=<id> in .env. Nothing in compose.model-server.yml or gateway/ changes, ever. The new folder appears in scripts/start-model-server.sh’s menu on its own, because that menu is built by listing folders.
A catalogue entry is minimal:
status is ready — the folder exists with a Dockerfile in it, so <KIND>_MODEL=<id> will deploy it — or planned, meaning chosen but not built yet.
The container contract
The container is the contract. Whatever is inside the folder, the image it builds must:
The image must also honour
PORT, so the folder is not welded to the slot’s numbering. That is the one change every vendored model folder gets.
Nothing is mandated about what a TTS model sends, only that it says so. Two TTS models here disagree on the wire — Indic Parler streams 44.1 kHz float32 under the name pcm_f32le, Orpheus streams 24 kHz signed 16-bit under OpenAI’s own name pcm — and the client decodes whichever arrives by reading the headers. A format the client cannot decode produces a clear error naming it, never silence or noise. See TTS models.
The STT row is the same principle pointed the other way. Uploads are a real audio file, not a bare PCM stream: soundfile-based models answer 415 to headerless bytes, and headerless bytes cannot state their own sample rate anyway. VoicEra was the off-spec side here — OpenAI’s transcriptions endpoint takes files — so the client wraps its buffer in a 44-byte WAV header, which costs nothing and every model reads.
/health returning 503 while a model loads is the useful behaviour, not a defect: it means “healthy” is “will answer fast”, not “the process is alive”, which is exactly what the gateway’s probe wants.
fetch.sh
A model that needs weights brings its own download step. If<slot>/<id>/fetch.sh exists, scripts/start-model-server.sh runs it — found by existence, so adding one never edits scripts/start-model-server.sh.
It must resolve paths from its own location and be safe to re-run. It runs before the build, which is the right time for a download and the wrong time for anything that needs the built image.
stt/indic-conformer/fetch.sh is the model of it: it computes its own directory, checks whether the target file already exists, and returns early if so.
- The vLLM-backed models (
llm/qwen3.5-4b,tts/orpheus,tts/indic-mio) have none — vLLM downloads its own weights from HuggingFace into thehf_cachevolume on first start. stt/indic-transcribehas none because preparing it is a download, a conversion and two verification gates, and the conversion has to run inside the built image.scripts/start-model-server.shrunsfetch.shbefore it builds, so the ordering the contract offers does not fit. Doing it by hand is honest; afetch.shthat silently could not work is not.
compose.extra.yml overlays
A folder may also containcompose.extra.yml, an overlay merged on top of the base Compose file. compose-files.sh and scripts/start-model-server.sh both find it by existence.
Two quite different needs turned out to have the same answer:
Either way the slot contract is unchanged — one service, one port, one route.
Paths in an overlay resolve against the project directory (
model-server/), not against the overlay’s own folder. additional_contexts in the base file follows a different rule — it resolves against the compose file’s directory — so the two cannot be reasoned about interchangeably.stt/indic-transcribe/compose.extra.yml spells its mounts from the project root even though the file lives inside the model folder:
compose.mps.yml, added alongside the shared one only when a daemon is really there. See Running on GPUs.
What the tests enforce
The suite runs without a GPU: the model layer is stubbed, everything else is real code.test_client_selection.py targets a voice_2_voice_server/api/services.py path that does not exist in this repo, so every test in that module is skipped here (pytest.mark.skipif), not run. It documents an older client convention (<catalogue id>-<slot> naming) that has since been superseded.The check that actually matters today — “will an agent be able to select this local model” — lives in apps/providers/local/<name>/service.py’s register_local(provider_id, gateway_model_id) call, which is_authenticated() uses to poll the gateway’s /models list. See Local providers.test_model_switching, test_model_extras and test_mps shell out to docker compose config, which interpolates without needing a running daemon; they skip if the docker CLI is absent.
Lint applies to the whole tree, with vendored model code excluded path by path in ruff.toml rather than by a blanket stt/** glob — the files written to fit a model into the slot sit inside those folders, so a blanket rule would quietly stop checking VoicEra’s own code the day someone adds a file.
A worked example
llm/qwen3.5-4b/ is the smallest complete model folder in the repo: a Dockerfile and a README, no code at all. vLLM already serves /v1/chat/completions, /v1/models and /health in the shape the gateway forwards, so there is no adapter to write.
Adding a second vLLM model is:
- Copy
llm/qwen3.5-4b/tollm/gemma-3-4b/. - Change
MODEL_ID,SERVED_NAMEandEXTRA_ARGSin the Dockerfile.SERVED_NAMEmust equal the folder name and the catalogue id — vLLM rejects any request whosemodelfield is not the name it was started with. - Add the id to
models.yamlunderllm:. - Set
LLM_MODEL=gemma-3-4bin.envanddocker compose ... up -d --build llm.
scripts/start-model-server.sh’s menu on its own.
Other folders are much larger — tts/indic-parler/ carries a paged-KV-cache engine — but the interface is the same either way. The size of the folder is a property of the model, not of the contract.
One thing scripts/start-model-server.sh still knows about a specific model: the AI4Bharat NeMo fork that indic-conformer needs. That is a build context, so Compose needs its path before the image exists, which is too early for fetch.sh. Any model that does not reference the nemo context never triggers it.