# SPDX-License-Identifier: AGPL-3.0-only import sys import types from types import SimpleNamespace import pytest class _DummyMetal: @staticmethod def is_available(): return False class _DummyMX: metal = _DummyMetal() @staticmethod def set_wired_limit(_limit): return None @staticmethod def device_info(): return {"max_recommended_working_set_size": 1024} class _DummyTokenizer: pass class _DummyProcessor: tokenizer = _DummyTokenizer() class _DummyModel: pass def _install_fake_mlx(monkeypatch): mlx_pkg = types.ModuleType("mlx") mlx_core = types.ModuleType("mlx.core") mlx_core.metal = _DummyMetal() mlx_core.set_wired_limit = _DummyMX.set_wired_limit mlx_core.device_info = _DummyMX.device_info mlx_pkg.core = mlx_core monkeypatch.setitem(sys.modules, "mlx", mlx_pkg) monkeypatch.setitem(sys.modules, "mlx.core", mlx_core) def _install_fake_fast_mlx(monkeypatch, calls): class _FastMLXModel: @staticmethod def from_pretrained(*args, **kwargs): calls.append((args, kwargs)) if kwargs["text_only"] is False: return _DummyModel(), _DummyProcessor() return _DummyModel(), _DummyTokenizer() unsloth_zoo_pkg = types.ModuleType("unsloth_zoo") mlx_pkg = types.ModuleType("unsloth_zoo.mlx") mlx_loader = types.ModuleType("unsloth_zoo.mlx.loader") mlx_loader.FastMLXModel = _FastMLXModel unsloth_zoo_pkg.mlx = mlx_pkg mlx_pkg.loader = mlx_loader monkeypatch.setitem(sys.modules, "unsloth_zoo", unsloth_zoo_pkg) monkeypatch.setitem(sys.modules, "unsloth_zoo.mlx", mlx_pkg) monkeypatch.setitem(sys.modules, "unsloth_zoo.mlx.loader", mlx_loader) def test_mlx_inference_text_load_forwards_studio_settings(monkeypatch): _install_fake_mlx(monkeypatch) calls = [] _install_fake_fast_mlx(monkeypatch, calls) from core.inference.mlx_inference import MLXInferenceBackend backend = MLXInferenceBackend() config = SimpleNamespace(identifier = "fake/text", is_vision = False, is_lora = False) assert backend.load_model( config, max_seq_length = 4096, load_in_4bit = False, hf_token = "hf-token", trust_remote_code = True, dtype = "float16", ) assert calls == [ ( ("fake/text",), { "max_seq_length": 4096, "dtype": "float16", "load_in_4bit": False, "token": "hf-token", "trust_remote_code": True, "text_only": True, }, ) ] assert backend._is_vlm is False assert isinstance(backend._tokenizer, _DummyTokenizer) # Non-LoRA text model: no base_model on the record. assert backend.models["fake/text"]["base_model"] is None def test_mlx_text_lora_record_keeps_base_model_for_native_template(monkeypatch): # A LoRA adapter's own tokenizer often ships no chat template; the native tool-calling template # lives on the base model. _install_fake_mlx(monkeypatch) calls = [] _install_fake_fast_mlx(monkeypatch, calls) from core.inference.mlx_inference import MLXInferenceBackend backend = MLXInferenceBackend() config = SimpleNamespace( identifier = "fake/text-adapter", is_vision = False, is_lora = True, base_model = "fake/text-base", ) assert backend.load_model(config, max_seq_length = 4096, hf_token = "hf-token") record = backend.models["fake/text-adapter"] assert record["is_lora"] is True assert record["base_model"] == "fake/text-base" def test_mlx_inference_vlm_lora_uses_unsloth_loader_without_native_adapter_rewrite( monkeypatch, tmp_path ): _install_fake_mlx(monkeypatch) calls = [] _install_fake_fast_mlx(monkeypatch, calls) def _native_vlm_load(*_args, **_kwargs): raise AssertionError("Studio MLX VLM inference must use FastMLXModel") mlx_vlm = types.ModuleType("mlx_vlm") mlx_vlm.load = _native_vlm_load monkeypatch.setitem(sys.modules, "mlx_vlm", mlx_vlm) adapter_dir = tmp_path / "adapter" adapter_dir.mkdir() cfg_path = adapter_dir / "adapter_config.json" original_cfg = '{"base_model_name_or_path": "fake/base", "rank": 8}\n' cfg_path.write_text(original_cfg) from core.inference.mlx_inference import MLXInferenceBackend backend = MLXInferenceBackend() config = SimpleNamespace( identifier = str(adapter_dir), is_vision = True, is_lora = True, base_model = "fake/base", ) assert backend.load_model( config, max_seq_length = 8192, load_in_4bit = True, hf_token = "hf-token", trust_remote_code = True, ) assert calls == [ ( (str(adapter_dir),), { "max_seq_length": 8192, "dtype": None, "load_in_4bit": True, "token": "hf-token", "trust_remote_code": True, "text_only": False, }, ) ] assert cfg_path.read_text() == original_cfg assert backend._is_vlm is True assert isinstance(backend._processor, _DummyProcessor) assert isinstance(backend._tokenizer, _DummyTokenizer) def test_mlx_inference_distributed_vlm_forwards_group_to_fast_mlx(monkeypatch): _install_fake_mlx(monkeypatch) calls = [] _install_fake_fast_mlx(monkeypatch, calls) from core.inference.mlx_inference import MLXInferenceBackend group = SimpleNamespace(size = lambda: 2, rank = lambda: 0) config = SimpleNamespace(identifier = "fake/vlm", is_vision = True, is_lora = False) for mode, group_key in (("tensor", "tensor_group"), ("pipeline", "pipeline_group")): calls.clear() assert MLXInferenceBackend().load_model(config, parallel_mode = mode, distributed_group = group) _, kwargs = calls.pop() assert kwargs["text_only"] is False and kwargs[group_key] is group calls.clear() singleton = SimpleNamespace(size = lambda: 1, rank = lambda: 0) assert MLXInferenceBackend().load_model( config, parallel_mode = "tensor", distributed_group = singleton ) assert not {"tensor_group", "pipeline_group"} & set(calls.pop()[1]) config = SimpleNamespace(identifier = "fake/adapter", is_vision = False, is_lora = True) with pytest.raises(ValueError, match = "LoRA adapter repos"): MLXInferenceBackend().load_model(config, parallel_mode = "tensor", distributed_group = group) @pytest.mark.parametrize("accepts_backend", (True, False)) def test_mlx_distributed_init_selects_jaccl_backend(monkeypatch, accepts_backend): _install_fake_mlx(monkeypatch) from core.inference.mlx_inference import _init_mlx_distributed group = SimpleNamespace(rank = lambda: 1, size = lambda: 2) calls = [] def _init(**kwargs): calls.append(kwargs) if kwargs and not accepts_backend: raise TypeError("backend keyword unsupported") return group sys.modules["mlx.core"].distributed = SimpleNamespace(init = _init) monkeypatch.setenv("MLX_JACCL_COORDINATOR", "127.0.0.1:12345") monkeypatch.setenv("MLX_IBV_DEVICES", "/tmp/devices.json") assert _init_mlx_distributed() == (group, 1, 2) assert calls == ([{"backend": "jaccl"}] if accepts_backend else [{"backend": "jaccl"}, {}]) def test_worker_share_object_receives_distributed_payload(monkeypatch): from core.inference import worker shared_obj = {"type": "turn", "text": "hi"} payload = worker._encode_share_object(shared_obj) def _array(value): val = value.item() if hasattr(value, "item") else value return SimpleNamespace( item = lambda: val, tolist = lambda: list(val) if hasattr(val, "__iter__") else [val], ) mlx_pkg = types.ModuleType("mlx") mlx_core = types.ModuleType("mlx.core") mlx_core.uint8 = "uint8" mlx_core.array = _array mlx_core.zeros = lambda *_a, **_k: _array([]) def _all_sum(value, group = None): value = value.item() if hasattr(value, "item") else value return _array(len(payload)) if value == 0 else _array(payload) mlx_core.distributed = SimpleNamespace(all_sum = _all_sum) mlx_pkg.core = mlx_core monkeypatch.setitem(sys.modules, "mlx", mlx_pkg) monkeypatch.setitem(sys.modules, "mlx.core", mlx_core) responses = [] worker._handle_share_object( SimpleNamespace( _distributed_group = object(), _distributed_rank = 1, _distributed_world_size = 2, ), {"type": "share_object", "request_id": "rid", "object": None}, SimpleNamespace(put = responses.append), ) response = responses[0] assert response["object"] == shared_obj def test_worker_share_object_oversize_notifies_peers(monkeypatch): from core.inference import worker calls = [] mlx_pkg = types.ModuleType("mlx") mlx_core = types.ModuleType("mlx.core") mlx_core.array = lambda value, **_kwargs: SimpleNamespace(item = lambda: value) mlx_core.eval = lambda value: value mlx_core.distributed = SimpleNamespace( all_sum = lambda value, group = None: calls.append(value.item()) or value ) mlx_pkg.core = mlx_core monkeypatch.setitem(sys.modules, "mlx", mlx_pkg) monkeypatch.setitem(sys.modules, "mlx.core", mlx_core) monkeypatch.setattr(worker, "_SHARE_OBJECT_MAX_BYTES", 8) responses = [] worker._handle_share_object( SimpleNamespace( _distributed_group = object(), _distributed_rank = 0, _distributed_world_size = 2, ), {"type": "share_object", "request_id": "rid", "object": {"text": "too long"}}, SimpleNamespace(put = responses.append), ) assert calls == [worker._SHARE_OBJECT_ERROR_SIZE] assert responses[0]["type"] == "share_error" # Regression: generate_chat_response must accept the four template kwargs # (tools / enable_thinking / reasoning_effort / preserve_thinking) so the route # layer can forward UI toggles. The old signature raised # "got an unexpected keyword argument 'tools'" on Mac. def test_mlx_generate_chat_response_accepts_template_kwargs(): import inspect from core.inference.mlx_inference import MLXInferenceBackend sig = inspect.signature(MLXInferenceBackend.generate_chat_response) params = sig.parameters for name in ("tools", "enable_thinking", "reasoning_effort", "preserve_thinking"): assert name in params, ( f"MLX.generate_chat_response is missing the {name!r} kwarg; " "the route layer forwards this and a missing kwarg raises " "TypeError on Mac" ) assert ( params[name].default is None ), f"{name!r} must default to None so existing callers stay valid" def test_mlx_generate_text_forwards_kwargs_into_template_helper(monkeypatch): """Mac text path must route through apply_chat_template_for_generation so reasoning / tool kwargs reach the tokenizer.""" _install_fake_mlx(monkeypatch) from core.inference.mlx_inference import MLXInferenceBackend # The text path renders once with tools, then the native-template fallback makes a second no- # tools probe call (tools=None) to detect whether the template dropped the schema. captured_calls = [] def _fake_apply(tokenizer, messages, **kwargs): captured_calls.append({"tokenizer": tokenizer, "messages": messages, "kwargs": kwargs}) return "" monkeypatch.setattr( "core.inference.chat_template_helpers.apply_chat_template_for_generation", _fake_apply, raising = True, ) # mlx_lm.stream_generate yields response objects with .token; use a # one-token generator so _generate_text returns without the real stack. import types as _types mlx_lm_pkg = _types.ModuleType("mlx_lm") mlx_lm_sample = _types.ModuleType("mlx_lm.sample_utils") mlx_lm_sample.make_sampler = lambda **_kw: object() mlx_lm_sample.make_logits_processors = lambda **_kw: None class _Resp: def __init__(self, tok): self.token = tok def _stream_generate(_model, _tokenizer, **_kw): yield _Resp(1) mlx_lm_pkg.stream_generate = _stream_generate monkeypatch.setitem(sys.modules, "mlx_lm", mlx_lm_pkg) monkeypatch.setitem(sys.modules, "mlx_lm.sample_utils", mlx_lm_sample) class _Tok: chat_template = "x" def decode( self, ids, skip_special_tokens = False, ): return "hi" backend = MLXInferenceBackend() backend._model = object() backend._tokenizer = _Tok() backend._is_vlm = False out = list( backend.generate_chat_response( messages = [{"role": "user", "content": "ping"}], tools = [{"function": {"name": "web_search"}}], enable_thinking = True, reasoning_effort = "medium", preserve_thinking = True, max_new_tokens = 1, ) ) assert out == ["hi"] # The toggled kwargs must reach the chat-template helper on the real render # (one of the calls carries the tools; the fallback probe passes tools=None). tool_renders = [ c for c in captured_calls if c["kwargs"].get("tools") == [{"function": {"name": "web_search"}}] ] assert tool_renders, captured_calls render = tool_renders[0] assert render["kwargs"]["enable_thinking"] is True assert render["kwargs"]["reasoning_effort"] == "medium" assert render["kwargs"]["preserve_thinking"] is True