Skip to content

Commit a7a7bd6

Browse files
localai-botmudler
andauthored
fix(mlx): route vision-language models to the mlx-vlm backend (#10274)
Vision-language checkpoints such as mlx-community/gemma-4-E4B-it-qat-4bit declare the "image-text-to-text" pipeline tag on HuggingFace. The mlx importer hardcoded backend "mlx" for every mlx-community model, so these VLMs were served by the text-only mlx-lm backend whose tokenizer does not carry the processor chat template. The template was never applied and the model produced degenerate, looping output that echoed the prompt. Detect the "image-text-to-text" pipeline tag in the importer and route those models to mlx-vlm, which applies the processor-aware chat template. An explicit backend preference still wins. As a defensive backstop, the mlx backend now warns loudly when the loaded model has no chat template, so a misrouted VLM surfaces the problem instead of silently looping. Fixes #10269 Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
1 parent cec93d2 commit a7a7bd6

3 files changed

Lines changed: 83 additions & 0 deletions

File tree

backend/python/mlx/backend.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,24 @@ def _prepare_prompt(self, request):
407407
if not request.Prompt and request.UseTokenizerTemplate and request.Messages:
408408
messages = messages_to_dicts(request.Messages)
409409

410+
# The mlx-lm tokenizer only carries a text-LM chat template. A
411+
# vision-language checkpoint (e.g. gemma-4 E4B) loaded here has no
412+
# usable template, so apply_chat_template silently passes the raw
413+
# text through and the model just echoes/loops (issue #10269).
414+
# Warn loudly so the misroute is visible; such models belong on the
415+
# mlx-vlm backend.
416+
chat_template = getattr(self.tokenizer, "chat_template", None)
417+
if not chat_template:
418+
underlying = getattr(self.tokenizer, "_tokenizer", None)
419+
chat_template = getattr(underlying, "chat_template", None)
420+
if not chat_template:
421+
print(
422+
"WARNING: this model has no chat template; output may be "
423+
"degenerate. Vision-language models (e.g. gemma-4 E4B) must "
424+
"use the 'mlx-vlm' backend instead of 'mlx'.",
425+
file=sys.stderr,
426+
)
427+
410428
kwargs = {"tokenize": False, "add_generation_prompt": True}
411429
if request.Tools:
412430
try:

core/gallery/importers/mlx.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,17 @@ func (i *MLXImporter) Import(details Details) (gallery.ModelConfig, error) {
6464
description = "Imported from " + details.URI
6565
}
6666

67+
// Vision-language checkpoints (e.g. gemma-4 E4B) declare the
68+
// "image-text-to-text" pipeline tag on HuggingFace. The text-only mlx-lm
69+
// tokenizer does not carry their processor chat template, so routing them
70+
// through the plain mlx backend yields degenerate looping output
71+
// (issue #10269). Send them to the mlx-vlm backend, which applies the
72+
// processor-aware chat template.
6773
backend := "mlx"
74+
if details.HuggingFace != nil && details.HuggingFace.PipelineTag == "image-text-to-text" {
75+
backend = "mlx-vlm"
76+
}
77+
// An explicit backend preference always wins.
6878
b, ok := preferencesMap["backend"].(string)
6979
if ok {
7080
backend = b

core/gallery/importers/mlx_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/json"
55

66
"github.com/mudler/LocalAI/core/gallery/importers"
7+
hfapi "github.com/mudler/LocalAI/pkg/huggingface-api"
78
. "github.com/onsi/ginkgo/v2"
89
. "github.com/onsi/gomega"
910
)
@@ -122,6 +123,60 @@ var _ = Describe("MLXImporter", func() {
122123
Expect(modelConfig.ConfigFile).To(ContainSubstring("backend: mlx-vlm"))
123124
})
124125

126+
It("should auto-route vision-language models to the mlx-vlm backend", func() {
127+
// gemma-4 E4B and similar VLMs declare pipeline_tag
128+
// "image-text-to-text" on HuggingFace. The text-only mlx-lm
129+
// tokenizer does not carry their processor chat template, so
130+
// routing them through the plain mlx backend produces degenerate
131+
// looping output (issue #10269). They must go to mlx-vlm.
132+
details := importers.Details{
133+
URI: "https://huggingface.co/mlx-community/gemma-4-E4B-it-qat-4bit",
134+
HuggingFace: &hfapi.ModelDetails{
135+
ModelID: "mlx-community/gemma-4-E4B-it-qat-4bit",
136+
PipelineTag: "image-text-to-text",
137+
},
138+
}
139+
140+
modelConfig, err := importer.Import(details)
141+
142+
Expect(err).ToNot(HaveOccurred())
143+
Expect(modelConfig.ConfigFile).To(ContainSubstring("backend: mlx-vlm"))
144+
})
145+
146+
It("should keep text-only models on the plain mlx backend", func() {
147+
details := importers.Details{
148+
URI: "https://huggingface.co/mlx-community/Llama-3.2-1B-Instruct-4bit",
149+
HuggingFace: &hfapi.ModelDetails{
150+
ModelID: "mlx-community/Llama-3.2-1B-Instruct-4bit",
151+
PipelineTag: "text-generation",
152+
},
153+
}
154+
155+
modelConfig, err := importer.Import(details)
156+
157+
Expect(err).ToNot(HaveOccurred())
158+
Expect(modelConfig.ConfigFile).To(ContainSubstring("backend: mlx"))
159+
Expect(modelConfig.ConfigFile).ToNot(ContainSubstring("backend: mlx-vlm"))
160+
})
161+
162+
It("should honor an explicit backend preference even for a VLM", func() {
163+
preferences := json.RawMessage(`{"backend": "mlx"}`)
164+
details := importers.Details{
165+
URI: "https://huggingface.co/mlx-community/gemma-4-E4B-it-qat-4bit",
166+
Preferences: preferences,
167+
HuggingFace: &hfapi.ModelDetails{
168+
ModelID: "mlx-community/gemma-4-E4B-it-qat-4bit",
169+
PipelineTag: "image-text-to-text",
170+
},
171+
}
172+
173+
modelConfig, err := importer.Import(details)
174+
175+
Expect(err).ToNot(HaveOccurred())
176+
Expect(modelConfig.ConfigFile).To(ContainSubstring("backend: mlx"))
177+
Expect(modelConfig.ConfigFile).ToNot(ContainSubstring("backend: mlx-vlm"))
178+
})
179+
125180
It("should handle invalid JSON preferences", func() {
126181
preferences := json.RawMessage(`invalid json`)
127182
details := importers.Details{

0 commit comments

Comments
 (0)