Everything below is served by one process on one port — chat, embeddings, and image / video / music / voice / 3D generation. OpenAI-compatible, Anthropic-compatible, and Ollama-compatible, so existing SDKs and apps connect unchanged. No API key on localhost — it's your machine — and one optional key the moment you expose it to the network.
Start the server from the MLX Core app (it runs whenever a model is loaded) or from the CLI:
mlx-serve serve # serves everything under ~/.mlx-serve/models, loads on demand mlx-serve run gemma4 # or: download one model, serve it, and chat
The default port is 11234 (--port to change it). Models are addressed by name — the model field accepts a model's directory name, an org/repo id, or an Ollama-style short name with tag. A request naming a discovered-but-unloaded model loads it on the fly.
curl http://localhost:11234/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "gemma-4-e4b-it-8bit",
"messages": [{"role": "user", "content": "Why is the sky blue?"}],
"stream": true
}'Or with the OpenAI SDK — just point base_url at your Mac:
from openai import OpenAI
client = OpenAI(base_url="http://localhost:11234/v1", api_key="unused")
r = client.chat.completions.create(
model="gemma-4-e4b-it-8bit",
messages=[{"role": "user", "content": "Hello!"}])
print(r.choices[0].message.content)| Endpoint | What it does |
|---|---|
GET/health | Liveness probe — 200 when the server is up. |
GET/v1/models | Every model the server can serve — loaded or discoverable — with a capabilities array (chat, tool_use, streaming, vision, audio, reasoning, json_schema, embeddings, image, music, video, 3d) so clients know what each one can do. |
POST/v1/chat/completions | Chat with streaming (SSE), tool calling, vision (image parts on multimodal models), JSON mode / response_format, logprobs / top_logprobs, and thinking models. The workhorse endpoint. |
POST/v1/completions | Raw text completion (no chat template) — FIM / code-completion friendly. logprobs here is an integer and returns OpenAI's legacy four-array shape. |
POST/v1/embeddings | Batched embeddings from encoder models (BERT/bge, EmbeddingGemma, Qwen3-Embedding) — the whole input array runs as one padded GPU forward. Pooling is read from the checkpoint, and dimensions truncates + renormalizes like text-embedding-3. |
Every response carries usage.prompt_tokens_details.cached_tokens, so you can see what the prefix cache saved. stream_options: {"include_usage": true} adds the usage chunk at the end of a stream. When a reply is cut because the model went in circles, finish_details: {"type": "repetition_loop"} sits beside finish_reason instead of the cut being reported as a plain length stop.
All optional; every endpoint stays spec-compatible without them.
| Field | Meaning |
|---|---|
enable_thinking | Toggle thinking/reasoning on models that support it; reasoning arrives as reasoning_content, never leaked into the visible text. |
reasoning_effort | OpenAI's "low" / "medium" / "high", mapped to a thinking budget (or to the model's own effort setting where it has one, like DeepSeek V4). |
reasoning_budget_tokens | Cap thinking tokens per request. An explicit value outranks reasoning_effort. |
kv_quant | Per-request KV-cache quantization: "off", 4, 8, "turbo2", "turbo4". |
kv_attn_mode | Read path for a quantized KV cache: "auto" (default), "dense", "fused". Fused reads the packed cache in place and pays off at long context. |
enable_pld / enable_drafter / enable_mtp | Per-request control over the three speculative-decoding paths (deep dive). Defaults are already sensible — outputs stay equivalent either way. |
Sampling defaults resolve in this order: request body → server launch flags → the model's own generation_config.json → engine defaults. Clients that omit temperature/top_p/top_k get what the model's authors recommend.
POST /v1/messages implements the Anthropic Messages API — typed content blocks, tool_use / tool_result, thinking blocks, and the full SSE event lifecycle (message_start, content_block_delta, …). It's what lets Claude Code run against your local model:
export ANTHROPIC_BASE_URL=http://localhost:11234
export ANTHROPIC_API_KEY=unused
claude # Claude Code now talks to your Mac| Endpoint | What it does |
|---|---|
POST/v1/responses | The stateful Responses API: typed output items, previous_response_id chaining, tool calls, sequence-numbered SSE events. |
GET/v1/responses/{id} | Fetch a stored response. |
DELETE/v1/responses/{id} | Delete a stored response. |
POST/v1/responses/compact | Compact a long conversation into an opaque token-light blob that round-trips through the next request. |
WS/v1/responses | Same endpoint over WebSocket: send response.create-shaped JSON frames, receive each SSE event as one text frame. |
Tools built for Ollama — Raycast, Obsidian, Enchanted, Open WebUI, ollama-python/js — connect unchanged (the swap, in depth). Run on Ollama's port and nothing needs configuring at all: mlx-serve serve --port 11434.
| Endpoint | What it does |
|---|---|
POST/api/chat · /api/generate | Ollama chat / generate, NDJSON streaming, tool calls with object arguments, think, images. |
POST/api/embed · /api/embeddings | Embeddings, new and legacy shapes. |
GET/api/tags · /api/ps · /api/version | Model listing, loaded models, version. |
POST/api/show · /api/pull | Model metadata; native HuggingFace pull by short name. |
Each media endpoint accepts "stream": true for SSE progress events ending in a base64 complete payload; without it the response is the finished artifact. Load and unload media models like any other (below) — chat and media coexist in one process.
/v1/images/generationsFLUX.2-klein (4B and 9B), Krea-2-Turbo and Mage-Flow Turbo / Edit (deep dive). Returns base64 PNG.
| Field | Meaning |
|---|---|
prompt | Required. |
size | "WxH", rounded to the backend's own grid: FLUX to a multiple of 32 in 256–1536, Krea and Mage-Flow to a multiple of 16 in 256–2048. Omitted in "edit" mode means "match the source". |
image + mode | Base64 source image with "variation" (img2img, plus strength 0–1) or "edit" (instruction edit on FLUX.2 or Mage-Flow Edit — "make the hair blue" keeps subject and scene). |
ref_images | Up to 3 extra base64 reference images beside image in "edit" mode — refer to them by number in the prompt: "replace the face of the man in image 1 with the face from image 2". Each keeps its own aspect ratio. |
lora_paths / lora_scales | Runtime style LoRAs (.safetensors, diffusers / kohya / PEFT layouts), up to 8, absolute paths. They stack: every adapter is summed at forward time, never merged, so nothing is re-quantized. Each file's own declared alpha is honored. The singular lora_path / lora_scale still works. |
cond_gain / cond_weights | Text-conditioning rebalance knobs. |
steps, seed | Sampler steps, reproducibility. |
/v1/images/editsThe OpenAI SDK's edit shape, so client.images.edit(image=…, prompt=…) works unchanged against any edit-capable model you have loaded. This one is multipart/form-data, not JSON, and it is pure translation into the mode:"edit" body above — there is no second inference path.
client.images.edit(
model="mage-flow-edit-turbo-8bit",
image=[open("room.png","rb"), open("chair.png","rb")], # repeated image[] = multi-reference
prompt="put the chair from image 2 into image 1")Accepted fields: model, prompt, image / image[] (up to 4), size. Anything we can't honor is a named 400 rather than a silent no-op: mask (the editors are maskless), n > 1, response_format: "url", an output_format other than png, and stream.
/v1/audio/speechQwen3-TTS (voice cloning) or Kokoro-82M (54 built-in voices), whichever is loaded (deep dive). Returns WAV bytes. The two backends share the endpoint but not the controls, and asking one for the other's control is a named 400 rather than a quietly plain-voiced answer.
| Field | Meaning |
|---|---|
input | The text to speak. Required (text also accepted). |
ref_audio | Qwen3-TTS only. Base64 WAV (24 kHz mono) — a few seconds of any voice clones it zero-shot, no transcript needed. {"warm_only": true} with a ref_audio caches the speaker embedding without synthesizing, so the first sentence of a session isn't cold. |
voice | Kokoro only. One of the 54 packs (default af_heart); a comma-separated list blends them, e.g. "af_bella,af_sky". An unknown name is a 400. |
speed / seed | Kokoro only. Speaking rate in (0, 5], default 1.0; seed for reproducibility. |
/v1/audio/music-generationsText-to-music, WAV out (deep dive): ACE-Step (48 kHz stereo, fast) or MiniMax Music 3 (sings full songs at 44.1 kHz, up to six minutes). On Music 3, lyrics is required and the musical-steering fields below don't exist; put tempo and key in the caption.
| Field | Meaning |
|---|---|
prompt | Style / genre / mood description. Required. |
lyrics | Optional on ACE-Step (empty means instrumental); required on MiniMax Music 3, structure tags like [verse] on their own lines. |
duration_seconds | 10–600, default 60. |
bpm / keyscale / timesignature | Musical steering — e.g. 120, "C major", "4/4". ACE-Step only. |
vocal_language, seed | Vocal language (default "en"); reproducibility. |
/v1/video/generationsOne endpoint, two backends: LTX-Video 2.3 / 2.5 (text, image or audio to video with synced sound; on 2.5, "decoder": "diffusion" picks its sharper diffusion decoder) and MiniMax-H3 / Hailuo 3.0 (video and its stereo soundtrack denoised jointly in one pass, long clips chained via chain_windows). Whichever is loaded serves the request, and the field a backend can't honor is a named 400 rather than a silently dropped setting (deep dive).
The response is raw frames plus raw audio, not a container: {"frames": N, "width", "height", "fps": 24, "format": "rgb8", "data": "<base64>"}, and when the model produced sound, audio_format: "pcm_s16le", audio_channels: 2, audio_sample_rate and audio_data beside it. Mux to MP4 on your side. These runs take minutes, so use "stream": true for progress and give your client a generous timeout.
Opt-in per-step preview (issue #208): "preview": true with "stream": true attaches a JPEG to each denoise progress event — Latent2RGB of the predicted clean latent, not a full VAE decode. Default off, so existing clients keep the {type, stage, step, total} shape. preview_frames (default 1, max 8) packs extra temporal slices as a horizontal filmstrip; preview_max_side (default 256, 0 = latent native) caps the long edge. Cached-velocity H3 steps emit progress without an image. complete is still the full rgb8 + PCM.
LTX-Video
| Field | Meaning |
|---|---|
prompt | Required. Quoted lines become spoken dialogue. |
width / height / num_frames | Output geometry and length (24 fps). |
pipeline | "one_stage" (fast default), "two_stage", "two_stage_hq" — dev-model guidance at half resolution, learned 2× upsample, distilled refine. |
first_frame_image | Base64 photo pinned as frame 0 — image-to-video. |
audio | Base64 WAV soundtrack the video performs to (two-stage only); the original clip is muxed into the MP4. |
cfg_scale / stg_scale / cfg_audio_scale / steps / stage2_steps / seed | Guidance and sampler knobs; sensible per-pipeline defaults. |
lora_paths / lora_scales | Same stacked-LoRA grammar as the image endpoint. |
MiniMax-H3 — narrower on purpose: no CFG scale and no pipeline mode (it is CFG-distilled and single-pipeline), and frame counts live on its own ladder. New in v26.8.2. It ships as two partitions that carry the same files and differ only in which conditioning they were trained for, so the server reads the pack's declared tasks and refuses what that build would ignore: FL2VA does keyframes and chained windows, REF2VA does references.
| Field | Meaning |
|---|---|
prompt | Required. Describe the scene, then the sound after overall_soundscape:. |
width / height | Multiples of 32, default 256. 1344×768 is the recommended size and takes about 50 minutes for 124 frames on an M4 Max. |
num_frames | Snapped to the model's 17k+5 ladder and the delivered count is reported back, so an audio mux can't drift. 24 fps, up to 15 seconds. |
turbo | true attaches the 4-step distillation adapter (about 2× faster end to end, slightly softer). steps then defaults to 4 and 4 is the floor; without turbo the default is 30. |
fast | The fast recipe (velocity cache + attention broadcast, about 2.8× at 768p) is on by default. false keeps every forward dense. |
first_frame_image / last_frame_image | FL2VA only. Base64 photo anchoring the start and/or end of the clip. An undecodable image is a 400, never a silent text-to-video. |
chain_windows | FL2VA only, 1–6. Generates that many back-to-back windows of num_frames, each continuing from the last frame of the one before, for a longer clip. |
ref_images / ref_videos / ref_audios | REF2VA only. Base64 PNG/JPEG strings, {"frames": […], "audio": "…"} objects, and WAV strings. Refer to them in the prompt by number: <Picture 1>, <Video 1>, <Audio 1>. ref_image_size is "match" (default) or "max". |
lora_paths / lora_scales | Same stacked grammar again, and turbo is simply file 0 of that stack, so a style LoRA rides on top of it. |
steps / seed | Sampler steps and reproducibility. |
/v1/3d/generationsHunyuan3D-2.1 — one photo in, a GLB mesh out; optional full-PBR texturing (deep dive). New in v26.7.2.
| Field | Meaning |
|---|---|
image | Base64 PNG/JPEG of the subject. Required. A cutout with transparency conditions best. |
texture | true runs the multiview paint stage — albedo + metallic-roughness baked into a 2K atlas (texture_steps to tune). |
octree_resolution | Mesh detail, 64–512 (default 256; the app uses 384). |
steps / guidance_scale / seed | Diffusion steps (default 30), guidance 0–20 (default 5), reproducibility. |
Response: {"created": …, "format": "glb", "data": "<base64 GLB>"} — standard glTF, opens anywhere.
| Endpoint | What it does |
|---|---|
POST/v1/load-model | {"model": "<id or absolute path>"} — load a discovered model, or register + load one from anywhere on disk. Add "default": true to make it the serving default without a restart. Multiple models stay resident (LRU-evicted under a memory cap). |
POST/v1/unload-model | {"model": "<id>"} — free a model's memory now. |
POST/v1/models/rescan | Pick up models downloaded while the server runs; the app calls it after every download. |
Requests are queued per model and batch-decode together where the architecture allows; a media generation runs on the same GPU and briefly pauses chat decode — one machine, one memory budget, no surprises.
--model-dir is repeatable, so a server can serve several folders at once. They merge first-wins, and every one of them shows up in /v1/models.
| Endpoint | What it does |
|---|---|
POST/tokenize | {"content": "…"} — token ids for a string, from the loaded model's own tokenizer. |
POST/detokenize | {"tokens": [1,2,3]} — back to text. |
GET/props | llama.cpp-style server properties: chat template, context length, live memory counters. Keeps answering on a server with no chat model loaded. |
Both are opt-in and independent. Start the server with --metrics, --api-key, or neither — the app exposes the same two switches in Settings.
| Endpoint | What it does |
|---|---|
GET/metrics | Prometheus text format. Standard vllm: names (throughput, time-to-first-token, tokens in and out) sit beside Apple-specific mlx_serve: ones (GPU utilization, memory, prefix-cache reuse), so an off-the-shelf vLLM Grafana dashboard works with no configuration. 503 unless --metrics is on. |
GET/metrics.json | The same figures as JSON — what the server's own live index-page panel polls. |
Recording happens once per request, never per token, so the metrics have no measurable effect on tokens/sec.
With --api-key <token> set, every request arriving from another machine must present the key — the OpenAI, Anthropic, and Ollama APIs, the index page, and the endpoints above. Any of these work:
curl http://mac.local:11234/v1/models -H "Authorization: Bearer <key>" curl http://mac.local:11234/v1/models -H "x-api-key: <key>" curl "http://mac.local:11234/v1/models?api_key=<key>"
Requests from the machine itself are trusted and need no key, so the app, your editor, and local scripts are unaffected — the key guards only what is reachable off the box. /health stays open either way. Because localhost is exempt, curl localhost will never show you a 401; test from another machine.
Download MLX Core or brew install mlx-serve, load a model, and every endpoint above is live on localhost.