Image-Text-to-Text
MLX
Safetensors
minimax_m3_vl
oMLX
oQ4
vision-language
conversational
custom_code
4-bit precision
Instructions to use unigilby/MiniMax-M3-oQ4 with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- MLX
How to use unigilby/MiniMax-M3-oQ4 with MLX:
# Make sure mlx-vlm is installed # pip install --upgrade mlx-vlm from mlx_vlm import load, generate from mlx_vlm.prompt_utils import apply_chat_template from mlx_vlm.utils import load_config # Load the model model, processor = load("unigilby/MiniMax-M3-oQ4") config = load_config("unigilby/MiniMax-M3-oQ4") # Prepare input image = ["http://images.cocodataset.org/val2017/000000039769.jpg"] prompt = "Describe this image." # Apply chat template formatted_prompt = apply_chat_template( processor, config, prompt, num_images=1 ) # Generate output output = generate(model, processor, formatted_prompt, image) print(output) - Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- LM Studio
- Pi
How to use unigilby/MiniMax-M3-oQ4 with Pi:
Start the MLX server
# Install MLX LM: uv tool install mlx-lm # Start a local OpenAI-compatible server: mlx_lm.server --model "unigilby/MiniMax-M3-oQ4"
Configure the model in Pi
# Install Pi: npm install -g @earendil-works/pi-coding-agent # Add to ~/.pi/agent/models.json: { "providers": { "mlx-lm": { "baseUrl": "http://localhost:8080/v1", "api": "openai-completions", "apiKey": "none", "models": [ { "id": "unigilby/MiniMax-M3-oQ4" } ] } } }Run Pi
# Start Pi in your project directory: pi
- Hermes Agent
How to use unigilby/MiniMax-M3-oQ4 with Hermes Agent:
Start the MLX server
# Install MLX LM: uv tool install mlx-lm # Start a local OpenAI-compatible server: mlx_lm.server --model "unigilby/MiniMax-M3-oQ4"
Configure Hermes
# Install Hermes: curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash hermes setup # Point Hermes at the local server: hermes config set model.provider custom hermes config set model.base_url http://127.0.0.1:8080/v1 hermes config set model.default unigilby/MiniMax-M3-oQ4
Run Hermes
hermes
- Atomic Chat
- OpenClaw
How to use unigilby/MiniMax-M3-oQ4 with OpenClaw:
Start the MLX server
# Install MLX LM: uv tool install mlx-lm # Start a local OpenAI-compatible server: mlx_lm.server --model "unigilby/MiniMax-M3-oQ4"
Configure OpenClaw
# Install OpenClaw: npm install -g openclaw@latest # Register the local server and set it as the default model: openclaw onboard --non-interactive --mode local \ --auth-choice custom-api-key \ --custom-base-url http://127.0.0.1:8080/v1 \ --custom-model-id "unigilby/MiniMax-M3-oQ4" \ --custom-provider-id mlx-lm \ --custom-compatibility openai \ --custom-text-input \ --accept-risk \ --skip-health
Run OpenClaw
openclaw agent --local --agent main --message "Hello from Hugging Face"
Update patches to match upstream PR jundot/omlx#1881
Browse files- patches/README.md +12 -11
- patches/api_thinking.py.patch +29 -0
- patches/api_tool_calling.py.patch +12 -10
- patches/oq.py.patch +5 -3
- patches/scheduler.py.patch +17 -15
- patches/server.py.patch +13 -11
patches/README.md
CHANGED
|
@@ -1,27 +1,28 @@
|
|
| 1 |
# oMLX patches for serving MiniMax-M3-oQ4
|
| 2 |
|
| 3 |
-
These are the oMLX-side changes needed to **serve** this checkpoint (beyond mlx-vlm PR
|
| 4 |
-
|
| 5 |
-
|
|
|
|
| 6 |
|
| 7 |
-
Apply from the
|
| 8 |
|
| 9 |
```bash
|
| 10 |
-
patch -p0 oq.py
|
| 11 |
-
patch -p0 scheduler.py
|
| 12 |
-
patch -p0 api/
|
| 13 |
patch -p0 api/tool_calling.py < patches/api_tool_calling.py.patch
|
| 14 |
-
patch -p0 server.py
|
| 15 |
```
|
| 16 |
|
| 17 |
| Patch | Purpose | Needed for |
|
| 18 |
|-------|---------|-----------|
|
| 19 |
| `scheduler.py.patch` | Serialize `minimax_m3_vl` (like Llama-4) + MiniMax-Sparse-Attention KV cache (`MiniMaxM3KVCache` ↔ batch variant) | running under oMLX batching / concurrency |
|
| 20 |
-
| `
|
| 21 |
| `api_tool_calling.py.patch` + `server.py.patch` | Parse `<invoke name=…>` + bare `<key>value</key>` params; strip the `]<]minimax[>[` token (200058) from streamed content | structured `tool_calls`, no markup leak |
|
| 22 |
| `oq.py.patch` | `_vlm_sanitize` proxy so oQ can quantize a VLM without instantiating it | **quantizing only** — not needed to serve this checkpoint |
|
| 23 |
|
| 24 |
Notes:
|
| 25 |
-
- The tool-call parser belongs more naturally in mlx-vlm's `tool_parsers` (then selectable via
|
| 26 |
-
`tokenizer_config.json` `tool_parser_type` with no oMLX patch); a separate mlx-vlm PR is the long-term home.
|
| 27 |
- `trust_remote_code: true` and `torch`+`torchvision` in the serving env are also required (see the main README).
|
|
|
|
|
|
|
|
|
| 1 |
# oMLX patches for serving MiniMax-M3-oQ4
|
| 2 |
|
| 3 |
+
These are the oMLX-side changes needed to **serve** this checkpoint (beyond mlx-vlm PR
|
| 4 |
+
[#1374](https://github.com/Blaizzy/mlx-vlm/pull/1374)). They are **submitted upstream as
|
| 5 |
+
[jundot/omlx#1881](https://github.com/jundot/omlx/pull/1881)** — once that merges, no patches are
|
| 6 |
+
needed. Until then, they apply cleanly onto oMLX `main`.
|
| 7 |
|
| 8 |
+
Apply from the `omlx/` package root (the dir containing `scheduler.py`, `server.py`, `oq.py`, `api/`):
|
| 9 |
|
| 10 |
```bash
|
| 11 |
+
patch -p0 oq.py < patches/oq.py.patch
|
| 12 |
+
patch -p0 scheduler.py < patches/scheduler.py.patch
|
| 13 |
+
patch -p0 api/thinking.py < patches/api_thinking.py.patch
|
| 14 |
patch -p0 api/tool_calling.py < patches/api_tool_calling.py.patch
|
| 15 |
+
patch -p0 server.py < patches/server.py.patch
|
| 16 |
```
|
| 17 |
|
| 18 |
| Patch | Purpose | Needed for |
|
| 19 |
|-------|---------|-----------|
|
| 20 |
| `scheduler.py.patch` | Serialize `minimax_m3_vl` (like Llama-4) + MiniMax-Sparse-Attention KV cache (`MiniMaxM3KVCache` ↔ batch variant) | running under oMLX batching / concurrency |
|
| 21 |
+
| `api_thinking.py.patch` | Normalize `<mm:think>`/`</mm:think>` → `<think>`/`</think>` in `extract_thinking` + `ThinkingParser.feed` | clean `reasoning_content` |
|
| 22 |
| `api_tool_calling.py.patch` + `server.py.patch` | Parse `<invoke name=…>` + bare `<key>value</key>` params; strip the `]<]minimax[>[` token (200058) from streamed content | structured `tool_calls`, no markup leak |
|
| 23 |
| `oq.py.patch` | `_vlm_sanitize` proxy so oQ can quantize a VLM without instantiating it | **quantizing only** — not needed to serve this checkpoint |
|
| 24 |
|
| 25 |
Notes:
|
|
|
|
|
|
|
| 26 |
- `trust_remote_code: true` and `torch`+`torchvision` in the serving env are also required (see the main README).
|
| 27 |
+
- The tool-call parser could instead live in mlx-vlm's `tool_parsers` (selectable via `tokenizer_config.json`
|
| 28 |
+
`tool_parser_type`); see the discussion on #1881.
|
patches/api_thinking.py.patch
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
diff --git a/api/thinking.py b/api/thinking.py
|
| 2 |
+
index 024c0df..e245c14 100644
|
| 3 |
+
--- a/api/thinking.py
|
| 4 |
+
+++ b/api/thinking.py
|
| 5 |
+
@@ -168,6 +168,12 @@ def extract_thinking(text: str) -> Tuple[str, str]:
|
| 6 |
+
if not text:
|
| 7 |
+
return ("", "")
|
| 8 |
+
|
| 9 |
+
+ # MiniMax-M3 (minimax_m3_vl) emits reasoning wrapped in <mm:think>...</mm:think>;
|
| 10 |
+
+ # normalize to the standard <think>/</think> so it separates into reasoning_content.
|
| 11 |
+
+ # No-op when absent.
|
| 12 |
+
+ if "<mm:think>" in text or "</mm:think>" in text:
|
| 13 |
+
+ text = text.replace("<mm:think>", "<think>").replace("</mm:think>", "</think>")
|
| 14 |
+
+
|
| 15 |
+
thinking_parts = []
|
| 16 |
+
remaining = text
|
| 17 |
+
|
| 18 |
+
@@ -249,6 +255,11 @@ class ThinkingParser:
|
| 19 |
+
if not text:
|
| 20 |
+
return ("", "")
|
| 21 |
+
|
| 22 |
+
+ # MiniMax-M3 emits <mm:think>...</mm:think> (each marker is a single token,
|
| 23 |
+
+ # so it arrives whole per chunk); normalize to <think>/</think>.
|
| 24 |
+
+ if "<mm:think>" in text or "</mm:think>" in text:
|
| 25 |
+
+ text = text.replace("<mm:think>", "<think>").replace("</mm:think>", "</think>")
|
| 26 |
+
+
|
| 27 |
+
# Prepend any buffered partial tag content
|
| 28 |
+
text = self._buffer + text
|
| 29 |
+
self._buffer = ""
|
patches/api_tool_calling.py.patch
CHANGED
|
@@ -1,11 +1,11 @@
|
|
| 1 |
-
--
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
return results[0] if len(results) == 1 else results
|
| 7 |
-
|
| 8 |
-
|
| 9 |
+def _parse_minimax_m3_tool_calls(text):
|
| 10 |
+ """Parse MiniMax-M3 (minimax_m3_vl) tool calls.
|
| 11 |
+
|
|
@@ -54,10 +54,12 @@
|
|
| 54 |
+ cleaned = re.sub(r"<invoke\s+name=.*?</invoke>", "", cleaned, flags=re.DOTALL)
|
| 55 |
+ cleaned = cleaned.replace(NS, "").strip()
|
| 56 |
+ return cleaned, tool_calls
|
| 57 |
-
|
| 58 |
-
|
| 59 |
def parse_tool_calls(
|
| 60 |
-
|
|
|
|
|
|
|
| 61 |
cleaned_text = cleaned_text[:idx].strip()
|
| 62 |
return cleaned_text, tool_calls
|
| 63 |
|
|
|
|
| 1 |
+
diff --git a/api/tool_calling.py b/api/tool_calling.py
|
| 2 |
+
index b51bcbb..9f3f425 100644
|
| 3 |
+
--- a/api/tool_calling.py
|
| 4 |
+
+++ b/api/tool_calling.py
|
| 5 |
+
@@ -509,6 +509,56 @@ def _parse_gemma4_tool_call_fallback(text: str) -> Union[dict, list]:
|
| 6 |
return results[0] if len(results) == 1 else results
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+def _parse_minimax_m3_tool_calls(text):
|
| 10 |
+ """Parse MiniMax-M3 (minimax_m3_vl) tool calls.
|
| 11 |
+
|
|
|
|
| 54 |
+ cleaned = re.sub(r"<invoke\s+name=.*?</invoke>", "", cleaned, flags=re.DOTALL)
|
| 55 |
+ cleaned = cleaned.replace(NS, "").strip()
|
| 56 |
+ return cleaned, tool_calls
|
| 57 |
+
+
|
| 58 |
+
+
|
| 59 |
def parse_tool_calls(
|
| 60 |
+
text: str,
|
| 61 |
+
tokenizer: Any,
|
| 62 |
+
@@ -666,6 +716,12 @@ def parse_tool_calls(
|
| 63 |
cleaned_text = cleaned_text[:idx].strip()
|
| 64 |
return cleaned_text, tool_calls
|
| 65 |
|
patches/oq.py.patch
CHANGED
|
@@ -1,6 +1,8 @@
|
|
| 1 |
-
--
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
| 4 |
|
| 5 |
proxy = _Proxy()
|
| 6 |
proxy.config = model_config
|
|
|
|
| 1 |
+
diff --git a/oq.py b/oq.py
|
| 2 |
+
index 295d9be..06a8268 100644
|
| 3 |
+
--- a/oq.py
|
| 4 |
+
+++ b/oq.py
|
| 5 |
+
@@ -1993,6 +1993,13 @@ def _build_model_sanitizer(config: dict, text_only: bool = False):
|
| 6 |
|
| 7 |
proxy = _Proxy()
|
| 8 |
proxy.config = model_config
|
patches/scheduler.py.patch
CHANGED
|
@@ -1,8 +1,10 @@
|
|
| 1 |
-
--
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
| 6 |
return cache_obj.merge([cache_obj])
|
| 7 |
+ # MiniMax-M3: convert its single MSA cache to the batch variant
|
| 8 |
+ # (MiniMaxM3BatchKVCache implements filter/extend/extract/merge). Without
|
|
@@ -13,13 +15,10 @@
|
|
| 13 |
return cache_obj
|
| 14 |
|
| 15 |
|
| 16 |
-
@@ -
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
+except ImportError:
|
| 21 |
-
+ pass
|
| 22 |
-
+
|
| 23 |
+# MiniMax-M3 (minimax_m3_vl): its MSA KV cache lacks the batch-aware methods
|
| 24 |
+# (filter/extract/merge/extend) oMLX continuous-batching expects, so serving
|
| 25 |
+# raises "MiniMaxM3KVCache does not yet support batching with history". M3
|
|
@@ -65,10 +64,13 @@
|
|
| 65 |
+ "supported; serialize MiniMax-M3 requests."
|
| 66 |
+ )
|
| 67 |
+ _M3KV.extend = _m3_extend_passthrough
|
| 68 |
-
|
| 69 |
-
|
|
|
|
| 70 |
|
| 71 |
-
|
|
|
|
|
|
|
| 72 |
continue
|
| 73 |
seen.add(obj_id)
|
| 74 |
|
|
|
|
| 1 |
+
diff --git a/scheduler.py b/scheduler.py
|
| 2 |
+
index 2192471..3cb9b12 100644
|
| 3 |
+
--- a/scheduler.py
|
| 4 |
+
+++ b/scheduler.py
|
| 5 |
+
@@ -806,6 +806,12 @@ def _to_batched_cache_layer(cache_obj: Any) -> Any:
|
| 6 |
+
and type(cache_obj) is _TQ_SINGLETON_CACHE_TYPE
|
| 7 |
+
):
|
| 8 |
return cache_obj.merge([cache_obj])
|
| 9 |
+ # MiniMax-M3: convert its single MSA cache to the batch variant
|
| 10 |
+ # (MiniMaxM3BatchKVCache implements filter/extend/extract/merge). Without
|
|
|
|
| 15 |
return cache_obj
|
| 16 |
|
| 17 |
|
| 18 |
+
@@ -987,6 +993,54 @@ try:
|
| 19 |
+
except ImportError:
|
| 20 |
+
pass
|
| 21 |
+
|
|
|
|
|
|
|
|
|
|
| 22 |
+# MiniMax-M3 (minimax_m3_vl): its MSA KV cache lacks the batch-aware methods
|
| 23 |
+# (filter/extract/merge/extend) oMLX continuous-batching expects, so serving
|
| 24 |
+# raises "MiniMaxM3KVCache does not yet support batching with history". M3
|
|
|
|
| 64 |
+ "supported; serialize MiniMax-M3 requests."
|
| 65 |
+ )
|
| 66 |
+ _M3KV.extend = _m3_extend_passthrough
|
| 67 |
+
+except ImportError:
|
| 68 |
+
+ pass
|
| 69 |
+
+
|
| 70 |
|
| 71 |
+
# ---------------------------------------------------------------------------
|
| 72 |
+
# Monkey-patch PromptProcessingBatch.prompt to set mRoPE deltas before the
|
| 73 |
+
@@ -1207,7 +1261,7 @@ def _model_declares_llama4(model: Any) -> bool:
|
| 74 |
continue
|
| 75 |
seen.add(obj_id)
|
| 76 |
|
patches/server.py.patch
CHANGED
|
@@ -1,6 +1,8 @@
|
|
| 1 |
-
--
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
| 4 |
# tools are present so clients see clean streamed text.
|
| 5 |
if content_delta:
|
| 6 |
if tool_filter:
|
|
@@ -9,7 +11,7 @@
|
|
| 9 |
if content_delta:
|
| 10 |
chunk = ChatCompletionChunk(
|
| 11 |
id=response_id,
|
| 12 |
-
@@ -
|
| 13 |
yield f"data: {chunk.model_dump_json(exclude_none=True)}\n\n"
|
| 14 |
if content_delta:
|
| 15 |
if tool_filter:
|
|
@@ -18,7 +20,7 @@
|
|
| 18 |
if content_delta:
|
| 19 |
chunk = ChatCompletionChunk(
|
| 20 |
id=response_id,
|
| 21 |
-
@@ -
|
| 22 |
yield f"data: {chunk.model_dump_json(exclude_none=True)}\n\n"
|
| 23 |
|
| 24 |
if tool_filter:
|
|
@@ -27,7 +29,7 @@
|
|
| 27 |
if remaining:
|
| 28 |
chunk = ChatCompletionChunk(
|
| 29 |
id=response_id,
|
| 30 |
-
@@ -
|
| 31 |
# markup when a known start marker is available.
|
| 32 |
if content_delta:
|
| 33 |
if tool_filter:
|
|
@@ -36,7 +38,7 @@
|
|
| 36 |
if content_delta:
|
| 37 |
# When tools are requested AND we haven't yet opened
|
| 38 |
# a text block, drop pure-whitespace deltas. Models
|
| 39 |
-
@@ -
|
| 40 |
)
|
| 41 |
if content_delta:
|
| 42 |
if tool_filter:
|
|
@@ -45,7 +47,7 @@
|
|
| 45 |
if content_delta:
|
| 46 |
if thinking_block_started and not text_block_started:
|
| 47 |
yield create_content_block_stop_event(index=block_index)
|
| 48 |
-
@@ -
|
| 49 |
|
| 50 |
# Flush any remaining buffered content from the tool-call filter
|
| 51 |
if tool_filter:
|
|
@@ -54,7 +56,7 @@
|
|
| 54 |
if remaining:
|
| 55 |
if not text_block_started:
|
| 56 |
if thinking_block_started:
|
| 57 |
-
@@ -
|
| 58 |
if msg_output_index is None:
|
| 59 |
msg_output_index = next_output_index
|
| 60 |
if tool_filter:
|
|
@@ -63,7 +65,7 @@
|
|
| 63 |
if content_delta:
|
| 64 |
seq += 1
|
| 65 |
yield format_sse_event(
|
| 66 |
-
@@ -
|
| 67 |
accumulated_reasoning += thinking_delta
|
| 68 |
if content_delta:
|
| 69 |
if tool_filter:
|
|
@@ -72,7 +74,7 @@
|
|
| 72 |
if content_delta:
|
| 73 |
seq += 1
|
| 74 |
yield format_sse_event(
|
| 75 |
-
@@ -
|
| 76 |
},
|
| 77 |
)
|
| 78 |
if tool_filter:
|
|
|
|
| 1 |
+
diff --git a/server.py b/server.py
|
| 2 |
+
index f58b000..88705e2 100644
|
| 3 |
+
--- a/server.py
|
| 4 |
+
+++ b/server.py
|
| 5 |
+
@@ -4002,7 +4002,7 @@ async def stream_chat_completion(
|
| 6 |
# tools are present so clients see clean streamed text.
|
| 7 |
if content_delta:
|
| 8 |
if tool_filter:
|
|
|
|
| 11 |
if content_delta:
|
| 12 |
chunk = ChatCompletionChunk(
|
| 13 |
id=response_id,
|
| 14 |
+
@@ -4062,7 +4062,7 @@ async def stream_chat_completion(
|
| 15 |
yield f"data: {chunk.model_dump_json(exclude_none=True)}\n\n"
|
| 16 |
if content_delta:
|
| 17 |
if tool_filter:
|
|
|
|
| 20 |
if content_delta:
|
| 21 |
chunk = ChatCompletionChunk(
|
| 22 |
id=response_id,
|
| 23 |
+
@@ -4077,7 +4077,7 @@ async def stream_chat_completion(
|
| 24 |
yield f"data: {chunk.model_dump_json(exclude_none=True)}\n\n"
|
| 25 |
|
| 26 |
if tool_filter:
|
|
|
|
| 29 |
if remaining:
|
| 30 |
chunk = ChatCompletionChunk(
|
| 31 |
id=response_id,
|
| 32 |
+
@@ -4419,7 +4419,7 @@ async def stream_anthropic_messages(
|
| 33 |
# markup when a known start marker is available.
|
| 34 |
if content_delta:
|
| 35 |
if tool_filter:
|
|
|
|
| 38 |
if content_delta:
|
| 39 |
# When tools are requested AND we haven't yet opened
|
| 40 |
# a text block, drop pure-whitespace deltas. Models
|
| 41 |
+
@@ -4494,7 +4494,7 @@ async def stream_anthropic_messages(
|
| 42 |
)
|
| 43 |
if content_delta:
|
| 44 |
if tool_filter:
|
|
|
|
| 47 |
if content_delta:
|
| 48 |
if thinking_block_started and not text_block_started:
|
| 49 |
yield create_content_block_stop_event(index=block_index)
|
| 50 |
+
@@ -4509,7 +4509,7 @@ async def stream_anthropic_messages(
|
| 51 |
|
| 52 |
# Flush any remaining buffered content from the tool-call filter
|
| 53 |
if tool_filter:
|
|
|
|
| 56 |
if remaining:
|
| 57 |
if not text_block_started:
|
| 58 |
if thinking_block_started:
|
| 59 |
+
@@ -5774,7 +5774,7 @@ async def stream_responses_api(
|
| 60 |
if msg_output_index is None:
|
| 61 |
msg_output_index = next_output_index
|
| 62 |
if tool_filter:
|
|
|
|
| 65 |
if content_delta:
|
| 66 |
seq += 1
|
| 67 |
yield format_sse_event(
|
| 68 |
+
@@ -5819,7 +5819,7 @@ async def stream_responses_api(
|
| 69 |
accumulated_reasoning += thinking_delta
|
| 70 |
if content_delta:
|
| 71 |
if tool_filter:
|
|
|
|
| 74 |
if content_delta:
|
| 75 |
seq += 1
|
| 76 |
yield format_sse_event(
|
| 77 |
+
@@ -5834,7 +5834,7 @@ async def stream_responses_api(
|
| 78 |
},
|
| 79 |
)
|
| 80 |
if tool_filter:
|