Skip to content

Commit 69c7a8e

Browse files
committed
fix(mlx): strip file:// LocalPrefix before loading filesystem-imported models
MLX backends passed request.Model verbatim to mlx_lm/mlx_vlm load(). For a model imported from the filesystem, LocalAI hands the backend a file:// URI (its LocalPrefix), which load() rejects: the scheme is neither a valid HF repo id nor an existing path (Path(model).exists() fails on the scheme), producing "Repo id must be in the form 'repo_name' or 'namespace/repo_name' ... Use repo_type argument if needed". Add a pure, unit-testable resolve_model_path(model, model_file) helper in the shared python_utils: it prefers the resolved ModelFile, strips a file:// scheme and percent-decodes the path, and leaves plain repo ids and local paths untouched. Wire it into the mlx, mlx-vlm and mlx-distributed backends (load, model_key, and the distributed broadcast all use the normalized path). Fixes #7461. Assisted-by: claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 51f4f67 commit 69c7a8e

5 files changed

Lines changed: 89 additions & 16 deletions

File tree

backend/python/common/python_utils.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,31 @@
55
chat-template-compatible message list from proto Message objects.
66
"""
77
import json
8+
from urllib.parse import unquote
9+
10+
11+
def resolve_model_path(model, model_file=""):
12+
"""Resolve a LocalAI model reference to something an HF/MLX loader accepts.
13+
14+
LocalAI hands backends either a plain HuggingFace repo id
15+
(``namespace/name``), an already-local filesystem path, or a
16+
``file://`` URI (its ``LocalPrefix``) for models imported from disk.
17+
Loaders such as ``mlx_lm.load`` reject the ``file://`` form because the
18+
scheme is neither a valid repo id nor an existing path, so we normalize
19+
it here before loading.
20+
21+
Resolution order:
22+
1. Prefer ``model_file`` when set and non-empty - that is the resolved
23+
local path LocalAI computed for the model.
24+
2. Strip a ``file://`` scheme and percent-decode it to a plain path.
25+
3. Leave plain repo ids and already-local paths unchanged.
26+
"""
27+
candidate = model_file if model_file else model
28+
if candidate is None:
29+
return candidate
30+
if candidate.startswith("file://"):
31+
return unquote(candidate[len("file://"):])
32+
return candidate
833

934

1035
def parse_options(options_list):

backend/python/mlx-distributed/backend.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'common'))
2929
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'common'))
3030
from grpc_auth import get_auth_interceptors
31-
from python_utils import messages_to_dicts, parse_options as _shared_parse_options
31+
from python_utils import messages_to_dicts, parse_options as _shared_parse_options, resolve_model_path
3232
from mlx_utils import parse_tool_calls, split_reasoning
3333

3434

@@ -99,7 +99,11 @@ async def LoadModel(self, request, context):
9999
from mlx_lm import load
100100
from mlx_lm.models.cache import make_prompt_cache, can_trim_prompt_cache, trim_prompt_cache
101101

102-
print(f"[Rank 0] Loading model: {request.Model}", file=sys.stderr)
102+
# Normalize the model reference: strip LocalAI's file:// LocalPrefix
103+
# and prefer the resolved ModelFile so mlx_lm.load() gets a plain
104+
# repo id or filesystem path (it rejects file:// URIs).
105+
model_path = resolve_model_path(request.Model, request.ModelFile)
106+
print(f"[Rank 0] Loading model: {model_path}", file=sys.stderr)
103107

104108
self.options = parse_options(request.Options)
105109
print(f"Options: {self.options}", file=sys.stderr)
@@ -128,7 +132,7 @@ async def LoadModel(self, request, context):
128132
)
129133
self.coordinator = DistributedCoordinator(self.group)
130134
self.coordinator.broadcast_command(CMD_LOAD_MODEL)
131-
self.coordinator.broadcast_model_name(request.Model)
135+
self.coordinator.broadcast_model_name(model_path)
132136
else:
133137
print("[Rank 0] No hostfile configured, running single-node", file=sys.stderr)
134138

@@ -144,9 +148,9 @@ async def LoadModel(self, request, context):
144148

145149
if tokenizer_config:
146150
print(f"Loading with tokenizer_config: {tokenizer_config}", file=sys.stderr)
147-
self.model, self.tokenizer = load(request.Model, tokenizer_config=tokenizer_config)
151+
self.model, self.tokenizer = load(model_path, tokenizer_config=tokenizer_config)
148152
else:
149-
self.model, self.tokenizer = load(request.Model)
153+
self.model, self.tokenizer = load(model_path)
150154

151155
if self.group is not None:
152156
from sharding import pipeline_auto_parallel
@@ -157,7 +161,7 @@ async def LoadModel(self, request, context):
157161
from mlx_cache import ThreadSafeLRUPromptCache
158162
max_cache_entries = self.options.get("max_cache_entries", 10)
159163
self.max_kv_size = self.options.get("max_kv_size", None)
160-
self.model_key = request.Model
164+
self.model_key = model_path
161165
self.lru_cache = ThreadSafeLRUPromptCache(
162166
max_size=max_cache_entries,
163167
can_trim_fn=can_trim_prompt_cache,

backend/python/mlx-vlm/backend.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'common'))
1919
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'common'))
2020
from grpc_auth import get_auth_interceptors
21-
from python_utils import messages_to_dicts, parse_options
21+
from python_utils import messages_to_dicts, parse_options, resolve_model_path
2222
from mlx_utils import parse_tool_calls, split_reasoning
2323

2424
from mlx_vlm import load, stream_generate
@@ -67,7 +67,11 @@ async def LoadModel(self, request, context):
6767
backend_pb2.Result: The load model result.
6868
"""
6969
try:
70-
print(f"Loading MLX-VLM model: {request.Model}", file=sys.stderr)
70+
# Normalize the model reference: strip LocalAI's file:// LocalPrefix
71+
# and prefer the resolved ModelFile so mlx_vlm.load() gets a plain
72+
# repo id or filesystem path (it rejects file:// URIs).
73+
model_path = resolve_model_path(request.Model, request.ModelFile)
74+
print(f"Loading MLX-VLM model: {model_path}", file=sys.stderr)
7175
print(f"Request: {request}", file=sys.stderr)
7276

