Using a Server#
Once a server is running (see Serving), you can reach it three ways: the native
/generate endpoint, the Python SDK, or the OpenAI-compatible API. Every model is
reachable via /generate and the SDK; the OpenAI routes cover the chat, speech, and
image models.
Native /generate#
POST /generate takes a multipart form and returns either a single JSON document or an
NDJSON stream.
Field |
Default |
Meaning |
|---|---|---|
|
— |
Text prompt (optional if media is provided). |
|
— |
One or more media uploads; each file’s modality is inferred from its extension. |
|
auto |
Comma-separated input modalities, one entry per prompt element in order. Auto-detected from the uploads and text when omitted, which is what keeps the ordering and the count of same-modality attachments; an explicit list replaces it. |
|
|
Comma-separated desired outputs (e.g. |
|
|
|
|
— |
JSON object of model-specific parameters (e.g. |
|
(uuid) |
Optional client-supplied id; the server generates one when omitted. An id
that still has a request in flight is refused with |
A client request_id is at most 128 characters from [A-Za-z0-9._:-]; any other is
refused with 400 (on /generate/ws, with the in-band error below). An empty
request_id counts as none: the server mints a fresh one, on both frontends and on
/generate/ws.
A non-streaming response groups outputs by modality, each payload base64-encoded:
{
"request_id": "…",
"outputs": {
"text": [{"data": "<base64>", "metadata": {}}],
"image": [{"data": "<base64-png>", "metadata": {}}]
}
}
A streaming response is application/x-ndjson — one JSON object per line as chunks
arrive. A request that fails after its stream has started ends it with one chunk of
modality error (the HTTP status in metadata.status); nothing follows it. A client that sends Accept: application/vnd.mstar.frames receives
length-framed binary chunks instead, which skips the base64 pass on multi-megabyte
video_frame payloads; a server without the framing keeps answering NDJSON, so a
client must parse by the response Content-Type. GET /health returns
{"status": "healthy"}.
Streamed text chunks (/generate and /generate/ws) are the raw UTF-8 bytes
of each token, so one character can be split across two chunks. Decode the stream
incrementally (codecs.getincrementaldecoder("utf-8"), as the Python SDK does)
or join the chunks before decoding; decoding each chunk on its own with
errors="strict" raises on a split character, and with "replace" turns both
halves into U+FFFD.
# text (non-streaming → JSON)
curl -s http://localhost:8000/generate -F 'text=Hello' -F 'streaming=false'
# image understanding (image in, text out)
curl -s http://localhost:8000/generate -F 'text=What is in this image?' -F 'files=@cat.jpg'
# text-to-speech (base64 PCM in outputs.audio)
curl -s http://localhost:8000/generate \
-F 'text=hello there' -F 'output_modalities=audio' \
-F 'model_kwargs={"voice":"tara"}' -F 'streaming=false'
WebSocket /generate/ws#
/generate/ws is the same request over one persistent WebSocket, for control loops
that cannot afford an HTTP round trip per step (robot policies, streaming world models).
The server negotiates per-message deflate on its WebSocket routes unless the deployment
turns it off (ws_per_message_deflate in Serving, as the Nemotron VoiceChat
configs do).
Each message is one request with the /generate fields — text, files as
[{"name": ..., "data": ...}], input_modalities, output_modalities,
model_kwargs, request_id — sent either as a JSON text frame (data base64) or
as a msgpack binary frame (data raw bytes); a field sent as null counts as
absent. Replies use the same encoding as the
message: one frame per result chunk, {"request_id", "modality", "data", "metadata"},
then {"request_id", "finish": true}. A rejected message answers
{"request_id", "error": ...} and the socket stays open; a request that fails after it
was accepted ends with one such reply. Both carry the HTTP status when there is one
(409 for a request_id still in flight, as on /generate), and no
finish follows. Messages may be pipelined —
send the next observation before the current action chunk has returned — and the
request_id tells the replies apart. Pipelined messages are submitted in the order
they were sent, so of two under one request_id it is the later one that gets the
409 while the earlier one runs; their replies may interleave. Closing the socket aborts whatever is still in flight.
import msgpack, numpy as np, websockets.sync.client
with websockets.sync.client.connect("ws://localhost:8000/generate/ws", max_size=None) as ws:
ws.send(msgpack.packb({
"text": "pick up the mug",
"files": [{"name": "obs.jpg", "data": open("obs.jpg", "rb").read()}],
"output_modalities": ["action"],
"model_kwargs": {"action_mode": "policy", "domain_name": "droid_lerobot",
"raw_action_dim": 10, "action_chunk_size": 32},
"request_id": "step-0",
}, use_bin_type=True))
while True:
reply = msgpack.unpackb(ws.recv(), raw=False)
if reply.get("modality") == "action":
actions = np.frombuffer(reply["data"], dtype=np.float32).reshape(32, -1)
if reply.get("finish") or reply.get("error"):
break
examples/cosmos3_action_ws_client.py is a complete openpi-style client that runs this
loop at a fixed observation rate and reports chunks/s, actions/s and latency percentiles.
Live input. A model that listens while it runs (a full-duplex speech model) keeps a request’s input open after the opening message, so the opening message may carry no media at all. An append feeds the running request more input, in order, while its output keeps streaming back:
{"request_id": ..., "append": {"text": ..., "files": [{"name": ..., "data": ..., "stream": ...}],
"model_kwargs": {...}}}
Every field of the record is optional, but an append needs text, files or end_input.
stream names the client stream a file is for (the model decides when it is absent; a
name the model doesn’t have is rejected), and model_kwargs are this append’s own: they
reach only the model’s process_stream_chunk, so they mean what that model reads from
them, and the opening message’s still hold for the request (its sampling settings, say).
"append": [files] is short for "append": {"files": [files]}. What a model takes
in an append is its own. An append of text alone that the model turns into no stream item
is rejected; text sent with files reaches the model, and if it ignores the text, the text
is dropped without an error. {"request_id", "end_input": true}
ends the input, alone or on an append (or on the opening message, for a one-shot
request). The request then finishes as usual. Such a model treats every request over the
socket as live, so a one-shot request to it carries end_input: true on its opening
message: without it, the request waits for appends until it times out (or, with no
request_id, is refused). A model may take several client streams
(audio and video, say); end_input may then name the ones it ends, as a list
("end_input": ["video_in"]), and the call’s input has ended once every stream has.
An append that names a stream already ended, as a file’s stream or in
end_input, is answered as late, as one after end_input: true is (below). So is
one whose files name no stream and that the model routes to a stream already ended:
what it carried for that stream is dropped, uncounted, and the rest goes on. The opening
message’s files may name a stream too, which is checked the same way (a name the
model doesn’t have gets a 400), but the model routes the opening’s files itself.
A live call needs a request_id; only the socket that opened it can append to it, and
closing the socket aborts it. A message is an append if it carries append at its top
level, or end_input (true or a list) and no text or files; the fields inside
an append’s record never open a request, and a message that opens a request under the
call’s id gets a 409 while the call goes on. An append carries its text, files and
model_kwargs inside the record: beside it they are rejected. A null field counts as absent, inside
the record too. Input resets the request timeout. A rejected append (an unknown file type,
say) ends the call with its error (status 400), and no finish follows, since the input
it carried is lost. So does an append the server then fails to load or process (a corrupt
file, say), with that failure’s status, as for a request’s own files, and so does input
past a limit the model or the deployment sets on how much a stream may carry over a call
(client_streams in Serving), with a 400 that names the limit. An append after
end_input, or after the model has ended the call, or to a request this socket has no
live call under, gets {"request_id", "append": true, "error"}: a reply to that append,
not the end of a request, and the call goes on. A live call that opens while the server
is at max_concurrent_requests ends at once with status 503, rather than wait for a
slot and start that far behind its input; a one-shot request waits in the queue. Other
models ignore end_input, and an append to them is rejected.
Live input needs the Python frontend: --rust-frontend serves no /generate/ws.
The append size that serves best is the model’s: Nemotron VoiceChat runs a live step as one
CUDA graph only for an append of exactly 80 ms (1280 samples at 16 kHz) onto a call whose
audio so far is a whole number of 80 ms frames. Keep the opening clip and every append a
multiple of 1280 samples: one that is not (a one-second opening clip, 16000 samples) keeps
the call off the graph for the rest of its life. Other sizes are served the same, more
slowly (see Supported Models).
ws.send(msgpack.packb({"request_id": "call", "output_modalities": ["text", "audio"]}))
for chunk in microphone_chunks(): # e.g. 80 ms WAV files
ws.send(msgpack.packb({"request_id": "call",
"append": [{"name": "chunk.wav", "data": chunk}]}))
# the record form: text, files aimed at a stream, and settings for this append,
# which only the model's process_stream_chunk reads
ws.send(msgpack.packb({"request_id": "call", "append": {
"text": "and now in French",
"files": [{"name": "chunk.wav", "data": chunk, "stream": "audio_in"}],
"model_kwargs": {"gain": 2.0}, # what this model's hook reads, not a sampling setting
}}))
ws.send(msgpack.packb({"request_id": "call", "end_input": True}))
Python SDK#
The SDK (mstar.client.MStarClient) is a thin HTTP client over /generate. It
depends only on requests (plus numpy for the audio helpers) — no torch — so it can
run anywhere:
from mstar import MStarClient
client = MStarClient("http://localhost:8000") # optional: timeout=600.0
If the server requires an API key (--api-key / MSTAR_API_KEY, see
API key), pass api_key="..."; without one the client reads MSTAR_API_KEY
from its own environment, and sends nothing when that is unset too.
The core method is generate:
generate(*, text=None, images=None, audio=None, video=None, output_modalities=("text",), input_modalities=None, stream=False, request_id=None, **model_kwargs)Submit a request.
images/audio/videoaccept a path, rawbytes, a(filename, bytes)tuple, or a list of those. Extra keyword args are forwarded as the model’smodel_kwargs(e.g.voice="tara",temperature=0.7,max_output_tokens=256);Nonevalues are dropped. Returns aGenerateResultwhenstream=False, or an iterator of stream events whenstream=True.
Convenience wrappers:
Method |
Returns |
|---|---|
|
Text generation (and, with |
|
PNG |
|
PNG |
|
An |
|
The |
|
Sugar for |
|
|
Result and event types live in mstar.client:
GenerateResult—.text,.images(list of PNG bytes),.audio(anAudioBufferorNone),.raw; plus.save_image(path)/.save_audio(path).AudioBuffer— decoded PCM with.sample_rate;.to_wav(path),.to_numpy(),len(...).Stream events —
TextChunk(text),ImageChunk(data)(.save(path)),AudioChunk(pcm, sample_rate), andVideoFrameChunk(data, metadata). A video-frame chunk validates its width, height, fps, pixel format and frame range;.to_numpy()returns a zero-copy[frame_count, height, width, 3]uint8 view. Rawvideo_framerequests requirestream=True. The SDK asks for the binary framing automatically forvideo_frame; other modalities opt in withMStarClient(prefer_binary=True). The server does not pace generation to the consumer: frames are produced at model speed and buffered by the API server until read, bounded by the request’s frame count, so a consumer slower than realtime accumulates that backlog in server memory (about 11 MiB per chunk at 720p).
res = client.chat("Hello!") # GenerateResult
print(res.text)
open("cat.png", "wb").write(client.generate_image("a cat in a hat"))
client.tts("Hi there", voice="tara").to_wav("out.wav")
for event in client.stream(text="Tell me a story"):
print(getattr(event, "text", ""), end="", flush=True)
OpenAI-compatible API#
mstar mounts OpenAI-style routes under /v1 for the models with standard OpenAI
semantics. Point any OpenAI client at http://<host>:<port>/v1:
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="none")
api_key can be anything unless the server sets a key (API key); then pass
that key, which the client sends as Authorization: Bearer <key>.
Endpoints and model coverage:
Endpoint |
Models |
Notes |
|---|---|---|
|
all |
Lists the served model. |
|
|
Text chat (streaming + non-streaming). Qwen3-Omni can also emit speech. |
|
|
Text-to-speech ( |
|
speech models that publish a voice list through their speech adapter ( |
The |
|
|
Speech-to-text (multipart upload; |
|
models whose adapter can continue a hypothesis |
Streaming speech-to-text: |
|
|
Text-to-image. |
|
|
Image editing (image + prompt → image). |
Models without an OpenAI surface (pi05, vjepa2, vjepa2_ac, waypoint)
return 404 on /v1/*; use /generate or the SDK for them. In particular,
Waypoint emits live RGB frame chunks and is not routed through the encoded-video
/v1/videos/generations endpoint.
# chat
client.chat.completions.create(model="bagel", messages=[{"role": "user", "content": "hi"}])
# text-to-speech
client.audio.speech.create(model="orpheus", input="hello there", voice="tara")
# (mstar extension) the voices the served model accepts
requests.get("http://localhost:8000/v1/audio/voices").json()["voices"]
# speech-to-text
client.audio.transcriptions.create(model="whisper_large", file=open("speech.wav", "rb"),
language="en")
# image generation
client.images.generate(model="bagel", prompt="a cat in a hat")
Per-model notes:
BAGEL — chat returns text only; use
/v1/images/generationsand/v1/images/editsfor image output.FLUX.2 klein / Z-Image —
/v1/images/generations(and/v1/images/editsfor klein) only:sizeasWxH(multiples of 16);seed;num_inference_steps,output_format(pngdefault,jpeg,webp),output_compression(JPEG / WebP quality) andpng_compress_level(0-9, default 1) viaextra_body.Qwen3-Omni — text sampling uses
thinker_*keys, speech usestalker_*, and the residual codec groups usecode_predictor_*; set the speaker withvoice(defaultEthan) and request audio output by including"audio"inmodalities. Non-OpenAI knobs (e.g.talker_top_k,code_predictor_top_p) go throughextra_body.Whisper / Higgs-Audio —
language(ISO-639-1) skips language detection,promptconditions the decoder on prior text (it reaches the model asinitial_prompt), andresponse_formatverbose_json/srt/vtt(ortimestamp_granularities[]) asks the model for timestamps. Whisper’s language and timestamp tokens travel in the text stream and are lifted intolanguage/segmentsby the server; Higgs-Audio emits no timestamps, so itsverbose_json/srt/vttget one segment per window, timed to the windows. A streaming client receives only the spoken words. Uploads longer than the model’s clip (30 s for Whisper) are served as consecutive windows. By default they run in order, openai-whisper style: each window gets the transcript so far asinitial_promptand the first window’s detected language, is decoded with timestamps so the next window can start where its last closed segment ended (no word is split by a boundary), and is decoded again when its text is a repetition loop (gzip compression ratio above 2.4): first without the conditioning text, then at rising temperatures — after which the transcript so far stops conditioning later windows. Leavetemperatureat 0 to get that fallback; a pinned temperature is used as is.long_form="parallel"inextra_bodysubmits fixed windows all at once, each cut at the quietest moment before its boundary. Segment timestamps are offset to the whole file. For Whisper,window_secondsinextra_body(5 to 30) sets the window length for one request, and an upload longer than it is windowed. An upload, like any audio a request attaches, is refused once it decodes past 30 min, whatever its header says (a 413 here, a 400 from the data worker). The decode up to that point still runs, so this bounds memory, not time: refusing a 31 min file takes about as long as decoding it.Omnilingual ASR (
omnilingual_asr_ctc_*): no language code is needed for any of its 1,600+ languages, andlanguage/prompt/temperaturehave no effect. A clip of up to 20 s is one request; a longer upload is cut into windows of about equal length, at most 16 s, at quiet points and transcribed in parallel (window_secondsinextra_body, 5 to 40, sets both lengths). There are no timestamps:srt/vttget one cue per window, timed to the windows. Nothing detects the language:verbose_json’slanguageis the request’slanguageas given, or null.Canary / Canary-Qwen / Parakeet / Nemotron ASR — no conditioning text (
promptis dropped) and no timestamps:timestamp_granularitiesis a 400, andsrt/vtt/verbose_jsonget one segment per window. Canary takeslanguage(the speech’s, English by default) and, throughextra_body,target_languageortask="translate"(into English) andpunctuation; the other three only transcribe, and refuse ataskother thantranscribe. Canary-Qwen is English only. Parakeet detects the language itself and refuses alanguageoutside the 25 it knows; Nemotron takes a locale (en-US), a bare code orauto. Uploads longer than the model’s clip (40 s for Canary and Canary-Qwen, 10 min for Parakeet and Nemotron) run as parallel windows.Realtime transcription — every
chunk_seconds(default 2 s) of new audio the session re-transcribes everything heard so far as one engine request whose assistant turn is prefilled with the previous hypothesis minus its lastunfixed_tokenstokens (the Qwen3-ASR SDK’s streaming algorithm), so only the tail is ever revised. Tunechunk_seconds/unfixed_chunks/unfixed_tokensundersession.mstarintranscription_session.update; a smallerchunk_secondsis raised to 0.5. An item holds at most 300 s of audio (max_item_secondsin the session, lower if the model hears less): commit before then, since an append past it is refused with anerror.Realtime turn detection — by default the client ends each utterance with
input_audio_buffer.commit. With"turn_detection": {"type": "server_vad"}the server does it, as OpenAI’s server VAD does:threshold(default 0.5),prefix_padding_ms(300) andsilence_duration_ms(500). Silero VAD scores each 32 ms frame; a turn starts on a frame at or abovethresholdwith up toprefix_padding_msof the audio before it. Silence starts on a frame belowthresholdminus 0.15, and a frame at or abovethresholdcancels it; the turn ends on whichever frame completessilence_duration_msof whole frames counted from that first silent frame, borderline frames included. The hysteresis is that of Silero’s ownVADIterator, butVADIteratorcounts from the frame after the first silent one and ends only on a frame belowthresholdminus 0.15, so with a nonzerosilence_duration_msit ends a turn one frame or more later. The server then sendsinput_audio_buffer.speech_started(audio_start_ms),input_audio_buffer.speech_stopped(audio_end_ms) andcommitted, and the transcription completes. Times are milliseconds of audio since the session started, cleared audio included. A commit takes exactly the turn’s audio; what follows, even in the same append, goes to the next turn. A turn never passes the item cap: it is committed before the frame that would pass it, and speech goes on in a new turn that starts with that frame. A clientcommitends an open turn; between turns it drops what the VAD has not put in a turn yet.create_responseandinterrupt_responseare accepted and echoed but do nothing in a transcription session;idle_timeout_msand other types are refused. With 24 kHz input each decision waits 3 ms more for the resampler. Installmstar[vad](Silero VAD, MIT, bundled 2 MB ONNX weights, run by onnxruntime on one CPU thread) or pointMSTAR_SILERO_VAD_PATHat the ONNX file (silero_vad.onnx; the package’s other exports take other inputs, and with one of themserver_vadis refused); weights are never downloaded. The Dynamo bridge has no turn detection yet and refuses it.Semantic turn detection —
"turn_detection": {"type": "semantic_vad", "eagerness": "auto"}ends a turn when the speaker sounds finished rather than after a fixed silence, as OpenAI’s semantic VAD does. When a pause reaches the eagerness’ pause (high 200 ms, medium and auto 300 ms, low 400 ms; all shorter than server_vad’s 500 ms), Pipecat’s Smart Turn v3 judges the turn’s last 8 s of audio once; a probability of at least 0.3, 0.5 or 0.7 (high, medium/auto, low) commits there, and otherwise the server keeps listening. A silence of 2, 4 or 8 s always ends the turn. Speech starts on Silero’s 0.5 with 300 ms of prefix padding; the events are the same as server_vad’s. Smart Turn (BSD-2, 8 MB int8 ONNX, about 45 ms per pause on one CPU thread) runs off the event loop. It needsonnxruntimeandtransformers(mstar[vad]) and its weights atMSTAR_SMART_TURN_PATHor in the local Hugging Face cache (hf download pipecat-ai/smart-turn-v3 smart-turn-v3.2-cpu.onnx). Without them, or with an ONNX file whose inputs or outputs differ, the server warns at startup and refusessemantic_vadwith anerrorevent that says why (no weights, a missing package, or a file that is not the model), asserver_vadis refused without Silero. The session goes on with its previous settings, so the client can chooseserver_vador manual commits instead. See OpenAI’s realtime VAD guide.Kokoro —
voiceis one of the 54 bundled voices (defaultaf_heart) or a blend such asaf_bella+af_skyoraf_bella(2)+af_sky(1);speedscales the speaking rate (0.25-4.0).lang_codeandphonemesgo throughextra_body.temperature/top_pare ignored: Kokoro does not sample.Orpheus — set the speaker with
voice— one oftara(default),zoe,zac,jess,leo,mia,julia,leah(theavailable_voiceslist in the Orpheus config).