Plugins are Python packages that extend Modelship with custom inference backends. Each plugin is a self-contained uv workspace package with its own dependencies, installed on demand.
Plugins can implement any usecase — TTS, STT, chat, embeddings, translation, image generation — not just speech synthesis.
When loader: custom is set in models.yaml, Modelship imports the module named by plugin and expects it to expose a ModelPlugin class extending BasePlugin.
A plugin overrides only the create_* method(s) matching its usecase:
| Usecase | Method to override | Raw return type |
|---|---|---|
tts |
create_speech |
RawSpeechResponse or AsyncGenerator[(bytes, int), None] |
transcription |
create_transcription |
RawTranscription |
translation |
create_translation |
RawTranslation |
generate |
create_chat_completion |
RawChatCompletion or AsyncGenerator[RawChatDelta, None] |
embed |
create_embedding |
list[list[float]] |
image |
create_image_generation |
list[bytes] (PNG-encoded) |
Plugins return protocol-agnostic raw outputs. The serving wrappers in modelship/infer/custom/openai/ translate these into OpenAI-compatible responses, so a different protocol adapter (e.g. Anthropic, gRPC) could be added later without touching any plugin.
Unimplemented methods fall back to a 404 "plugin does not support this action" error.
plugins/
myplugin/
pyproject.toml
myplugin/
__init__.py
myplugin.py
[project]
name = "myplugin"
version = "0.1.0"
requires-python = "==3.12.10"
dependencies = [
# only packages unique to your plugin — see "Dependency contract" below
]
[build-system]
requires = ["uv_build"]
build-backend = "uv_build"
[tool.uv.sources]
modelship = { workspace = true }
[tool.uv.build-backend]
module-name = "myplugin"
module-root = ""Plugins assume the host environment provides modelship itself plus the full core/gpu/cpu stack: torch, torchvision, transformers, numpy, scipy, librosa, soundfile, onnxruntime[-gpu], diffusers, vllm. Do not redeclare any of these in your plugin's dependencies.
Why: plugin wheels are shipped to Ray workers via runtime_env, which installs them into a per-job venv layered over a base image that already has the core stack baked in. Redeclaring modelship or torch causes pip to either pull a second copy from PyPI (version drift, two packages on sys.path) or error on the layered install.
Declare only what's unique to your plugin — e.g. kokoro-onnx, pywhispercpp, snac. Dev-time imports of modelship.* resolve because the workspace's shared .venv always has the root modelship package installed; you don't need to declare it to get IDE/pyright support.
# plugins/myplugin/myplugin/myplugin.py
from collections.abc import AsyncGenerator
from modelship.plugins.base_plugin import BasePlugin
from modelship.infer.infer_config import ModelshipModelConfig
from modelship.openai.protocol import ErrorResponse, RawSpeechResponse
class ModelPlugin(BasePlugin):
def __init__(self, model_config: ModelshipModelConfig):
self.model_name = model_config.model
self.config = model_config.plugin_config or {}
async def start(self):
# load your model here
pass
async def create_speech(
self,
input: str,
voice: str | None = None,
speed: float | None = None,
stream: bool = False,
request_id: str | None = None,
) -> RawSpeechResponse | AsyncGenerator[tuple[bytes, int], None] | ErrorResponse:
audio_bytes = b"..." # your synthesis here
return RawSpeechResponse(audio=audio_bytes)from modelship.plugins.base_plugin import BasePlugin
from modelship.infer.infer_config import ModelshipModelConfig
from modelship.openai.protocol import ErrorResponse, RawTranscription
class ModelPlugin(BasePlugin):
def __init__(self, model_config: ModelshipModelConfig):
self.model_name = model_config.model
async def start(self):
pass
async def create_transcription(
self,
audio_data: bytes,
language: str | None = None,
prompt: str | None = None,
temperature: float | None = None,
request_id: str | None = None,
) -> RawTranscription | ErrorResponse:
text = "..." # your transcription here
return RawTranscription(text=text, language=language, duration_seconds=0.0)# plugins/myplugin/myplugin/__init__.py
from myplugin.myplugin import ModelPlugin
__all__ = ["ModelPlugin"][project.optional-dependencies]
myplugin = ["myplugin"]
[tool.uv.sources]
myplugin = { workspace = true }uv sync --extra mypluginIn models.yaml:
- name: myplugin
usecase: tts # or transcription, translation, generate, embed, image
loader: custom
plugin: myplugin
num_gpus: 0.1For streaming speech, yield (pcm_bytes, sample_rate) tuples from an async generator. pcm_bytes must be signed 16-bit little-endian mono PCM — the serving wrapper base64-encodes each chunk into SSE speech.audio.delta events.
async def create_speech(self, input, voice=None, speed=None, stream=False, request_id=None):
if stream:
return self._stream(input, voice, speed)
# non-stream path
audio = self._synthesize_full(input)
return RawSpeechResponse(audio=audio)
async def _stream(self, input, voice, speed):
for pcm_chunk, sample_rate in self._synthesize_chunks(input):
yield pcm_chunk, sample_rateEvery plugin must include a README.md in its package root (plugins/myplugin/README.md). This is the primary documentation for users configuring the plugin. It should cover:
- Installation — how to install the plugin (
uv sync --extrafor local development; automatic via wheels for deployment) - Configuration — example
models.yamlentry with allplugin_configoptions documented in a table - Voices / options — any model-specific choices (voice presets, providers, etc.)
- Example request — a working
curlcommand
See the built-in plugins for reference: Kokoro ONNX, Orpheus, whisper.cpp.
Open a PR adding:
plugins/myplugin/with your packageplugins/myplugin/README.mddocumenting configuration and usage- One line in root
pyproject.tomloptional extras