e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
414 lines
14 KiB
Python
414 lines
14 KiB
Python
# 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 "<rendered prompt>"
|
|
|
|
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
|