Server Mode¶
Table of Contents¶
- What Is Server Mode?
- Starting the Server
- Authentication
- Media inputs
- Endpoint Overview
- Serving Audio Models
- Serving Nemotron VoiceChat (Full-Duplex Speech)
- Serving Embedding and Reranking Models
- Server Lifecycle
- Monitoring
- Integration Examples
- Long Prefill and Proxy Timeouts
- KV Cache Size Limit (--max-kv-size)
- Nemotron-H Request Memory Admission
- Loop Detection (N-gram Repetition Guard)
- Reasoning Re-echo for Content-Only Clients
- Thinking-Token Budget (Qwen3 family)
- Chat Template Kwargs
- Serving Florence-2
- Serving Block-Diffusion Models
What Is Server Mode?¶
Server mode turns mlxcel into a persistent HTTP service. Instead of loading the model for each request and exiting (as mlxcel generate does), the server loads the model once, keeps it resident in memory, and serves an unlimited number of inference requests over its lifetime.
This design is appropriate whenever:
- Multiple clients need access to the same model simultaneously.
- Request latency matters and the model load time (which can be several seconds for large models) is unacceptable per call.
- You want a drop-in replacement for the OpenAI API or a llama.cpp server.
The server exposes OpenAI-compatible generation, Responses, Audio, and Embeddings; Anthropic Messages; Jina/Cohere/TEI-compatible reranking; and llama-server-native completion, embedding, tokenization, and observability routes. Clients that already use those contracts normally need only a base URL change, but native and OpenAI embedding/completion paths deliberately have different response shapes.
Starting the Server¶
Two binaries can start the server:
| Binary | Entry Point | Use Case |
|---|---|---|
mlxcel serve | mlxcel CLI subcommand | General use |
mlxcel-server | Standalone binary | Deployments that need a server-only executable |
Both binaries share the same startup logic in src/server/startup.rs. Most runtime flags are shared, but a few spellings intentionally differ for llama.cpp compatibility: mlxcel serve presents --n-parallel and --draft-model as its primary spellings, while standalone mlxcel-server presents --parallel and --model-draft; the corresponding alternate spellings are aliases. See Speculative Decoding and CLI Reference - mlxcel serve.
Minimal startup:
# Bare names resolve to mlx-community/<name> and auto-download on first use.
mlxcel serve --model Qwen2.5-7B-Instruct-4bit
Standalone server binary:
The model argument accepts an existing local path, a HuggingFace owner/name repo-id, or a bare name resolved through ${MLXCEL_DEFAULT_ORG:-mlx-community}. Repo-id misses are downloaded into the mlxcel global store. Use --models-dir <PATH> or MLXCEL_MODELS_DIR to relocate that store for serve and mlxcel-server.
The server defaults to four parallel request slots (--parallel 4 / --n-parallel 4) and automatic decode storage. Model families that cannot batch are clamped to one slot. Prompt-prefix caching and APC are enabled by default; use --no-prompt-cache or --apc-enabled=false for controlled comparisons.
DFlash speculative decoding server:
mlxcel-server \
--model ./models/qwen3.5-27b-4bit \
--model-draft ./models/Qwen3.5-27B-DFlash \
--draft-kind dflash \
--draft-block-size 16 \
--port 8080
For mlxcel serve, the primary spelling for the same drafter path is --draft-model (--model-draft is also accepted):
mlxcel serve \
--model ./models/qwen3.5-27b-4bit \
--draft-model ./models/Qwen3.5-27B-DFlash \
--draft-kind dflash \
--draft-block-size 16 \
--port 8080
Full-model drafter (a smaller checkpoint with the target's tokenizer):
mlxcel serve \
--model ./models/Qwen3-8B-4bit \
--draft-model ./models/Qwen3-0.6B-4bit \
--draft-kind full-model \
--port 8080
The worker loads the drafter next to the target at startup. Each eligible request drafts on its own draft caches and runs one verify round per decode tick while the active batch has at most --prompt-lookup-max-batch rows (default 2).
Prompt-lookup speculation (no drafter checkpoint):
Prompt lookup proposes tokens copied from earlier in the context and verifies them inside the batch scheduler. It is opt-in, pays off on edit-style requests, and runs verify rounds only while the active batch has at most --prompt-lookup-max-batch rows (default 2). See Speculative Decoding for eligibility and measurements. At temperature 0, prompt-lookup and full-model rounds can leave the plain-decode token stream, so their output is not guaranteed to match a run without speculation (#2778).
Binding to all interfaces (for Docker or remote access):
Unix socket mode:
A --host value ending in .sock is served as a Unix domain socket at that path. --port is ignored in that case:
Unix socket mode is useful when the server and client run on the same host and you want to avoid TCP overhead and open network ports.
--port 0 is a TCP setting: it binds an ephemeral port chosen by the kernel.
Bundled WebUI:
--webui serves the administrative browser UI at /webui/ on the same listener. Without a model argument the server starts model-free, and models are loaded from the UI:
On a loopback listener with no --api-key, the server prints a one-time session key to the terminal. A non-loopback listener requires an explicit API key and TLS, and a Unix socket listener is refused. See WebUI.
Authentication¶
By default, the server accepts requests without any authentication. This is suitable for local development but should not be exposed to untrusted networks without setting an API key.
When an API key is configured, all endpoints except /health and / require the key to be passed as a Bearer token in the Authorization header.
Authentication is not a media sandbox
An image, audio or video request steers the server's own fetches, so an API key authenticates the caller without restricting where those fetches go. Two policies bound them, and neither is a hostname allowlist or an HTTPS-only switch: remote URLs are refused when they resolve to a non-public address, and local files are readable only under a --media-path root. See Media inputs. Keep trusted clients plus reverse-proxy, egress-firewall and container or OS filesystem controls. Local video paths are separately constrained by MLXCEL_VIDEO_DIR_ALLOWLIST.
Media inputs¶
A file:// media URL is refused outright unless the server was started with --media-path <dir>. With a root configured, a request's path resolves relative to that root first and an absolute path is accepted only when it names a file inside it. Under --media-path /srv/media, file://cat.png, file:///srv/media/cat.png and a bare cat.png all read /srv/media/cat.png; file:///etc/passwd is refused as an escape. The resolved path is canonicalized and must stay inside the canonical root, so a symlink pointing out of it is refused while one staying inside still resolves. The open uses O_NOFOLLOW and accepts regular files only, so neither a last-component symlink swap nor a FIFO under the root can be used against the server. Paths carrying .., control characters, : * ? " < > |, a leading or trailing space, a trailing ., more than 255 bytes, Unicode separator look-alikes, or a percent escape for ., /, \ or NUL are rejected before any of that.
An http(s) media URL is checked for a non-public address in three places: before the request (scheme, no credentials in the URL, and every address a hostname resolves to), on every redirect in the five-hop chain, and again on the peer address actually reached before a single body byte is read, which closes the DNS-rebinding window. Loopback, the unspecified address, multicast and broadcast, the IPv4 private and link-local ranges (which puts the cloud metadata service at 169.254.169.254 out of reach), carrier-grade NAT, the protocol-assignment, benchmarking and documentation prefixes, 240.0.0.0/4, IPv6 unique-local, link-local and documentation, and any IPv4-mapped or IPv4-compatible IPv6 spelling of a refused IPv4 address are all refused. A deployment serving media from an internal object store sets MLXCEL_ALLOW_PRIVATE_MEDIA_URLS=1, which turns every address check into a pass.
Payload limits are unchanged by either policy: --max-image-payload-size, --max-images, the decoder's width, height and allocation caps, the audio per-clip and per-request ceilings, and the 10-second fetch timeout all still apply.
A video_url block sent to a checkpoint with a vision tower but no native video path is decoded and sent to the model as ordered still images. --video-max-frames (default 16, MLXCEL_VIDEO_MAX_FRAMES) caps the kept frames and --video-fps (default 2.0, MLXCEL_VIDEO_FPS) sets the sampling rate when the block carries no fps. The frames count against --max-images. See Video on a model with no native video path.
Setting the key inline:
Reading the key from a file:
echo "sk-my-secret-key" > /etc/mlxcel/api-key
mlxcel serve \
--model ./models/Qwen2.5-7B-Instruct \
--api-key-file /etc/mlxcel/api-key
Sending an authenticated request:
curl http://localhost:8080/v1/chat/completions \
-H "Authorization: Bearer sk-my-secret-key" \
-H "Content-Type: application/json" \
-d '{"model":"Qwen2.5-7B-Instruct","messages":[{"role":"user","content":"Hello"}]}'
If the key is missing or incorrect, the server returns HTTP 401 with a JSON error body:
The LLAMA_API_KEY environment variable is an alternative to the --api-key flag and is useful in containerized deployments.
Request Body Errors¶
/v1/chat/completions, /v1/responses, /v1/messages, and /v1/messages/count_tokens, together with their aliases without /v1, answer a body they cannot parse the same way /v1/completions does: with a JSON invalid_request_error carrying the parser's message, truncated to 1 KiB. This happens before the availability check, dispatch, or streaming setup, and authentication still runs first. Malformed or empty JSON, and a missing or wrongly typed field such as model, return HTTP 400; a missing or non-JSON Content-Type returns HTTP 415. The Anthropic routes use their own envelope, {"type": "error", "error": {...}}, and /v1/messages/count_tokens answers exactly as /v1/messages does. The disaggregated router's /v1/chat/completions behaves the same way, including HTTP 413 for a body over its size limit. On the single-node server, one server-wide layer answers every HTTP 413 in the OpenAI envelope, on the Anthropic routes too.
Anthropic Messages¶
Claude Code starts its system prompt with an x-anthropic-billing-header stamp whose cch value changes on every request and whose fourth cc_version part changes on every session. On the Anthropic Messages routes the server replaces those two values with f characters of the same length before rendering, so a turn reuses the whole previous prompt instead of stopping at the stamp. Only top-level system text starting with the exact header is touched. Set MLXCEL_ANTHROPIC_PIN_BILLING_STAMP=0 to pass the client's text through unchanged. The variable is read once at startup through a OnceLock, so changing it afterwards has no effect.
Endpoint Overview¶
The server exposes the following HTTP endpoints:
| Method | Path | Description | Auth Required |
|---|---|---|---|
POST | /v1/chat/completions | OpenAI chat completions | Yes (if key set) |
POST | /v1/completions | OpenAI text completions | Yes (if key set) |
POST | /v1/embeddings | OpenAI embeddings | Yes (if key set) |
POST | /v1/rerank, /v1/reranking | Jina/Cohere or TEI reranking | Yes (if key set) |
POST | /v1/messages | Anthropic Messages API create | Yes (if key set) |
POST | /v1/messages/count_tokens | Anthropic Messages API token count | Yes (if key set) |
GET | /v1/models | List loaded models | Yes (if key set) |
POST | /v1/responses | OpenAI Responses API create | Yes (if key set) |
GET | /v1/responses/:id | Retrieve a stored Responses API response | Yes (if key set) |
DELETE | /v1/responses/:id | Delete a stored Responses API response | Yes (if key set) |
POST | /v1/responses/:id/cancel | Cancel or mark a Responses API response as cancelled | Yes (if key set) |
POST | /v1/responses/compact, /responses/compact | Summarize older Responses turns into one compaction output item. See REST API | Yes (if key set) |
POST | /v1/audio/speech | Kokoro text-to-speech; WAV response | Yes (if key set) |
POST | /v1/audio/transcriptions | Whisper transcription | Yes (if key set) |
POST | /v1/audio/translations | Whisper English translation | Yes (if key set) |
POST | /v1/images/generations | Image generation; mounted only when --image-model loads a checkpoint. See Image Generation | Yes (if key set) |
POST | /v1/images/edits | Image editing with reference images; same worker and mounting rule | Yes (if key set) |
GET (WebSocket upgrade) | /v1/realtime | Nemotron VoiceChat full-duplex speech session; mounted only when a VoiceChat checkpoint is served. See Nemotron VoiceChat Realtime API | Yes (if key set) |
GET | /v1/cache/stats | Prompt-cache statistics | Yes (if key set) |
POST | /v1/cache/reset | Clear the prompt-cache store | Yes (if key set) |
POST | /chat/completions | Alias for /v1/chat/completions | Yes (if key set) |
POST | /completions, /completion | llama-server-native completion; not an alias for /v1/completions | Yes (if key set) |
POST | /embedding, /embeddings | llama-server-native embeddings; not aliases for /v1/embeddings | Yes (if key set) |
POST | /rerank, /reranking | Same rerank handler as the /v1 paths | Yes (if key set) |
POST | /messages | Alias for /v1/messages | Yes (if key set) |
POST | /messages/count_tokens | Alias for /v1/messages/count_tokens | Yes (if key set) |
GET | /models | Alias for /v1/models | Yes (if key set) |
POST | /responses | Alias for /v1/responses | Yes (if key set) |
GET | /responses/:id | Alias for /v1/responses/:id | Yes (if key set) |
DELETE | /responses/:id | Alias for /v1/responses/:id | Yes (if key set) |
POST | /responses/:id/cancel | Alias for /v1/responses/:id/cancel | Yes (if key set) |
POST | /audio/speech, /audio/transcriptions, /audio/translations | Audio aliases without /v1 | Yes (if key set) |
POST | /tokenize | Tokenize a text string | Yes (if key set) |
POST | /detokenize | Detokenize a token list | Yes (if key set) |
POST | /apply-template | Render the chat template and return the exact prompt /v1/chat/completions would have been served with | Yes (if key set) |
POST | /infill | llama-server fill-in-the-middle completion | Yes (if key set) |
POST | /v1/chat/completions/input_tokens, /chat/completions/input_tokens | Input token count for a chat request without generating | Yes (if key set) |
POST | /v1/responses/input_tokens, /responses/input_tokens | Input token count for a Responses request without generating | Yes (if key set) |
POST | /v1/chat/completions/control | Realtime control of a live completion, matching llama-server b10621 | Yes (if key set) |
GET | /v1/stream | Replay a resumable stream by conversation id and offset | Yes (if key set) |
DELETE | /v1/stream | Drop a retained stream | Yes (if key set) |
POST | /v1/streams/lookup | Look up retained streams for a list of conversation ids | Yes (if key set) |
GET | /lora-adapters | List loaded LoRA adapters and their scales | Yes (if key set) |
POST | /lora-adapters | Set LoRA adapter scales without restarting the server | Yes (if key set) |
GET | /v1/settings, /settings | Typed settings schema, current values, and a stable mutable fingerprint | Yes (if key set) |
PATCH | /v1/settings, /settings | Validate and publish a settings merge or replace; rejected names do not roll back valid ones | Yes (if key set) |
GET | /health, /v1/health | Health check | No |
GET | / | Health check (alias) | No |
GET | /slots | Per-slot status (enabled by default; disable with --no-slots) | Yes (if key set) |
GET | /props | Server properties (requires --props) | Yes (if key set) |
GET | /metrics | Prometheus metrics (requires --metrics) | Yes (if key set) |
POST | /slots/:id_slot | Per-slot action: ?action=save, restore, or erase | Yes (if key set) |
GET | /v1/internal/mtp-policy | Versioned read of the B=1 MTP verdict (settled, profiling, forced, unavailable, exactness_declined) | Yes (if key set) |
GET, POST | /tools, /cors-proxy | Recognized for llama-server compatibility but disabled; returns 403 with a feature_disabled body | Yes (if key set) |
GET | /webui/ | Bundled WebUI shell and static assets (requires --webui) | No |
GET, POST | /ui-api/v1/* | WebUI control plane: bootstrap, catalog, model actions, downloads, removals, operations, events, and runtime observation (requires --webui) | Yes, always |
The /slots endpoint is enabled by default and can be disabled with --no-slots. The /props and /metrics endpoints are disabled by default; enable them with --props and --metrics. Prompt-cache endpoints are always mounted and return a stable disabled payload when the cache is off.
For full request and response schemas, see the API Reference and Embedding and Reranking APIs.
Serving Audio Models¶
Whisper and Kokoro checkpoints populate an audio-only server slot. A Whisper process serves transcription and translation; a Kokoro process serves speech synthesis. Neither process also serves chat generation.
# STT
mlxcel-server -m ./models/whisper-base \
--audio-queue-depth 8 --audio-request-timeout-secs 120
# TTS
mlxcel-server -m ./models/kokoro-82m \
--audio-queue-depth 8 --audio-request-timeout-secs 120
See OpenAI Audio API for checkpoint layouts, request schemas, supported WAV formats, voice packs, limits, and error responses.
Serving Nemotron VoiceChat (Full-Duplex Speech)¶
A Nemotron VoiceChat checkpoint (model_type: "nemotron_voicechat", for example mlx-community/NemotronLabs-VoiceChat-11B-4bit) is served over a WebSocket at /v1/realtime instead of the chat routes:
The server loads the checkpoint on a dedicated realtime engine thread and starts neither the chat worker, the batch scheduler, nor the text warmup. /v1/chat/completions, /v1/completions, /v1/responses, and /v1/messages answer 501 Not Implemented with a message naming /v1/realtime.
--api-keyapplies to the upgrade request (sendAuthorization: Bearer <key>), and--api-prefixprefixes the path like every other route.--cors-originsand--allowed-originsgate the upgrade by itsOriginheader. A browser origin the policy does not allow gets403 Forbidden; a client that sends noOriginis accepted.- The engine admits one session at a time. A second connection while a session is active receives a
server_busyerror and close code 1013. - Limits: 1 MiB per WebSocket message or frame, 600 s of audio per session, 30 s to send
session.updateafter connecting, and 120 s without a client message before an idle session is closed.
The CLI form of the same model is mlxcel generate --audio <wav> --output-audio <wav> (see CLI Reference). See Nemotron VoiceChat Realtime API for the message protocol.
Serving Embedding and Reranking Models¶
The server can run an embedding model and a reranker beside a chat model. Each specialized provider owns its model, tokenizer, and MLX arrays on a dedicated worker thread, so neither shares the chat worker's stream.
mlxcel-server \
-m mlx-community/Qwen3-4B-4bit \
--embedding-model Qwen/Qwen3-Embedding-0.6B \
--reranker-model mlx-community/Qwen3-Reranker-0.6B-4bit \
--port 8080
An embedding checkpoint supplied directly to -m starts an embedding-only process; a one-label ForSequenceClassification checkpoint starts a rerank-only process. Generative rerankers require explicit selection because their config resembles a generator:
# Generative reranker only; the duplicate path selects the kind without loading twice.
mlxcel-server \
-m mlx-community/Qwen3-Reranker-0.6B-4bit \
--reranker-model mlx-community/Qwen3-Reranker-0.6B-4bit
One process supports one embedding provider and one reranker provider. --embedding-batch-size defaults to 16. --rerank-batch-size 0 selects the kind default of 8 text pairs or 2 multimodal pairs. Each worker queue is bounded by --embedding-queue-depth (default 8) and both request types use --embedding-request-timeout-secs (default 120). A full queue returns 503 and a reply timeout returns 504.
--pooling and --embd-normalize override the checkpoint's dense-vector contract. Use them only when the model's documentation or a compatibility requirement calls for it. --embedding/--embeddings and --rerank/--reranking select restricted llama-server-compatible modes and require the matching provider.
See Embedding and Reranking APIs for exact wire shapes and Embeddings and Reranking for model families, prefix rules, MaxSim, index design, and evaluation.
Serving an Image Model¶
--image-model <checkpoint> (or MLXCEL_IMAGE_MODEL) loads an image-generation model beside the chat model, on its own worker thread with a bounded queue, the same way --embedding-model loads an embedding provider. Requests run one at a time, because a diffusion run holds the GPU for seconds: a full queue answers 503 and a per-request timeout answers 504. A load failure is fatal at startup, so a server that was asked for image routes never answers 501 for them.
Dropping -m selects image-only serving. The image checkpoint becomes the served model, and its id is the first --alias entry or the checkpoint directory name:
mlxcel-server --image-model ./models/flux2-klein --port 8080
mlxcel serve --image-model ./models/flux2-klein
/health reports ready once the checkpoint loads, /v1/models lists that id with the image capability and no completion, and the /v1/images/* routes run normally. Chat, completion, prompt-inspection, /tokenize, /detokenize and /apply-template answer the same structured 501 that embedding-only mode uses, naming --image-model and no -m. Image-only mode cannot be combined with --models-dir, --models-preset, --webui, --embedding-model, --reranker-model, --embeddings or --reranking; startup fails naming both flags.
The routes, checkpoint families, and request schemas are in Image Generation.
Server Lifecycle¶
The server goes through a defined sequence of lifecycle states from startup to shutdown.
sequenceDiagram
participant CLI
participant Startup
participant ModelLoader
participant BatchScheduler
participant HTTP
CLI->>Startup: mlxcel serve --model ...
Startup->>Startup: Initialize logging
Startup->>Startup: Resolve API key
Startup->>Startup: Resolve chat template
Startup->>ModelLoader: Load model weights
ModelLoader-->>Startup: Model ready
Startup->>BatchScheduler: Start batch scheduler
Note over Startup: Warmup pass (1 token)
Startup->>HTTP: Bind TCP / Unix socket
HTTP-->>CLI: Server listening on :8080
loop For each request
HTTP->>BatchScheduler: Enqueue request
BatchScheduler->>ModelLoader: Prefill + decode
ModelLoader-->>HTTP: Token stream
HTTP-->>CLI: SSE / JSON response
end
CLI->>HTTP: SIGINT / SIGTERM
HTTP-->>CLI: Graceful shutdown Lifecycle state descriptions:
- Logging initialization - Tracing subscriber is configured based on
--verboseand--log-fileflags. - API key resolution - The key is read from
--api-key,--api-key-file, orLLAMA_API_KEY. - Chat template resolution - The Jinja2 template is loaded from
--chat-template,--chat-template-file, or the model'stokenizer_config.json. - Model loading - Safetensor weight files are read and their tensors uploaded before the server reports
Model loaded, so the first request pays no weight I/O (MLXCEL_WEIGHT_MATERIALIZATION=lazydefers this to first use). The scheduler logs the decode path per model, for exampledecode path: native batched forward. If--surgeryis set, the configured weight surgery pipeline is applied during loading. If--adapteris set, LoRA weights are fused through the adapter path. - Batch scheduler start - The continuous batching scheduler is initialized with the configured queue depth and batch size limits.
- Warmup pass - A single one-token generation pass is run to trigger Metal shader compilation. This prevents the first real request from experiencing compilation latency. Disable with
--no-warmup. - Socket binding - The server binds to the TCP address or Unix socket and begins accepting connections.
The server does not have an explicit graceful shutdown handler beyond standard signal propagation. Active requests that are in flight when the process receives SIGINT or SIGTERM are interrupted. On exit, the server joins its chat model worker and every side worker (audio, embedding, image, rerank, and realtime) before main returns, because a thread that frees MLX arrays after the C runtime's static destructors run aborts a CPU build with pure virtual method called. The join is bounded by a 120 s WORKER_EXIT_TIMEOUT (150 s for the router's pool drain). A worker still loading when the deadline expires keeps running, and the server logs that it is still running rather than reporting a clean exit.
Monitoring¶
Health Check¶
The /health endpoint returns a JSON body describing the server status. No authentication is required, making it suitable for load balancer health probes. It returns HTTP 200 when the model is loaded and capacity is available, and HTTP 503 while the model is still loading or no slot is available.
{
"status": "ok",
"model": "Qwen2.5-7B-Instruct",
"batch": {
"active_sequences": 2,
"queue_depth": 0,
"max_batch_size": 4
},
"observability": {
"sequences_started": 145,
"sequences_completed": 143
},
"context_size": 0,
"tool_call_parser": "mlxcel"
}
context_size is the effective per-slot context window. With an explicit --ctx-size and multiple active slots, it reports the shared budget after division, not the original total budget.
Prometheus Metrics¶
When started with --metrics, the server exposes a /metrics endpoint in Prometheus text format:
mlxcel serve --model ./models/Qwen2.5-7B-Instruct --metrics
curl -s http://localhost:8080/metrics | grep mlxcel
Metrics include batch occupancy, request queue depth, tokens generated, and request completion counts.
mlxcel_requests_total and mlxcel_completion_tokens_total count a chat completion the same way whether it streams or not. A streamed chat completion is recorded once when its generation finishes, with the token counts of its usage chunk, whether or not the request asked for that chunk. A stream the client cancelled is not counted: a disconnect of a stream sent without X-Conversation-Id, DELETE /v1/stream, or replacement of the session by a new request with the same X-Conversation-Id. A resumable stream whose client only disconnects keeps generating and is counted when it finishes. Streaming /v1/completions and native /completion requests are not recorded in these counters.
Slot Status¶
By default, the /slots endpoint shows the current state of each concurrency slot, including the same effective per-slot context_size reported by /health. Disable it with --no-slots if needed:
Integration Examples¶
curl¶
Streaming chat completion:
curl -s http://localhost:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "Qwen2.5-7B-Instruct",
"messages": [{"role": "user", "content": "Count from 1 to 5."}],
"stream": true
}'
Non-streaming text completion:
curl -s http://localhost:8080/v1/completions \
-H "Content-Type: application/json" \
-d '{
"model": "Qwen2.5-7B-Instruct",
"prompt": "The capital of France is",
"max_tokens": 5
}'
Python requests¶
import requests
response = requests.post(
"http://localhost:8080/v1/chat/completions",
json={
"model": "Qwen2.5-7B-Instruct",
"messages": [{"role": "user", "content": "What is Rust?"}],
"max_tokens": 200,
},
)
print(response.json()["choices"][0]["message"]["content"])
OpenAI Python SDK¶
mlxcel's /v1/chat/completions endpoint is schema-compatible with the OpenAI API. Point the base_url at the mlxcel server:
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:8080/v1",
api_key="sk-any-value", # required by the client library; ignored if server has no key set
)
response = client.chat.completions.create(
model="Qwen2.5-7B-Instruct",
messages=[{"role": "user", "content": "Explain monads in simple terms."}],
max_tokens=300,
)
print(response.choices[0].message.content)
Streaming with the OpenAI SDK:
stream = client.chat.completions.create(
model="Qwen2.5-7B-Instruct",
messages=[{"role": "user", "content": "Write a haiku about Rust."}],
stream=True,
)
for chunk in stream:
delta = chunk.choices[0].delta.content
if delta:
print(delta, end="", flush=True)
Request Priority¶
The server supports three priority levels for the prefill queue: high, normal (default), and low. Set the X-Priority header to control queue ordering:
curl http://localhost:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-H "X-Priority: high" \
-d '{"model":"Qwen2.5-7B-Instruct","messages":[{"role":"user","content":"Urgent query"}]}'
High-priority requests move ahead of normal and low-priority requests in the prefill queue. This is useful when some clients (for example, interactive UI requests) need lower latency than background batch jobs.
Reproducible Greedy Output (cache_prompt)¶
A prompt-cache hit is a split prefill: the cached prefix came from an earlier forward and only the remainder is prefilled now. The split changes the floating-point summation order, so the first generated token's logits are close to a cold prefill's but not always bit-identical, and a near-tie can flip a greedy reply. This is not cache corruption, and no split size removes it.
When a request needs output that does not depend on cache state (regression tests, evaluation runs, comparing two builds), send cache_prompt: false. The request is prefilled cold and neither reads nor donates cache entries. The field is honored at the request root, through the flattened OpenAI-SDK extra_body, or nested inside extra_body, on the chat routes and on native /completion. --no-cache-prompt is the server-wide form.
curl http://localhost:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"m","temperature":0,"cache_prompt":false,"messages":[{"role":"user","content":"Hello"}]}'
See Prompt Cache for when a cache hit reproduces the cold run exactly.
Long Prefill and Proxy Timeouts¶
When a prompt is large (32k+ tokens), the prefill forward pass can take tens of seconds before the first generated token is emitted. During that window the SSE stream is open but silent.
Reverse proxies and load balancers apply per-connection idle timeouts that fire before the first token arrives:
| Proxy | Relevant setting | Default |
|---|---|---|
| nginx | proxy_read_timeout | 60 s |
| HAProxy | timeout tunnel | 60 s |
| AWS ALB | idle timeout | 60 s |
| Cloudflare | no-response timeout | 100 s |
mlxcel-server sends an SSE keepalive comment every 15 seconds to prevent these drops. The comment is invisible to clients that parse SSE token events; it only resets the proxy idle timer.
If you see clients receiving 504 or connection-reset errors on long-context requests, the first thing to check is whether the proxy idle timeout is shorter than the prefill time. Raising proxy_read_timeout to 300+ seconds (or disabling the timeout on the tunnel leg) is the most reliable fix for very long prompts. The --decode-timeout flag controls the server-side decode-hang detection (default: 600 s), not the prefill step; setting it does not prevent proxy drops. --timeout is the HTTP socket read/write timeout (default: 3600 s).
KV Cache Size Limit (--max-kv-size)¶
By default, the KV cache grows without bound for the lifetime of each sequence. On long-context conversations or generation tasks that exceed the model's context window, memory footprint grows proportionally with the number of tokens processed. --max-kv-size N caps the live KV window at N tokens: once a sequence's cache exceeds N tokens the scheduler drops the oldest entries before each forward pass so only the most recent N tokens participate in attention.
RoPE-faithful semantics: the cache's monotonic write position (offset) is never decremented. When the live window is trimmed, live_start advances instead, so every cached key's relative position to the current query stays correct under RoPE. This matches mlx-lm's position semantics and avoids the position drift that a naive offset-decrement approach would introduce.
CLI flag (applies server-wide to all sequences):
Environment variable (CLI flag wins on conflict):
Valid range: 0 (disabled, the default) or any integer from 64 to 2147483647 (i32::MAX). Values below 64 are rejected at startup because they are smaller than the prefill step granularity and would cause the cache to trim on every decode step without providing a useful live window. Values above i32::MAX are rejected because the MLX FFI uses i32 for all cache shape and slice indices.
Sliding-window models are unaffected: models that already manage their own rotating window (RotatingKVCache), namely Gemma ¾, Exaone 4, RecurrentGemma, Step 3.5, and gpt-oss, apply their own model-level cap and ignore --max-kv-size.
Turbo KV quantization limitation: --max-kv-size is not supported together with Turbo KV quantization (--kv-cache-mode turbo4* / --kv-quant-scheme turboquant). When both are set, the live-window cap is silently skipped for Turbo-quantized layers and a warning is logged at startup. Interaction with the paged memory backend is out of scope for this release and tracked separately.
Nemotron-H Request Memory Admission¶
On Metal, text-only Nemotron-H serving checks a conservative memory estimate before it starts each request. The scheduler clamps this family to one active slot. A request whose estimate exceeds the budget never runs GPU forward work and gets HTTP 503 with error type request_memory_budget_exceeded; a streaming request receives the 503 before SSE headers are sent. /health stays available, and smaller requests run afterwards. Existing context-length checks take precedence and stay HTTP 400.
The budget is the smallest of the device's recommended working-set size, the configured wired-memory cap, and the current MLX allocator cap. MLXCEL_REQUEST_MEMORY_BUDGET (for example 24G) can lower it but never raise it; an explicit zero, empty, none, negative, nonfinite, or overflowing value stops server startup. The estimate covers resident weights and retained prompt snapshots, cold KV for prompt plus planned output, recurrent and convolution state, and the largest forward of the request's prefill plan.
Guarded requests prefill cold: they neither restore nor donate prompt snapshots, and background prompt-cache warmups are skipped. Both the batch scheduler and --no-batch serving apply the guard. Metal Nemotron-H runtime (unfused) LoRA fails at model construction because its activation workspace is not modeled; use a fused adapter checkpoint instead. The guard does not apply to CUDA, ROCm, the CPU backend, other model families, multimodal paths, or speculative decoding, and it is not a device-wide reservation against memory used by other processes.
Loop Detection (N-gram Repetition Guard)¶
Some checkpoints can collapse into a degenerate loop under tool- or grammar-constrained decoding, where a single token or a short block repeats until the token budget is exhausted. mlxcel can detect a short pattern repeating at the tail of the generated stream and end the request early, the same countermeasure vLLM exposes in SamplingParams.
The Gemma 4 family enables this unconditionally by default (threshold min=1, max=20, count=4), independent of whether the request uses tools or a json_schema response format. Other families leave it off unless a request opts in.
Control it at three levels, each overriding the one before it:
- Per-family default: on for Gemma 4, off otherwise.
- Operator override (
MLXCEL_LOOP_DETECTION): forces the policy for every request on this server. See MLXCEL Environment Variables. - Per-request fields (
max_pattern_size,min_pattern_size,min_countin the request body): always win, including an explicitmax_pattern_size: 0to force-disable on a Gemma 4 server. See REST API Reference.
Reasoning Re-echo for Content-Only Clients¶
Some chat templates render an earlier turn differently depending on whether the following assistant message carries its reasoning. AI21 Jamba-Reasoning, for example, keeps its thinking instruction on an earlier user turn only in that case, so a client that sends back only content (the OpenAI SDK default) re-renders that turn shorter than it was generated and the follow-up misses the prompt cache.
While the prompt cache is on, the chat routes remember the exact reasoning they returned for each plain (non-tool-call) reply and put it back into a later content-only assistant turn, as if the client had echoed it:
| Route | Reasoning that is re-injected |
|---|---|
/v1/chat/completions | reasoning_content |
/v1/responses | the reasoning output item |
/v1/messages | the thinking block, recorded only when the request enabled extended thinking |
A stored trace is found only by a request with the same model, chat-template signature (template, kwargs, tools), session (prompt_cache_key, then user), identical preceding messages, and identical assistant content. Any edit misses, a miss changes nothing, and reasoning the client echoes itself always wins. Echoed reasoning reaches the chat template under both reasoning_content and reasoning, so templates that read either field render it once. The store holds only a 32-byte key and the reasoning text, is bounded to 16 MiB and 4096 entries with least-recently-used eviction (MLXCEL_REASONING_ECHO_MAX_BYTES, 0 disables it), and is empty after a restart. It is not consulted under cache_prompt: false, --reasoning-format none or deepseek-legacy, or --skip-chat-parsing.
Templates whose generation prompt ends in <think>\n (primed-think templates) stream the model's reasoning as reasoning from the first token on all three routes.
Thinking-Token Budget (Qwen3 family)¶
Qwen3, Qwen3.5, and Qwen3.6 generate a <think>...</think> reasoning block before the final answer. mlxcel-server caps how many tokens the model can spend inside that block, matching the semantics of llama.cpp's --reasoning-budget flag and vLLM's thinking_token_budget sampling parameter.
| Value | Behavior |
|---|---|
-1 | Unrestricted (default). |
0 | Immediate </think> on the first reasoning token. |
N > 0 | Cap the reasoning block at N tokens, then force </think>. |
The opening <think> token is not counted toward the cap (matching vLLM). For models without <think> / </think> tokens the parameter is silently ignored, and the server logs one warning per model load.
CLI flag (applies to every request unless overridden per-request):
Environment variable (CLI flag wins on conflict):
Per-request override on /v1/chat/completions. The primary name is thinking_budget_tokens; thinking_token_budget (vLLM) and thinking_budget (Qwen) are accepted as aliases. When more than one is present, the primary name wins:
curl http://localhost:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "qwen3",
"messages": [{"role":"user","content":"Explain gradient descent."}],
"max_tokens": 512,
"thinking_budget_tokens": 256
}'
Per-request override on /completion (llama.cpp-style). The same three alias names are accepted:
curl http://localhost:8080/completion \
-H "Content-Type: application/json" \
-d '{
"prompt": "<|im_start|>user\nExplain gradient descent.<|im_end|>\n<|im_start|>assistant\n",
"n_predict": 512,
"thinking_budget_tokens": 256
}'
Resolution precedence (highest wins):
- Per-request body field (
thinking_budget_tokens, thenthinking_token_budget, thenthinking_budget). --reasoning-budget <N>CLI flag.LLAMA_ARG_REASONING_BUDGETenv var.- Default: unrestricted (
-1).
A per-request value of -1 reverts to unrestricted for that one request, even if the server default was set. When thinking_budget_tokens > 0 it must be <= max_tokens (or n_predict on /completion); otherwise the server returns a 400. Other negative values (e.g. -2) also return 400.
When the budget fires, the forced </think> is emitted as a normal SSE delta chunk in streaming mode and counted toward finish_reason accounting (it never triggers user-supplied stop sequences).
Chat Template Kwargs¶
Some HuggingFace chat templates accept feature switches rendered directly into the prompt. mlxcel-server accepts a JSON object of template kwargs at startup (as a default) and per-request (as an override), matching the shape of llama.cpp's --chat-template-kwargs flag and vLLM's extra_body.chat_template_kwargs. Today the canonical consumer is Qwen3.6's preserve_thinking for multi-turn reasoning retention, but the mechanism is generic so future keys inherit the same plumbing.
CLI flag:
mlxcel-server --model ./models/Qwen3.6-35B-A3B \
--chat-template-kwargs '{"preserve_thinking": true}'
Environment variable (CLI wins on conflict):
LLAMA_ARG_CHAT_TEMPLATE_KWARGS='{"preserve_thinking": true}' \
mlxcel-server --model ./models/Qwen3.6-35B-A3B
Per-request override on /v1/chat/completions. Three shapes are accepted; the first match wins:
- Top-level
chat_template_kwargs(llama.cpp shape):
- Nested under
extra_body(vLLM / OpenAI Python SDK shape):
client.chat.completions.create(
model="qwen3.6",
messages=[...],
extra_body={"chat_template_kwargs": {"preserve_thinking": True}},
)
- DashScope flat shape (secondary; only recognized for the
preserve_thinkingkey):
Merge semantics: per-request keys override the server default on a per-key basis; unrelated server-default keys persist through the merge. If the server is started with {"preserve_thinking": true, "enable_thinking": false} and a request supplies {"preserve_thinking": false}, the effective kwargs are {"preserve_thinking": false, "enable_thinking": false}.
Resolution precedence (highest wins):
- Top-level
chat_template_kwargson the request. extra_body.chat_template_kwargson the request.extra_body.preserve_thinkingon the request (DashScope,preserve_thinkingkey only).--chat-template-kwargsCLI flag.LLAMA_ARG_CHAT_TEMPLATE_KWARGSenv var.- Default: empty (no kwargs).
preserve_thinking behavior:
true:<think>...</think>blocks from every prior assistant turn reach the model unchanged.false(default): assistant messages before the latest non-tool user turn have their<think>blocks stripped server-side, matching the Qwen3/Qwen3.5 rolling-checkpoint convention.
Non-thinking models (Qwen2, Llama) silently ignore the flag. Qwen3 / Qwen3.5 accept the flag, but the multi-turn quality benefit is only validated on Qwen3.6 because earlier Qwen3 variants were not trained on preserved reasoning traces.
Malformed JSON on --chat-template-kwargs or LLAMA_ARG_CHAT_TEMPLATE_KWARGS causes the server to refuse to start with a clear error.
enable_thinking auto-default for thinking models:
At startup, after loading the tokenizer, the server calls infer_thinking_markers() to resolve the model's think-start / think-end token sequences. If a matching pair is found, the server automatically seeds enable_thinking=true as a server-level default kwarg. Recognized pairs are single-token <think> / </think> (Qwen3, GLM4, Nemotron-H, ExaOne4), single-token <longcat_think> / </longcat_think> (some fine-tunes), and multi-token <|channel>thought / <channel|> (Gemma 4). That default flows through build_template_context the same way any other server-default kwarg does, so per-request chat_template_kwargs or extra_body.chat_template_kwargs can still override it.
For non-thinking models the flag is never injected, leaving their chat templates unaffected. The server emits one INFO log line at startup identifying the resolved marker pair and the number of tokens in each sequence, which is useful for verifying that a Gemma 4 multi-token marker is being resolved correctly:
INFO Tokenizer recognizes a think marker pair; defaulting chat_template kwarg `enable_thinking=true` think_start=Some("<|channel>thought") think_end=Some("<channel|>") think_start_tokens_len=2 think_end_tokens_len=1
To disable thinking mode for an individual request on a thinking model, pass enable_thinking=false in any of the three per-request shapes described above.
reasoning_effort mapping: the chat-completions reasoning_effort field is mapped onto the reasoning_effort chat-template kwarg under three guards. An explicit chat_template_kwargs.reasoning_effort wins, the loaded template must actually mention the name, and the value is passed through rather than translated. /v1/responses does not do this mapping for its reasoning.effort field; set chat_template_kwargs explicitly there.
A template that refuses a request fails the request. When rendering the chat template raises, the request returns HTTP 400 before generation starts rather than falling back to a plain concatenation that the model was not trained on. This covers the typed path and the raw/multimodal path alike, so a request carrying tool calls, a prior-turn reasoning field, or typed media parts gets the same answer. A streaming request returns the 400 in place of the SSE stream rather than failing mid-stream.
Serving Florence-2¶
Florence-2 is an encoder-decoder model, so the server routes it to a dedicated single-stream seq2seq worker rather than the batch or paged scheduler. Requests queue on the worker channel, bounded by --max-queue-depth, and are answered one at a time by design: the encoder pass has a different cost profile from the decode loop, and no batched admission policy has been measured for it.
Send an OpenAI-compatible POST /v1/chat/completions with one user message whose text is the Florence-2 task prompt and one image_url content part. message.content carries the rendered answer and the mlxcel-specific message.florence2_result field carries the parsed coordinates as JSON.
Decode is greedy, so sampling parameters and response_format are accepted and ignored, and stream: true delivers the whole answer as a single delta chunk because the parsed result only exists after the full decode. The input text of the seven input-taking task modes is validated at the request boundary, all declared images must resolve, and image payloads pass through the same decompression-bomb bounds every other family uses. usage.prompt_tokens reports the fused encoder length including the projected image tokens.
See Vision-Language Models for the task markers and the CLI form.
Serving Block-Diffusion Models¶
When the loaded checkpoint is a block-diffusion model (DiffusionGemma), the server routes requests to a dedicated serial worker instead of the batch scheduler: requests are queued in arrival order and processed one at a time, by design. /v1/chat/completions and /v1/completions both work, with streaming and non-streaming responses, and image input follows the standard OpenAI image_url content part format.
Three serve-level flags tune the diffusion engine: --diffusion-sampler, --diffusion-threshold, and --max-denoising-steps. The checkpoint is a thinking-channel model, so give requests enough max_tokens (a budget of 64 may produce only reasoning with an empty content; 256 covers most short prompts). See Block Diffusion for the generation mechanism, flags, and operational caveats.