7377
# Parse Options[] key:value strings into a typed dict
@@ -76,10 +80,10 @@ async def LoadModel(self, request, context):
7680

7781
# Load model and processor using MLX-VLM
7882
# mlx-vlm load function returns (model, processor) instead of (model, tokenizer)
79-
self.model, self.processor = load(request.Model)
83+
self.model, self.processor = load(model_path)
8084

8185
# Load model config for chat template support
82-
self.config = load_config(request.Model)
86+
self.config = load_config(model_path)
8387

8488
# Auto-infer the tool parser from the chat template. mlx-vlm has
8589
# its own _infer_tool_parser that falls back to mlx-lm parsers.

backend/python/mlx/backend.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'common'))
1818
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'common'))
1919
from grpc_auth import get_auth_interceptors
20-
from python_utils import messages_to_dicts, parse_options
20+
from python_utils import messages_to_dicts, parse_options, resolve_model_path
2121
from mlx_utils import parse_tool_calls, split_reasoning
2222

2323
from mlx_lm import load, stream_generate
@@ -63,7 +63,11 @@ async def LoadModel(self, request, context):
6363
backend_pb2.Result: The load model result.
6464
"""
6565
try:
66-
print(f"Loading MLX model: {request.Model}", file=sys.stderr)
66+
# Normalize the model reference: strip LocalAI's file:// LocalPrefix
67+
# and prefer the resolved ModelFile so mlx_lm.load() gets a plain
68+
# repo id or filesystem path (it rejects file:// URIs).
69+
model_path = resolve_model_path(request.Model, request.ModelFile)
70+
print(f"Loading MLX model: {model_path}", file=sys.stderr)
6771
print(f"Request: {request}", file=sys.stderr)
6872

6973
# Parse Options[] key:value strings into a typed dict (shared helper)
@@ -89,9 +93,9 @@ async def LoadModel(self, request, context):
8993
# Load model and tokenizer using MLX
9094
if tokenizer_config:
9195
print(f"Loading with tokenizer_config: {tokenizer_config}", file=sys.stderr)
92-
self.model, self.tokenizer = load(request.Model, tokenizer_config=tokenizer_config)
96+
self.model, self.tokenizer = load(model_path, tokenizer_config=tokenizer_config)
9397
else:
94-
self.model, self.tokenizer = load(request.Model)
98+
self.model, self.tokenizer = load(model_path)
9599

96100
# mlx_lm.load() returns a TokenizerWrapper that detects tool
97101
# calling and thinking markers from the chat template / vocab.
@@ -111,7 +115,7 @@ async def LoadModel(self, request, context):
111115
# Initialize thread-safe LRU prompt cache for efficient generation
112116
max_cache_entries = self.options.get("max_cache_entries", 10)
113117
self.max_kv_size = self.options.get("max_kv_size", None)
114-
self.model_key = request.Model
118+
self.model_key = model_path
115119
self.lru_cache = ThreadSafeLRUPromptCache(
116120
max_size=max_cache_entries,
117121
can_trim_fn=can_trim_prompt_cache,

backend/python/mlx/test.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
# Make the shared helpers importable so we can unit-test them without a
1313
# running gRPC server.
1414
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'common'))
15-
from python_utils import messages_to_dicts, parse_options
15+
from python_utils import messages_to_dicts, parse_options, resolve_model_path
1616
from mlx_utils import parse_tool_calls, split_reasoning
1717

1818
class TestBackendServicer(unittest.TestCase):
@@ -322,6 +322,42 @@ def test_split_reasoning_no_marker(self):
322322
self.assertEqual(r, "")
323323
self.assertEqual(c, "just text")
324324

325+
def test_resolve_model_path_file_uri(self):
326+
# file:// LocalPrefix (LocalAI import) is stripped to a plain path.
327+
self.assertEqual(resolve_model_path("file:///a/b"), "/a/b")
328+
329+
def test_resolve_model_path_file_uri_percent_decoded(self):
330+
# Percent-encoded characters (e.g. spaces) are decoded.
331+
self.assertEqual(
332+
resolve_model_path("file:///Users/me/My%20Models/Qwen3"),
333+
"/Users/me/My Models/Qwen3",
334+
)
335+
336+
def test_resolve_model_path_hf_repo_id_unchanged(self):
337+
# Plain HuggingFace repo ids must pass through untouched.
338+
self.assertEqual(
339+
resolve_model_path("mlx-community/Qwen3-Coder-30B"),
340+
"mlx-community/Qwen3-Coder-30B",
341+
)
342+
343+
def test_resolve_model_path_local_path_unchanged(self):
344+
# An already-local absolute path is left as-is.
345+
self.assertEqual(resolve_model_path("/models/Qwen3"), "/models/Qwen3")
346+
347+
def test_resolve_model_path_prefers_model_file(self):
348+
# The resolved ModelFile wins over Model when both are set.
349+
self.assertEqual(
350+
resolve_model_path("file:///ignored", "/resolved/local/path"),
351+
"/resolved/local/path",
352+
)
353+
354+
def test_resolve_model_path_model_file_file_uri(self):
355+
# A ModelFile that is itself a file:// URI is also normalized.
356+
self.assertEqual(
357+
resolve_model_path("ignored", "file:///a/b"),
358+
"/a/b",
359+
)
360+
325361
def test_parse_tool_calls_with_shim(self):
326362
tm = types.SimpleNamespace(
327363
tool_call_start="<tool_call>",

0 commit comments

Comments
 (0)