# SPDX-License-Identifier: AGPL-3.0-only import sys import types from types import SimpleNamespace 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) # 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