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.
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. Live verification against a real SGLang runtime on Linux is still an open follow-up.
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.
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.
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.
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.
Prompt encoding differs by backend. 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. The vLLM, SGLang and DeepSpeed-MII backends still hand the rendered string to their engine, which tokenizes it itself; upstream tracks those three as open (#781), so a doubled BOS there is possible and the v0.75.0 fix does not cover them. 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.