Inference Server
Deploy fine-tuned models as an OpenAI-compatible API server.
Transformers Backend
pip install "soup-cli[serve]"
soup serve --model ./output --port 8000Simple HTTP API using HuggingFace Transformers. Good for testing and low-traffic use.
vLLM Backend (2-4x Faster)
Install
[serve-fast]in a different environment from[train]. Their accelerator stacks can resolve differenttorchbuilds:pip install vllminto a training environment can silently replace thetorchSoup was installed against, producing an environment outside the bounds Soup declares, with no warning at install time. Since v0.73.3,soup env checkcatches that after the fact, exit 3, without needing a lock file. Not creating it is cheaper.
pip install "soup-cli[serve-fast]"
soup serve --model ./output --backend vllm
# Multi-GPU with tensor parallelism
soup serve --model ./output --backend vllm --tensor-parallel 2
# Control GPU memory usage
soup serve --model ./output --backend vllm --gpu-memory 0.8
# Cap the context vLLM reserves KV cache for (v0.73.0, vLLM only)
soup serve --model ./output --backend vllm --max-model-len 8192Recommended for production. Uses PagedAttention for high throughput. --max-model-len is the knob to reach for when vLLM refuses to start because the model's declared context window would need more KV cache than the card has: lower it and the engine reserves less. A value below 1 exits 1, and on any other backend the flag prints a warning and is ignored. The [serve-fast] extra floors vllm>=0.4.3 since v0.76.0 (#892): the backend now hands the engine a token-id mapping, which vLLM accepts as the first argument only from 0.4.3 on.
KV-cache type
The KV cache is usually the second-largest thing in VRAM while serving, after the weights, and it grows with context length rather than with model size.
soup serve --model ./output --kv-cache-type bf16 # or f16, q8_0 (fp8 is accepted by the config but cannot be served)bf16andf16set the cache dtype and need no extra dependency.q8_0is 8-bit quantized and needshqqoroptimum-quanto. Without one the command exits 2 with the install hint rather than falling back quietly.fp8is accepted by the config and cannot be served today: the transformers backend has no fp8 KV-cache path, and the vLLM / SGLang routing that would provide one is not wired, so it raises either way. The Hopper SM 9.0 check only picks which error you see.
Transformers backend only. Routing this flag through vLLM and SGLang is still open upstream, so do not assume it applies when you switch backend.
SGLang Backend
pip install "soup-cli[sglang]"
soup serve --model ./output --backend sglang
# Multi-GPU
soup serve --model ./output --backend sglang --tensor-parallel 2Alternative high-throughput backend with RadixAttention. It serves /v1/chat/completions, /v1/models and /health; the Anthropic-shaped /v1/messages endpoint is served by the transformers and vLLM backends, not by SGLang.
v0.74.0 ported the two vLLM repairs that were still standing here. The prompt goes through the model's own chat template rather than a third hand-rolled copy, so a served model no longer sees a format it was never trained on, and finish_reason reports length on a truncated response instead of a hardcoded stop, on both the sync and streaming paths. (At v0.74.0, live verification of that port against a real SGLang runtime on Linux was an open follow-up. Since v0.76.0 this backend also posts token ids instead of a string when a chat template rendered the prompt; upstream's changelog reports that change measured end to end on SGLang 0.5.9. See Prompt encoding.)
Breaking in v0.74.0: this backend now obeys
--trust-remote-code. It had hardcoded it on at both runtime call sites, so a custom-code model executed its own repository code on this backend whether or not you opted in. The warning panel said so, but a notice is not a gate. A custom-code model now fails to load here without the flag, which is the same default-deny every other backend has had since v0.36. The same release also gave this backend the model's own chat template instead of a hand-rolledUser:/Assistant:prompt, and a realfinish_reasonoflengthwhen a response hitsmax_tokensinstead of a hardcodedstop.
A page you visit cannot drive your server (v0.75.1)
Until v0.75.1, on the default loopback bind with no token at all, /v1/tools/python, /v1/tools/bash, /v1/tools/web_search and /v1/thumbs checked neither the Host header nor Origin (0.53.7 to 0.75.0), and the adapter activate and deactivate routes could be driven by a cross-site request from any page (0.30.0 to 0.75.0). A web page the operator merely visited could therefore reach them through DNS rebinding. A token nobody was asked to set is not a control. Everything in this section describes the default transformers backend; the vLLM, SGLang and MII backends have none of these checks, as set out at the end of the section.
The check that shipped has two tiers, and the difference is deliberate:
| Routes | Check | Refusal |
|---|---|---|
Tool routes, /v1/thumbs, adapter activate and deactivate | Host must name the bound address, and any Origin must name the same | 421 for a foreign or missing Host (not on a wildcard bind, see below), 403 for a foreign Origin |
/v1/chat/completions, /v1/messages, GET /v1/adapters | Origin only | 403 for a foreign Origin |
/health, /metrics, /v1/models | none, by choice | they carry no generation cost and list no adapters (/health does report the name of the active one) |
One exception, and it is the bind people use to reach the server from another machine. On a wildcard bind (--host 0.0.0.0 or ::) there is no single name to compare Host against, so the Host check is skipped and only Origin is tested, which must equal the request's own Host. A page that rebinds its own hostname to your server sends exactly that pair, so on a wildcard bind this check does not stop DNS rebinding. It still refuses a cross-site request whose Origin names a different site from its Host. Bind to the specific address you serve on to get the full check, and on any non-loopback bind treat the bearer token as the real control for the routes it covers.
Why the generation routes get only the narrower half: an inference route is the one a reverse proxy legitimately fronts under some other hostname, so refusing a foreign Host there would refuse every proxied deployment. A request carrying no Origin at all, which means curl, the OpenAI and Anthropic SDKs, and any proxy, is unaffected. What the Origin check buys is that a page the operator visits cannot drive generation or enumerate loaded adapters, which CORS alone does not prevent: CORS stops that page reading the reply, not sending the request.
The limit is stated upstream rather than left to be discovered, and it is worth repeating: none of this covers a non-browser client, which sends no Origin and can name the bind address in Host. For such a client the bearer token (--tool-auth-token) is the only protection, and it covers only the tool routes, /v1/thumbs and the three /v1/adapters routes. Generation (/v1/chat/completions, /v1/messages), /v1/models, /health and /metrics check no token on any backend, so a non-loopback bind publishes the model to whoever can reach the port. Put an authenticating reverse proxy in front of it, or keep it on loopback.
Two changes ride along. The tool token is now compared in constant time rather than with !=. And GET /v1/adapters, activate and deactivate now require that token too, with the check running before the 404 branches, so an unauthenticated probe cannot learn which adapter names are loaded from an "unknown adapter" reply. Through v0.75.0 those three routes ignored the token even when one was set (only the tool routes and /v1/thumbs checked it), so on a non-loopback bind anyone who could reach the port could list your adapters and switch the one being served. One disclosed consequence: soup loop's deploy step posts without an authorization header, so against a token-protected endpoint it now fails closed.
Binding to a non-loopback host (v0.74.0)
soup serve --model ./output --host 0.0.0.0 --tool-auth-token "$TOKEN"Without the token that command now exits 2, where it used to print a warning and start anyway. The reason is that the endpoint the token protects changed underneath it: /v1/tools/bash was re-enabled behind real operating-system namespace and sandbox isolation, so it actually executes code now, and a warning is not a sufficient control over an executing endpoint reachable from the network. Where strict isolation is unavailable, Windows included, the endpoint fails closed with HTTP 501 rather than falling back to something weaker. It is not a filesystem sandbox, and the docs say so rather than implying one.
The exit-2 rule applies to any --host other than 127.0.0.1, localhost or ::1, a LAN address included, and it runs before the backend is chosen. Only the transformers backend reads the token and has the Origin and Host checks above. The vLLM, SGLang and DeepSpeed-MII backends have loopback-only CORS and no Origin, Host or token check, and the tool, thumbs and adapter routes do not exist on them. So --backend vllm --host 0.0.0.0 --tool-auth-token "$TOKEN" starts, and the token protects nothing, because no route on that app reads it. For those backends the network is the only boundary: keep them on loopback or behind an authenticating proxy.
Since v0.76.0 the server says this at startup. On any bind beyond loopback it prints a yellow Note:. On the transformers backend it reads that --tool-auth-token is checked on the tool, thumbs and adapter routes only, and that /v1/chat/completions, /v1/messages, /v1/models, /health and /metrics answer every client that can reach the port without a token; on a wildcard bind a second line adds that the Host header is not compared. On vLLM, SGLang and MII the note says that no route checks the token, generation included. The --host help string was updated to say the same. Nothing about the scope itself changed: the notice describes what this page already described. The --tool-auth-token help string still says "Strongly recommended" for a non-loopback host, although the bind is refused without it.
Device selection (v0.76.0)
soup serve --model ./output --device cuda:1
soup serve --model ./output --device cpu--device takes cuda, cuda:<index>, mps or cpu, in any case (mlx is also accepted and loads like cpu). Any other value exits 2 and names the accepted ones before the model loads. On the transformers backend the base model, a merged model and the speculative draft model all follow the same rule:
--device | Placement | Dtype |
|---|---|---|
cpu | Pinned to the CPU | float32 |
cuda:<index>, mps | Pinned to that device | float16 |
cuda | device_map="auto" | float16 |
An explicit --kv-cache-type bf16 or f16 still sets the load dtype on every device. Through v0.75.2 the argument never reached the loader, so --device cpu still placed the model on a visible GPU and cuda:N was accepted and ignored.
This changes the default on a machine with no GPU. The device is auto-detected when you do not pass --device, so the rule applies without the flag: on a CPU-only host the model now loads in float32 pinned to the CPU, which doubles its RAM use. Upstream's changelog gives a 7B model as about 28 GB instead of 14 GB. The placement rules above are the transformers backend's, as the flag's own help says.
Speculative decoding
# Transformers backend
soup serve --model ./output --speculative-decoding small-draft-model --num-speculative-tokens 5
# vLLM backend
soup serve --model ./output --backend vllm --speculative-decoding small-draft-modelA smaller draft model proposes tokens the target verifies in one pass, keeping the target's exact output. Whether that is actually faster depends on the pair, so measure it rather than assume: soup draft measure reports the real acceptance rate and plain-vs-assisted throughput, and on the pair we validated it came out at 0.55 to 0.64x, a net slowdown. A high acceptance rate does not settle it either: upstream's documentation cites a Llama-3.1-8B target with a Llama-3.2-1B draft on one H100, where 81.3% acceptance still ran at 0.48x of plain decoding. Since v0.76.0 measure also reports a break-even acceptance rate and a best draft length.
--auto-quant is refused (v0.76.0)
soup serve --auto-quant exits 2. Soup cannot compare GGUF, AWQ, GPTQ, FP8 and an unquantized baseline before a serving engine has loaded them, and through v0.75.2 the flag timed three calls to a stub that returned a constant, so its choice came from timer noise. On vLLM that choice could force AWQ, GPTQ or FP8 on a checkpoint that did not declare it. vLLM now follows the checkpoint's own quantization metadata. If you served with --auto-quant on vLLM before, check which quantization the engine actually loaded. To serve a quantized model, quantize the checkpoint explicitly (see Model Export) and serve that without the flag.
Batch inference, CUDA graphs and benchmarks (v0.76.0)
soup infer takes --batch-size N to generate several prompts together, and an experimental --cuda-graphs that replays CUDA graphs for decoding on a resident, unquantized Qwen2 or Llama model. soup bench is now a command group with infer and train. Details, and what each flag refuses, are on the soup bench page. Upstream's record for --cuda-graphs: on one RTX 5070 Laptop, unpinned, Qwen2.5-1.5B FP16, the slowest run with graphs decoded 2.9x faster than the fastest run without (benchmarks/gate-v0.76.0-cuda-graph-decode.md). soup serve has no --cuda-graphs flag.
Multi-Adapter Serving (v0.22.0+)
Serve multiple LoRA adapters on a single base model:
soup serve --model ./base --adapters chat=./adapters/chat --adapters code=./adapters/codeSelect an adapter per request with an adapter field in the request body. It is not the model field: model is ignored for selection, so "model": "chat" quietly runs whichever adapter is active, or the base model.
{"adapter": "chat", "messages": [{"role": "user", "content": "Hello!"}]}A request with no adapter uses the one chosen with POST /v1/adapters/activate/<name>, or the base model if none is active, and an unknown name is a 404. --adapters needs --backend transformers, and every adapter path must sit under the working directory.
Canary routing (v0.76.0). Through v0.75.2 soup loop canary recorded a canary adapter and its traffic share in .soup/loop.yaml and soup serve never read them, so no request was ever routed. Since v0.76.0 the transformers backend hashes a request's conversation key (conversation_id in the body, or an X-Conversation-Id header) and sends it to the canary adapter or to the stable model at the recorded share. It does so only when the canary adapter was loaded with --adapters, the request names no adapter of its own (an explicit choice is honoured), and the request carries a key; any other request follows normal activation. Each routed generation's outcome is buffered and flushed to disk after 64 outcomes or two seconds, so the stats soup loop watch reads can lag traffic by up to that window. See soup loop.
API Endpoints
Every backend exposes the same OpenAI-compatible core:
POST /v1/chat/completions— chat completions (streaming supported, except on the DeepSpeed-MII backend)GET /v1/models— list available modelsGET /health— health check (the DeepSpeed-MII backend has none)
Beyond that core the backends differ. POST /v1/messages and GET /metrics are served by the transformers and vLLM backends only, and /metrics answers whether or not --dashboard is passed; on SGLang and MII --dashboard prints a warning and collects nothing. The adapter, tool and thumbs routes exist on the transformers backend only.
Streaming on the transformers backend is not token by token. A stream: true request generates the whole reply first and then sends it word by word, split on spaces. That is why --structured-output and --reasoning-parser can apply to the whole reply before the first chunk leaves. Since v0.76.0 a streamed request honours both (#1607); through v0.75.2 it skipped them, so a streamed reply ignored the output constraint and carried the reasoning block. --reasoning-parser is read by the transformers backend only: on vLLM, SGLang and MII the flag is accepted and nothing reads it.
Prompt encoding. Since v0.75.0 the transformers backend encodes a rendered chat template with no tokenizer special tokens, so a Llama-3, Gemma or Mistral template no longer sends a doubled BOS. Since v0.76.0 the vLLM, SGLang and DeepSpeed-MII backends do the same (#867, #890, #891): when the model's chat template rendered the prompt, Soup encodes it itself and hands the engine token ids instead of a string, so the engine cannot add a second BOS in front of the one the template already rendered.
- vLLM receives
{"prompt_token_ids": [...]}. - SGLang receives the ids as
input_idson its/generateendpoint. This path useshttpx, which the[sglang]extra now declares. - DeepSpeed-MII receives a prompt object that carries the ids, through a tokenizer wrapper. If the engine reports a prompt length different from the one Soup encoded, the server logs a warning naming both numbers.
usage.prompt_tokens drops by one on a template that renders its own BOS. A model with no chat template falls back to a generic User: / Assistant: prompt and the server says so at startup; a template that fails to render falls back the same way for that request and logs a warning; that fallback carries no special tokens of its own and is still sent to the engine as a string, which the engine tokenizes as it always did. For vLLM and SGLang, upstream's changelog reports the change checked against a running engine; the MII path is covered by a test against MII's tokenizer contract. Through v0.75.2, on a template that renders its own BOS, these three engines ran on the doubled-BOS prompt, so a verdict, benchmark or usage number taken through them on an earlier release was measured on a prompt that differed from training by one token. See what changed for soup ship.
curl http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "output",
"messages": [{"role": "user", "content": "Hello!"}]
}'Compatible with OpenAI SDK:
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="unused")
response = client.chat.completions.create(
model="output",
messages=[{"role": "user", "content": "Hello!"}],
)Note:
max_tokensis capped at 16,384 per request. Error details are never exposed in HTTP responses.
Tool endpoints: what bounds them
The transformers backend also serves POST /v1/tools/python (body {"code": "..."}), POST /v1/tools/bash (body {"command": "..."}) and POST /v1/tools/web_search, behind the bearer-token and Origin/Host checks described above. A code or command longer than 65,536 characters is a 400. The Python and bash routes run in the same sandbox as the RLVR code reward, and its limits are:
| Limit | Value |
|---|---|
| Wall clock | 5 seconds. A timeout returns timed_out: true (bash reports exit code 124). |
| Output | 10,000 bytes of stdout and stderr combined. Past that the process is killed and the call returns exit code 1 (the bash route also returns sandbox output exceeded limit in stderr; the Python route returns an empty stderr). |
| Memory and CPU (POSIX) | 512 MB of address space and 5 seconds of CPU time. |
| Other POSIX limits | 10 MB largest file, no core dumps, 256 open files, 64 processes. |
| Working directory | A fresh temporary directory per call. |
| Environment | A minimal allowlist (PATH, LANG, LC_ALL, TMPDIR, TERM, TZ and the Windows runtime variables), so tokens and API keys in the server's environment are not passed to the child. |
| Concurrency | At most 4 sandboxed processes at a time, shared by both routes. |
What isolates the process depends on the platform, and the bash route refuses to run without it:
- Linux: new user, network and PID namespaces through
os.unshare, which exists only in Python 3.12 and later, and the kernel has to allow unprivileged user namespaces. Soup supports Python 3.10 and 3.11 as well, and on those the strategy isbest-effort, so/v1/tools/bashanswers 501. A kernel that refuses the unshare also gives a 501 on the bash route, where the Python route quietly carries on without the namespaces. - macOS: the command runs under
sandbox-execwith a default-deny profile that blocks the network and limits writes to/tmp,/private/tmpand/var/folders. - Windows:
/v1/tools/bashanswers 501./v1/tools/pythonstill runs, without the POSIX resource limits, guarded by the timeout, the output cap, the environment allowlist and a Python-level socket patch that its own source calls bypassable.
None of this is a filesystem jail: the macOS profile allows reads everywhere and the Linux namespaces do not touch the file system, so an endpoint a client can reach can read whatever the server's user can read. Two details of the Python route's response: stderr is always empty, and timed_out is true for any failed run (a non-zero exit, an output overflow or a launch failure), not only for a timeout.
Soup is free and Apache-2.0. If it saved you a training run, starring the repo costs nothing and helps most. You can also fund the GPU time behind the work a 4 GB laptop cannot reach.