From cb274484a6835daa64b00c84dadf45f82f4ffd61 Mon Sep 17 00:00:00 2001
From: Avaya Aggarwal <119044997+OnePunchMonk@users.noreply.github.com>
Date: Sat, 27 Jun 2026 00:00:10 +0530
Subject: [PATCH 01/31] Add GGUF --tensor-parallel CLI option (#6561)
---------
Co-authored-by: oobabooga <112222186+oobabooga@users.noreply.github.com>
---
studio/backend/models/inference.py | 3 +-
unsloth_cli/_inference.py | 108 +++++--
unsloth_cli/commands/chat.py | 26 +-
unsloth_cli/commands/inference.py | 26 +-
unsloth_cli/tests/test_inference_chat.py | 355 +++++++++++++++++++++++
5 files changed, 494 insertions(+), 24 deletions(-)
diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py
index 26825a472e..4a3162b09e 100644
--- a/studio/backend/models/inference.py
+++ b/studio/backend/models/inference.py
@@ -106,8 +106,7 @@ class LoadRequest(BaseModel):
"Extra arguments forwarded verbatim to llama-server for GGUF models. "
"One token per list entry, e.g. ['--top-k', '20', '--seed', '42']. "
"Studio-managed flags (model identity, port, context length, GPU placement, "
- "auth, --flash-attn, --no-context-shift, --jinja) are rejected. Ignored for "
- "non-GGUF models."
+ "auth, UI/server mode) are rejected. Ignored for non-GGUF models."
),
)
diff --git a/unsloth_cli/_inference.py b/unsloth_cli/_inference.py
index 0baeb2ffe5..e8a3414d73 100644
--- a/unsloth_cli/_inference.py
+++ b/unsloth_cli/_inference.py
@@ -3,11 +3,12 @@
"""Model loading and streaming shared by `inference` and `chat`."""
+import asyncio
import os
import re
import sys
from pathlib import Path
-from typing import Optional
+from typing import List, Optional
import typer
@@ -211,28 +212,65 @@ def resolve_model_config(model: str, *, hf_token: Optional[str]):
return model_config
-def _load_gguf_backend(model_config, *, hf_token, max_seq_length):
+def _validate_llama_extra_args_or_exit(llama_extra_args: Optional[List[str]]) -> list[str]:
+ from core.inference.llama_server_args import validate_extra_args
+ try:
+ return validate_extra_args(llama_extra_args)
+ except ValueError as exc:
+ typer.echo(f"Error: {exc}", err = True)
+ raise typer.Exit(code = 1)
+
+
+def _load_gguf_backend(
+ model_config,
+ *,
+ hf_token,
+ max_seq_length,
+ tensor_parallel: bool = False,
+ llama_extra_args: Optional[List[str]] = None,
+):
ensure_studio_backend_path()
from core.inference.llama_cpp import LlamaCppBackend
+ from core.inference.tensor_fallback import load_with_tensor_fallback
llama_backend = LlamaCppBackend()
+ extra_args = _validate_llama_extra_args_or_exit(llama_extra_args)
common = dict(
hf_variant = model_config.gguf_variant,
model_identifier = model_config.identifier,
is_vision = model_config.is_vision,
n_ctx = max_seq_length,
)
- if model_config.gguf_hf_repo:
- loaded = llama_backend.load_model(
- hf_repo = model_config.gguf_hf_repo, hf_token = hf_token, **common
+
+ async def _attempt_gguf_load(
+ requested_tensor_parallel: bool, attempt_extra_args: Optional[List[str]]
+ ) -> bool:
+ attempt_common = dict(
+ common,
+ tensor_parallel = requested_tensor_parallel,
+ extra_args = attempt_extra_args,
)
- else:
- loaded = llama_backend.load_model(
+ if model_config.gguf_hf_repo:
+ return llama_backend.load_model(
+ hf_repo = model_config.gguf_hf_repo,
+ hf_token = hf_token,
+ **attempt_common,
+ )
+ return llama_backend.load_model(
gguf_path = model_config.gguf_file,
mmproj_path = model_config.gguf_mmproj_file,
mtp_draft_path = model_config.gguf_mtp_file,
- **common,
+ **attempt_common,
)
+
+ loaded = asyncio.run(
+ load_with_tensor_fallback(
+ _attempt_gguf_load,
+ requested_tensor = tensor_parallel,
+ extra_args = extra_args,
+ label = model_config.identifier,
+ )
+ )
if not loaded:
typer.echo("Model load failed", err = True)
raise typer.Exit(code = 1)
@@ -245,6 +283,8 @@ def load_chat_backend(
hf_token: Optional[str],
max_seq_length: int,
load_in_4bit: bool,
+ tensor_parallel: bool = False,
+ llama_extra_args: Optional[List[str]] = None,
model_config = None,
fresh_backend: bool = False,
):
@@ -259,7 +299,13 @@ def load_chat_backend(
typer.echo(f"Loading {model}", err = True)
if model_config.is_gguf:
- return _load_gguf_backend(model_config, hf_token = hf_token, max_seq_length = max_seq_length)
+ return _load_gguf_backend(
+ model_config,
+ hf_token = hf_token,
+ max_seq_length = max_seq_length,
+ tensor_parallel = tensor_parallel,
+ llama_extra_args = llama_extra_args,
+ )
if fresh_backend:
ensure_studio_backend_path()
@@ -447,18 +493,31 @@ class HttpChatBackend:
# No redirects: this carries a bearer token (see urlopen_no_redirect).
return urlopen_no_redirect(request, timeout = timeout)
- def ensure_loaded(self, model: str, *, hf_token, max_seq_length, load_in_4bit) -> None:
+ def ensure_loaded(
+ self,
+ model: str,
+ *,
+ hf_token,
+ max_seq_length,
+ load_in_4bit,
+ tensor_parallel: bool = False,
+ llama_extra_args: Optional[List[str]] = None,
+ ) -> None:
typer.echo(f"Loading {model} on the Studio server", err = True)
+ payload = {
+ "model_path": model,
+ "hf_token": hf_token,
+ "max_seq_length": max_seq_length,
+ "load_in_4bit": load_in_4bit,
+ "tensor_parallel": tensor_parallel,
+ }
+ if llama_extra_args:
+ payload["llama_extra_args"] = llama_extra_args
try:
self._request(
"POST",
"/api/inference/load",
- {
- "model_path": model,
- "hf_token": hf_token,
- "max_seq_length": max_seq_length,
- "load_in_4bit": load_in_4bit,
- },
+ payload,
).close()
except Exception as exc:
typer.echo(f"Model load failed: {exc}", err = True)
@@ -538,7 +597,15 @@ class HttpChatBackend:
pass
-def connect_studio_server(model: str, *, hf_token, max_seq_length, load_in_4bit):
+def connect_studio_server(
+ model: str,
+ *,
+ hf_token,
+ max_seq_length,
+ load_in_4bit,
+ tensor_parallel: bool = False,
+ llama_extra_args: Optional[List[str]] = None,
+):
"""Backend on a running Studio server, or None (caller loads locally)."""
base_url = find_studio_server()
if not base_url:
@@ -576,6 +643,11 @@ def connect_studio_server(model: str, *, hf_token, max_seq_length, load_in_4bit)
return _refuse("couldn't self-issue a Studio token (is Studio set up here?).")
backend = HttpChatBackend(base_url, token)
backend.ensure_loaded(
- model, hf_token = hf_token, max_seq_length = max_seq_length, load_in_4bit = load_in_4bit
+ model,
+ hf_token = hf_token,
+ max_seq_length = max_seq_length,
+ load_in_4bit = load_in_4bit,
+ tensor_parallel = tensor_parallel,
+ llama_extra_args = llama_extra_args,
)
return backend
diff --git a/unsloth_cli/commands/chat.py b/unsloth_cli/commands/chat.py
index c62916bc75..a483aeeb6c 100644
--- a/unsloth_cli/commands/chat.py
+++ b/unsloth_cli/commands/chat.py
@@ -1,7 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
-from typing import Optional
+from typing import List, Optional
import typer
from rich.console import Console
@@ -153,6 +153,22 @@ def chat(
),
max_seq_length: int = typer.Option(4096, "--max-seq-length"),
load_in_4bit: bool = typer.Option(True, "--load-in-4bit/--no-load-in-4bit"),
+ tensor_parallel: bool = typer.Option(
+ False,
+ "--tensor-parallel/--no-tensor-parallel",
+ help = (
+ "Split a GGUF across GPUs by tensor (--split-mode tensor) instead "
+ "of by layer. Ignored for non-GGUF models."
+ ),
+ ),
+ llama_extra_args: Optional[List[str]] = typer.Option(
+ None,
+ "--llama-extra-arg",
+ help = (
+ "Extra llama-server arg for GGUF models. Repeat for multiple "
+ "tokens, e.g. --llama-extra-arg=--top-k --llama-extra-arg 20."
+ ),
+ ),
think: bool = typer.Option(
False,
"--think/--no-think",
@@ -190,7 +206,13 @@ def chat(
err.print(f"--compare unavailable: {compare_blocked}", style = "red", markup = False)
raise typer.Exit(code = 1)
- load_opts = dict(hf_token = hf_token, max_seq_length = max_seq_length, load_in_4bit = load_in_4bit)
+ load_opts = dict(
+ hf_token = hf_token,
+ max_seq_length = max_seq_length,
+ load_in_4bit = load_in_4bit,
+ tensor_parallel = tensor_parallel,
+ llama_extra_args = llama_extra_args,
+ )
# Prefer a running Studio server: instant starts, model shared with the UI.
chat_backend = None if no_server else connect_studio_server(model, **load_opts)
diff --git a/unsloth_cli/commands/inference.py b/unsloth_cli/commands/inference.py
index 5dbc32c7d2..1401fa9e9e 100644
--- a/unsloth_cli/commands/inference.py
+++ b/unsloth_cli/commands/inference.py
@@ -1,7 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
-from typing import Optional
+from typing import List, Optional
import typer
@@ -31,6 +31,22 @@ def inference(
),
max_seq_length: int = typer.Option(2048, "--max-seq-length"),
load_in_4bit: bool = typer.Option(True, "--load-in-4bit/--no-load-in-4bit"),
+ tensor_parallel: bool = typer.Option(
+ False,
+ "--tensor-parallel/--no-tensor-parallel",
+ help = (
+ "Split a GGUF across GPUs by tensor (--split-mode tensor) instead "
+ "of by layer. Ignored for non-GGUF models."
+ ),
+ ),
+ llama_extra_args: Optional[List[str]] = typer.Option(
+ None,
+ "--llama-extra-arg",
+ help = (
+ "Extra llama-server arg for GGUF models. Repeat for multiple "
+ "tokens, e.g. --llama-extra-arg=--top-k --llama-extra-arg 20."
+ ),
+ ),
think: bool = typer.Option(
False,
"--think/--no-think",
@@ -55,7 +71,13 @@ def inference(
# A running Studio server keeps the model warm between runs, which is
# exactly what a one-shot command wants.
- load_opts = dict(hf_token = hf_token, max_seq_length = max_seq_length, load_in_4bit = load_in_4bit)
+ load_opts = dict(
+ hf_token = hf_token,
+ max_seq_length = max_seq_length,
+ load_in_4bit = load_in_4bit,
+ tensor_parallel = tensor_parallel,
+ llama_extra_args = llama_extra_args,
+ )
chat_backend = None if no_server else connect_studio_server(model, **load_opts)
if chat_backend is None:
chat_backend = load_chat_backend(model, **load_opts)
diff --git a/unsloth_cli/tests/test_inference_chat.py b/unsloth_cli/tests/test_inference_chat.py
index 770bd0db29..013b7c5a81 100644
--- a/unsloth_cli/tests/test_inference_chat.py
+++ b/unsloth_cli/tests/test_inference_chat.py
@@ -9,6 +9,7 @@ import inspect
import sys
import types
from pathlib import Path
+from types import SimpleNamespace
_REPO_ROOT = Path(__file__).resolve().parents[2]
if str(_REPO_ROOT) not in sys.path:
@@ -16,6 +17,7 @@ if str(_REPO_ROOT) not in sys.path:
import typer
+import pytest
from rich.console import Console
from typer.testing import CliRunner
@@ -43,6 +45,14 @@ def _chat_app():
return cli
+def _inference_app():
+ from unsloth_cli.commands.inference import inference
+
+ cli = typer.Typer()
+ cli.command()(inference)
+ return cli
+
+
def test_visible_text_passthrough_when_shown():
text = "reasoninganswer"
assert visible_text(text, show_thinking = True) == text
@@ -81,6 +91,16 @@ def test_inference_think_defaults_off():
assert "--think/--no-think" in (getattr(opt, "param_decls", None) or [])
+def test_inference_exposes_gguf_runtime_options():
+ from unsloth_cli.commands.inference import inference
+
+ tensor = _option(inference, "tensor_parallel")
+ assert "--tensor-parallel/--no-tensor-parallel" in (getattr(tensor, "param_decls", None) or [])
+
+ extra = _option(inference, "llama_extra_args")
+ assert "--llama-extra-arg" in (getattr(extra, "param_decls", None) or [])
+
+
def test_chat_command_is_registered_with_options():
params = inspect.signature(chatmod.chat).parameters
assert "model" in params
@@ -94,6 +114,12 @@ def test_chat_command_is_registered_with_options():
verbose = _option(chatmod.chat, "verbose")
assert {"--verbose", "-v"} <= set(getattr(verbose, "param_decls", None) or [])
+ tensor = _option(chatmod.chat, "tensor_parallel")
+ assert "--tensor-parallel/--no-tensor-parallel" in (getattr(tensor, "param_decls", None) or [])
+
+ extra = _option(chatmod.chat, "llama_extra_args")
+ assert "--llama-extra-arg" in (getattr(extra, "param_decls", None) or [])
+
class _FakeBackend:
def __init__(self):
@@ -324,6 +350,243 @@ def test_http_backend_streams_cumulative_text(monkeypatch):
assert out == ["He", "Hello"]
+def test_http_backend_load_forwards_gguf_runtime_options(monkeypatch):
+ backend = HttpChatBackend("http://localhost:8888", "token")
+ requests = []
+
+ class _OK:
+ def close(self):
+ pass
+
+ def fake_request(
+ method,
+ path,
+ payload = None,
+ timeout = None,
+ ):
+ requests.append((method, path, payload, timeout))
+ return _OK()
+
+ monkeypatch.setattr(backend, "_request", fake_request)
+
+ backend.ensure_loaded(
+ "org/model-GGUF",
+ hf_token = "hf_x",
+ max_seq_length = 8192,
+ load_in_4bit = False,
+ tensor_parallel = True,
+ llama_extra_args = ["--top-k", "20"],
+ )
+
+ assert requests == [
+ (
+ "POST",
+ "/api/inference/load",
+ {
+ "model_path": "org/model-GGUF",
+ "hf_token": "hf_x",
+ "max_seq_length": 8192,
+ "load_in_4bit": False,
+ "tensor_parallel": True,
+ "llama_extra_args": ["--top-k", "20"],
+ },
+ None,
+ )
+ ]
+
+
+def test_http_backend_load_sends_explicit_false_tensor_parallel(monkeypatch):
+ backend = HttpChatBackend("http://localhost:8888", "token")
+ requests = []
+
+ class _OK:
+ def close(self):
+ pass
+
+ monkeypatch.setattr(
+ backend,
+ "_request",
+ lambda method, path, payload = None, timeout = None: (
+ requests.append((method, path, payload, timeout)),
+ _OK(),
+ )[1],
+ )
+
+ backend.ensure_loaded(
+ "org/model-GGUF",
+ hf_token = None,
+ max_seq_length = 4096,
+ load_in_4bit = True,
+ tensor_parallel = False,
+ )
+
+ assert requests[0][2]["tensor_parallel"] is False
+
+
+def test_load_gguf_backend_forwards_local_runtime_options(monkeypatch):
+ import unsloth_cli._inference as inference
+
+ calls = []
+
+ class _FakeLlamaCppBackend:
+ def load_model(self, **kwargs):
+ calls.append(kwargs)
+ return True
+
+ fake_llama_cpp = types.ModuleType("core.inference.llama_cpp")
+ fake_llama_cpp.LlamaCppBackend = _FakeLlamaCppBackend
+ fake_args = types.ModuleType("core.inference.llama_server_args")
+ fake_args.validate_extra_args = lambda args: list(args or [])
+ fake_tensor_fallback = types.ModuleType("core.inference.tensor_fallback")
+
+ async def _passthrough(
+ attempt_load,
+ *,
+ requested_tensor,
+ extra_args,
+ label = "",
+ cancelled = None,
+ ):
+ return await attempt_load(requested_tensor, extra_args)
+
+ fake_tensor_fallback.load_with_tensor_fallback = _passthrough
+
+ monkeypatch.setitem(sys.modules, "core", types.ModuleType("core"))
+ monkeypatch.setitem(sys.modules, "core.inference", types.ModuleType("core.inference"))
+ monkeypatch.setitem(sys.modules, "core.inference.llama_cpp", fake_llama_cpp)
+ monkeypatch.setitem(sys.modules, "core.inference.llama_server_args", fake_args)
+ monkeypatch.setitem(sys.modules, "core.inference.tensor_fallback", fake_tensor_fallback)
+ monkeypatch.setattr(inference, "ensure_studio_backend_path", lambda: None)
+
+ config = SimpleNamespace(
+ gguf_variant = "Q4_K_M",
+ identifier = "org/model-GGUF",
+ is_vision = False,
+ gguf_hf_repo = "org/model-GGUF",
+ )
+
+ backend = inference._load_gguf_backend(
+ config,
+ hf_token = "hf_x",
+ max_seq_length = 8192,
+ tensor_parallel = True,
+ llama_extra_args = ["--top-k", "20"],
+ )
+
+ assert isinstance(backend, ChatBackend)
+ assert calls == [
+ {
+ "hf_repo": "org/model-GGUF",
+ "hf_token": "hf_x",
+ "hf_variant": "Q4_K_M",
+ "model_identifier": "org/model-GGUF",
+ "is_vision": False,
+ "n_ctx": 8192,
+ "tensor_parallel": True,
+ "extra_args": ["--top-k", "20"],
+ }
+ ]
+
+
+def test_load_gguf_backend_exits_cleanly_on_invalid_extra_args(monkeypatch):
+ import unsloth_cli._inference as inference
+
+ fake_llama_cpp = types.ModuleType("core.inference.llama_cpp")
+ fake_llama_cpp.LlamaCppBackend = object
+ fake_args = types.ModuleType("core.inference.llama_server_args")
+
+ def _raise(_args):
+ raise ValueError("llama-server flag '--model' is managed by Unsloth Studio")
+
+ fake_args.validate_extra_args = _raise
+ fake_tensor_fallback = types.ModuleType("core.inference.tensor_fallback")
+ fake_tensor_fallback.load_with_tensor_fallback = None
+
+ monkeypatch.setitem(sys.modules, "core", types.ModuleType("core"))
+ monkeypatch.setitem(sys.modules, "core.inference", types.ModuleType("core.inference"))
+ monkeypatch.setitem(sys.modules, "core.inference.llama_cpp", fake_llama_cpp)
+ monkeypatch.setitem(sys.modules, "core.inference.llama_server_args", fake_args)
+ monkeypatch.setitem(sys.modules, "core.inference.tensor_fallback", fake_tensor_fallback)
+ monkeypatch.setattr(inference, "ensure_studio_backend_path", lambda: None)
+
+ config = SimpleNamespace(
+ gguf_variant = "Q4_K_M",
+ identifier = "org/model-GGUF",
+ is_vision = False,
+ gguf_hf_repo = "org/model-GGUF",
+ )
+
+ with pytest.raises(typer.Exit) as excinfo:
+ inference._load_gguf_backend(
+ config,
+ hf_token = "hf_x",
+ max_seq_length = 8192,
+ llama_extra_args = ["--model"],
+ )
+
+ assert excinfo.value.exit_code == 1
+
+
+def test_load_gguf_backend_uses_tensor_fallback(monkeypatch):
+ import unsloth_cli._inference as inference
+
+ calls = []
+ fallback_calls = []
+
+ class _FakeLlamaCppBackend:
+ def load_model(self, **kwargs):
+ calls.append(kwargs)
+ return kwargs["tensor_parallel"] is False
+
+ fake_llama_cpp = types.ModuleType("core.inference.llama_cpp")
+ fake_llama_cpp.LlamaCppBackend = _FakeLlamaCppBackend
+ fake_args = types.ModuleType("core.inference.llama_server_args")
+ fake_args.validate_extra_args = lambda args: list(args or [])
+ fake_tensor_fallback = types.ModuleType("core.inference.tensor_fallback")
+
+ async def _fallback(
+ attempt_load,
+ *,
+ requested_tensor,
+ extra_args,
+ label = "",
+ cancelled = None,
+ ):
+ fallback_calls.append((requested_tensor, extra_args, label))
+ ok = await attempt_load(requested_tensor, extra_args)
+ if ok:
+ return True
+ return await attempt_load(False, ["--split-mode", "layer"])
+
+ fake_tensor_fallback.load_with_tensor_fallback = _fallback
+
+ monkeypatch.setitem(sys.modules, "core", types.ModuleType("core"))
+ monkeypatch.setitem(sys.modules, "core.inference", types.ModuleType("core.inference"))
+ monkeypatch.setitem(sys.modules, "core.inference.llama_cpp", fake_llama_cpp)
+ monkeypatch.setitem(sys.modules, "core.inference.llama_server_args", fake_args)
+ monkeypatch.setitem(sys.modules, "core.inference.tensor_fallback", fake_tensor_fallback)
+ monkeypatch.setattr(inference, "ensure_studio_backend_path", lambda: None)
+
+ config = SimpleNamespace(
+ gguf_variant = "Q4_K_M",
+ identifier = "org/model-GGUF",
+ is_vision = False,
+ gguf_hf_repo = "org/model-GGUF",
+ )
+
+ backend = inference._load_gguf_backend(
+ config,
+ hf_token = "hf_x",
+ max_seq_length = 8192,
+ tensor_parallel = True,
+ )
+
+ assert isinstance(backend, ChatBackend)
+ assert fallback_calls == [(True, [], "org/model-GGUF")]
+ assert [call["tensor_parallel"] for call in calls] == [True, False]
+ assert calls[1]["extra_args"] == ["--split-mode", "layer"]
+
+
def test_http_backend_merges_emoji_split_across_deltas(monkeypatch):
backend = HttpChatBackend("http://localhost:8888", "token")
response = _FakeSSEResponse(
@@ -365,6 +628,98 @@ def test_chat_prefers_running_studio_server(monkeypatch):
assert closed == ["http"]
+def test_chat_forwards_gguf_runtime_options_to_loader(monkeypatch):
+ loads = []
+
+ class _FakeHttpBackend:
+ def close(self):
+ pass
+
+ monkeypatch.setattr(chatmod, "resolve_model_config", lambda *a, **k: _FakeConfig())
+ monkeypatch.setattr(
+ chatmod,
+ "connect_studio_server",
+ lambda model, **kwargs: (loads.append((model, kwargs)), _FakeHttpBackend())[1],
+ )
+ monkeypatch.setattr(chatmod, "load_chat_backend", lambda *a, **k: None)
+ monkeypatch.setattr(chatmod, "_compare_needs_second_model", lambda: False)
+
+ result = CliRunner().invoke(
+ _chat_app(),
+ [
+ "fake-model",
+ "--tensor-parallel",
+ "--llama-extra-arg=--top-k",
+ "--llama-extra-arg",
+ "20",
+ ],
+ input = "/exit\n",
+ )
+
+ assert result.exit_code == 0, result.output
+ assert loads == [
+ (
+ "fake-model",
+ {
+ "hf_token": None,
+ "max_seq_length": 4096,
+ "load_in_4bit": True,
+ "tensor_parallel": True,
+ "llama_extra_args": ["--top-k", "20"],
+ },
+ )
+ ]
+
+
+def test_inference_forwards_gguf_runtime_options_to_loader(monkeypatch):
+ from unsloth_cli.commands import inference as infermod
+
+ loads, streams, closed = [], [], []
+
+ class _FakeBackend:
+ def stream(self, messages, **kwargs):
+ streams.append((messages, kwargs))
+ return iter(["answer"])
+
+ def close(self):
+ closed.append(True)
+
+ monkeypatch.setattr(
+ infermod,
+ "connect_studio_server",
+ lambda model, **kwargs: (loads.append((model, kwargs)), _FakeBackend())[1],
+ )
+ monkeypatch.setattr(infermod, "load_chat_backend", lambda *a, **k: None)
+
+ result = CliRunner().invoke(
+ _inference_app(),
+ [
+ "fake-model",
+ "hello",
+ "--tensor-parallel",
+ "--llama-extra-arg=--top-k",
+ "--llama-extra-arg",
+ "20",
+ ],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert loads == [
+ (
+ "fake-model",
+ {
+ "hf_token": None,
+ "max_seq_length": 2048,
+ "load_in_4bit": True,
+ "tensor_parallel": True,
+ "llama_extra_args": ["--top-k", "20"],
+ },
+ )
+ ]
+ assert streams[0][0] == [{"role": "user", "content": "hello"}]
+ assert closed == [True]
+
+
def test_chat_server_mode_compare_loads_base_locally(monkeypatch):
streamed, closed, base_loads = [], [], []
From 9451aef51e2639e4003a1b490048afed1471e51d Mon Sep 17 00:00:00 2001
From: Daniel Han
Date: Fri, 26 Jun 2026 12:07:53 -0700
Subject: [PATCH 02/31] studio: return a clean model id from the OpenAI API
instead of the local .gguf path (#6518)
---------
Co-authored-by: oobabooga <112222186+oobabooga@users.noreply.github.com>
---
studio/backend/core/inference/llama_cpp.py | 9 +++
.../core/inference/llama_server_args.py | 5 ++
studio/backend/core/inference/model_ids.py | 57 +++++++++++++++++
studio/backend/routes/inference.py | 62 +++++++++++++++----
studio/backend/tests/test_model_ids.py | 53 ++++++++++++++++
.../tests/test_openai_models_path_leak.py | 45 ++++++++++++++
6 files changed, 218 insertions(+), 13 deletions(-)
create mode 100644 studio/backend/core/inference/model_ids.py
create mode 100644 studio/backend/tests/test_model_ids.py
create mode 100644 studio/backend/tests/test_openai_models_path_leak.py
diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py
index 255fc4140f..31969afb14 100644
--- a/studio/backend/core/inference/llama_cpp.py
+++ b/studio/backend/core/inference/llama_cpp.py
@@ -5476,6 +5476,15 @@ class LlamaCppBackend:
"--no-context-shift",
]
+ # Report a clean public model id (matching GET /v1/models) rather
+ # than the raw -m path in llama-server's own /v1/models and the
+ # "model" field of its chat/completions responses.
+ from core.inference.model_ids import public_model_id
+
+ _alias = public_model_id(self._model_identifier or model_path)
+ if _alias:
+ cmd.extend(["--alias", _alias])
+
fully_gpu_offloaded = False
if use_fit:
cmd.extend(["--fit", "on"])
diff --git a/studio/backend/core/inference/llama_server_args.py b/studio/backend/core/inference/llama_server_args.py
index b42be5ee0d..f400d2ae40 100644
--- a/studio/backend/core/inference/llama_server_args.py
+++ b/studio/backend/core/inference/llama_server_args.py
@@ -25,6 +25,11 @@ _DENYLIST_GROUPS: tuple[frozenset[str], ...] = (
# Model identity: Studio resolves it from LoadRequest; a second -m would
# load a different model than Studio thinks it loaded.
frozenset({"-m", "--model"}),
+ # Public model id: Studio sets a sanitized --alias so the OpenAI API never
+ # exposes the local .gguf path. A user-supplied alias is appended after
+ # Studio's and, with llama.cpp's last-wins parsing, would reintroduce the
+ # path leak this is meant to prevent.
+ frozenset({"-a", "--alias"}),
frozenset({"-mu", "--model-url"}),
frozenset({"-dr", "--docker-repo"}),
frozenset({"-hf", "-hfr", "--hf-repo"}),
diff --git a/studio/backend/core/inference/model_ids.py b/studio/backend/core/inference/model_ids.py
new file mode 100644
index 0000000000..4d409cb4b5
--- /dev/null
+++ b/studio/backend/core/inference/model_ids.py
@@ -0,0 +1,57 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""Public model identifiers for the OpenAI-compatible API.
+
+The exposed API must report a stable, clean model id rather than the absolute
+on-disk path of a local GGUF. The internal identifier for a direct local load is
+the absolute ``.gguf`` path, which leaks the host filesystem layout and is
+awkward for clients to round-trip. ``public_model_id`` maps such an internal
+identifier to a clean name while leaving Hugging Face repo ids (``org/model``)
+and already-clean names untouched.
+"""
+
+from __future__ import annotations
+
+import os
+from typing import Optional
+
+_GGUF_SUFFIX = ".gguf"
+
+
+def _looks_like_path(identifier: str) -> bool:
+ """True when *identifier* is a local filesystem path, not a HF repo id.
+
+ A repo id is ``org/model`` (a single forward slash, no leading separator, no
+ drive, no ``.gguf``). Anything ending in ``.gguf``, starting with a path
+ separator or a relative/home prefix (``./``, ``../``, ``~``), carrying a
+ Windows drive, or with three or more ``/`` segments is treated as a local
+ path.
+ """
+ if identifier.lower().endswith(_GGUF_SUFFIX):
+ return True
+ if identifier.startswith(("/", "\\", "./", "../", ".\\", "..\\", "~")):
+ return True
+ if len(identifier) >= 2 and identifier[1] == ":": # Windows drive, e.g. C:\
+ return True
+ if identifier.count("/") >= 2 or "\\" in identifier:
+ return True
+ return False
+
+
+def public_model_id(identifier: Optional[str]) -> Optional[str]:
+ """Return a clean, path-free public id for *identifier*.
+
+ - Local GGUF path -> the file stem with ``.gguf`` stripped, e.g.
+ ``/srv/models/Qwen3-30B-A3B-Q4_K_M.gguf`` -> ``Qwen3-30B-A3B-Q4_K_M``.
+ - HF repo id (``org/model``) and already-clean names -> returned unchanged.
+ - ``None`` / empty -> returned unchanged.
+ """
+ if not identifier:
+ return identifier
+ if not _looks_like_path(identifier):
+ return identifier
+ name = os.path.basename(identifier.replace("\\", "/").rstrip("/"))
+ if name.lower().endswith(_GGUF_SUFFIX):
+ name = name[: -len(_GGUF_SUFFIX)]
+ return name or identifier
diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py
index a9c85fa99a..1061542ca6 100644
--- a/studio/backend/routes/inference.py
+++ b/studio/backend/routes/inference.py
@@ -1107,6 +1107,7 @@ from auth.authentication import get_current_subject
from state.tool_approvals import resolve_tool_decision
from core.inference.key_exchange import decrypt_api_key
+from core.inference.model_ids import public_model_id
from core.inference.api_monitor import api_monitor
from core.inference.llama_http import nonstreaming_client
from core.inference.providers import get_base_url
@@ -3703,7 +3704,7 @@ async def generate_audio(
# Pick backend — both return (wav_bytes, sample_rate)
llama_backend = get_llama_cpp_backend()
if llama_backend.is_loaded and getattr(llama_backend, "_is_audio", False):
- model_name = llama_backend.model_identifier
+ model_name = public_model_id(llama_backend.model_identifier)
gen = lambda: llama_backend.generate_audio_response(
text = text,
audio_type = llama_backend._audio_type,
@@ -3721,7 +3722,7 @@ async def generate_audio(
model_info = backend.models.get(backend.active_model_name, {})
if not model_info.get("is_audio"):
raise HTTPException(status_code = 400, detail = "Active model is not an audio model.")
- model_name = backend.active_model_name
+ model_name = public_model_id(backend.active_model_name)
gen = lambda: backend.generate_audio_response(
text = text,
temperature = payload.temperature,
@@ -4837,7 +4838,8 @@ async def openai_chat_completions(
return response
if using_gguf:
- model_name = llama_backend.model_identifier or payload.model
+ # Echo a clean public id in the response, never the absolute .gguf path.
+ model_name = public_model_id(llama_backend.model_identifier) or payload.model
if getattr(llama_backend, "_is_audio", False):
if _wants_multiple_choices(payload):
_raise_unsupported_n("GGUF audio chat completions")
@@ -4852,7 +4854,9 @@ async def openai_chat_completions(
status_code = 400,
detail = "No model loaded. Call POST /inference/load first.",
)
- model_name = backend.active_model_name or payload.model
+ # Clean public id so the response never echoes a local path; the audio
+ # branch below receives this sanitized label too.
+ model_name = public_model_id(backend.active_model_name) or payload.model
if _wants_multiple_choices(payload):
_raise_unsupported_n("non-GGUF chat completions")
@@ -6401,7 +6405,9 @@ def _openai_model_objects() -> list[dict]:
llama_backend = get_llama_cpp_backend()
if llama_backend.is_loaded:
entry = {
- "id": llama_backend.model_identifier,
+ # Public id, never the absolute .gguf path (which leaks the host
+ # filesystem layout); see core.inference.model_ids.public_model_id.
+ "id": public_model_id(llama_backend.model_identifier),
"object": "model",
"created": _created,
"owned_by": "local",
@@ -6422,7 +6428,7 @@ def _openai_model_objects() -> list[dict]:
if backend.active_model_name:
model_info = backend.models.get(backend.active_model_name, {})
entry = {
- "id": backend.active_model_name,
+ "id": public_model_id(backend.active_model_name),
"object": "model",
"created": _created,
"owned_by": "local",
@@ -6463,9 +6469,24 @@ async def openai_retrieve_model(model_id: str, current_subject: str = Depends(ge
model, or 404 model_not_found otherwise. Defined after the LIST route so
it does not shadow it; ``{model_id:path}`` keeps ids with slashes intact.
"""
- for model in _openai_model_objects():
+ objects = _openai_model_objects()
+ for model in objects:
if model["id"] == model_id:
return model
+ # Backward compatibility: a client may still send the legacy raw identifier
+ # (e.g. an absolute .gguf path cached from an older /v1/models). Resolve it to
+ # the clean object so it keeps working, without ever echoing the path back.
+ llama_backend = get_llama_cpp_backend()
+ backend = get_inference_backend()
+ for raw in (
+ llama_backend.model_identifier if llama_backend.is_loaded else None,
+ backend.active_model_name or None,
+ ):
+ if raw and model_id == raw:
+ clean = public_model_id(raw)
+ for model in objects:
+ if model["id"] == clean:
+ return model
raise HTTPException(
status_code = 404,
detail = openai_error_body(
@@ -7402,6 +7423,15 @@ async def _responses_stream(
target_url = f"{llama_backend.base_url}/v1/chat/completions"
async def event_generator():
+ # Clean public id for every response envelope. Prefer the loaded model's
+ # id so the stream agrees with /v1/models, chat/completions and the
+ # non-streaming twin; fall back to a sanitized payload.model (a legacy
+ # raw .gguf path is stripped, never echoed back).
+ _clean_model = (
+ public_model_id(getattr(llama_backend, "model_identifier", None))
+ or public_model_id(payload.model)
+ or payload.model
+ )
full_text = ""
full_reasoning = ""
input_tokens = 0
@@ -7563,7 +7593,7 @@ async def _responses_stream(
"object": "response",
"created_at": created_at,
"status": "failed",
- "model": payload.model,
+ "model": _clean_model,
"output": _snapshot_output(),
"usage": {
"input_tokens": input_tokens,
@@ -7587,7 +7617,7 @@ async def _responses_stream(
"object": "response",
"created_at": created_at,
"status": "in_progress",
- "model": payload.model,
+ "model": _clean_model,
"output": [],
"usage": {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0},
},
@@ -7627,7 +7657,7 @@ async def _responses_stream(
"object": "response",
"created_at": created_at,
"status": "failed",
- "model": payload.model,
+ "model": _clean_model,
"output": [],
"error": {"code": 502, "message": _friendly_error(e)},
},
@@ -7653,7 +7683,7 @@ async def _responses_stream(
"object": "response",
"created_at": created_at,
"status": "failed",
- "model": payload.model,
+ "model": _clean_model,
"output": [],
"error": {
"code": resp.status_code,
@@ -8002,7 +8032,7 @@ async def _responses_stream(
"object": "response",
"created_at": created_at,
"status": "completed",
- "model": payload.model,
+ "model": _clean_model,
"output": _snapshot_output(),
"usage": {
"input_tokens": input_tokens,
@@ -8274,7 +8304,13 @@ async def anthropic_messages(
),
)
- model_name = getattr(llama_backend, "model_identifier", None) or payload.model
+ # Clean public id so /v1/messages never echoes the local .gguf path (and a
+ # legacy raw path sent as payload.model is sanitized rather than returned).
+ model_name = (
+ public_model_id(getattr(llama_backend, "model_identifier", None))
+ or public_model_id(payload.model)
+ or payload.model
+ )
message_id = f"msg_{uuid.uuid4().hex[:24]}"
# ── Translate Anthropic → OpenAI ──────────────────────────
diff --git a/studio/backend/tests/test_model_ids.py b/studio/backend/tests/test_model_ids.py
new file mode 100644
index 0000000000..1b0cd927d8
--- /dev/null
+++ b/studio/backend/tests/test_model_ids.py
@@ -0,0 +1,53 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+import sys
+from pathlib import Path
+
+_BACKEND = Path(__file__).resolve().parents[1]
+if str(_BACKEND) not in sys.path:
+ sys.path.insert(0, str(_BACKEND))
+
+from core.inference.model_ids import public_model_id # noqa: E402
+
+
+def test_local_gguf_path_becomes_clean_stem():
+ assert public_model_id("/srv/models/Qwen3-30B-A3B-Q4_K_M.gguf") == "Qwen3-30B-A3B-Q4_K_M"
+ assert public_model_id("/home/u/.cache/models/llama.gguf") == "llama"
+
+
+def test_hf_repo_id_unchanged():
+ assert public_model_id("unsloth/Qwen3-30B-A3B-GGUF") == "unsloth/Qwen3-30B-A3B-GGUF"
+ assert public_model_id("Qwen3-30B-A3B") == "Qwen3-30B-A3B"
+
+
+def test_none_and_empty_passthrough():
+ assert public_model_id(None) is None
+ assert public_model_id("") == ""
+
+
+def test_windows_path():
+ assert public_model_id("C:\\models\\foo.gguf") == "foo"
+ assert public_model_id("models\\sub\\bar.gguf") == "bar"
+
+
+def test_directory_path_uses_basename():
+ assert public_model_id("/opt/models/MyModelDir") == "MyModelDir"
+ # A 3+ segment relative path is a local path, not an org/model repo id.
+ assert public_model_id("a/b/c") == "c"
+
+
+def test_relative_and_home_paths_are_sanitized():
+ # ./ ../ ~ prefixed paths are local and must not be echoed raw.
+ assert public_model_id("./model.gguf") == "model"
+ assert public_model_id("../models/foo.gguf") == "foo"
+ assert public_model_id("~/models/baz.gguf") == "baz"
+ assert public_model_id("./mistral") == "mistral"
+ assert public_model_id("~/mistral") == "mistral"
+ assert public_model_id(".\\models\\foo.gguf") == "foo"
+
+
+def test_dotted_repo_id_not_mistaken_for_relative_path():
+ # A leading dot that is not ./ or ../ is an ordinary clean name.
+ assert public_model_id(".hidden-model") == ".hidden-model"
+ assert public_model_id("org/.config") == "org/.config"
diff --git a/studio/backend/tests/test_openai_models_path_leak.py b/studio/backend/tests/test_openai_models_path_leak.py
new file mode 100644
index 0000000000..a84a33f840
--- /dev/null
+++ b/studio/backend/tests/test_openai_models_path_leak.py
@@ -0,0 +1,45 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""GET /v1/models must report a clean public id, never the on-disk .gguf path."""
+
+import json
+import sys
+from pathlib import Path
+
+_BACKEND = Path(__file__).resolve().parents[1]
+if str(_BACKEND) not in sys.path:
+ sys.path.insert(0, str(_BACKEND))
+
+import routes.inference as inf # noqa: E402
+
+
+class _FakeLlama:
+ is_loaded = True
+ model_identifier = "/srv/models/Qwen3-30B-A3B-Q4_K_M.gguf"
+ context_length = 4096
+ max_context_length = None
+ native_context_length = None
+
+
+class _FakeUnsloth:
+ active_model_name = None
+ models: dict = {}
+ context_length = None
+ max_seq_length = None
+
+
+def test_openai_models_returns_clean_id_without_path(monkeypatch):
+ monkeypatch.setattr(inf, "get_llama_cpp_backend", lambda: _FakeLlama())
+ monkeypatch.setattr(inf, "get_inference_backend", lambda: _FakeUnsloth())
+
+ objs = inf._openai_model_objects()
+
+ assert len(objs) == 1
+ assert objs[0]["id"] == "Qwen3-30B-A3B-Q4_K_M"
+ # The serialized payload must not leak the absolute path or the .gguf suffix.
+ blob = json.dumps(objs)
+ assert "/srv/models" not in blob
+ assert ".gguf" not in blob
+ # Context fields still flow through.
+ assert objs[0]["context_length"] == 4096
From 4a5d41eb3d3731f41347c6adec0f6dde95cf3253 Mon Sep 17 00:00:00 2001
From: Luca Cesarano
Date: Fri, 26 Jun 2026 21:48:30 +0200
Subject: [PATCH 03/31] fix(install): enable UV_NATIVE_TLS on macOS for
corporate TLS-inspection proxies (#6671)
---------
Co-authored-by: Luca Cesarano
Co-authored-by: oobabooga <112222186+oobabooga@users.noreply.github.com>
---
README.md | 5 +++++
install.sh | 15 +++++++++++++++
2 files changed, 20 insertions(+)
diff --git a/README.md b/README.md
index 9162d29b1c..e3fd4e6980 100644
--- a/README.md
+++ b/README.md
@@ -246,6 +246,11 @@ curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_STUDIO_HOME=/abs/path sh
$env:UNSLOTH_STUDIO_HOME='C:\path'; irm https://unsloth.ai/install.ps1 | iex
```
+On macOS, the installer defaults to the system certificate store (`UV_SYSTEM_CERTS=1`) so uv trusts the CAs in your Keychain, needed behind TLS-inspecting proxies (Cisco Umbrella, Zscaler, etc.). Opt out with:
+```bash
+curl -fsSL https://unsloth.ai/install.sh | UV_SYSTEM_CERTS=0 sh
+```
+
Point the frontend build at a corporate npm mirror/proxy with `UNSLOTH_NPM_REGISTRY` (for the developer install behind a firewall that blocks `registry.npmjs.org`):
```bash
UNSLOTH_NPM_REGISTRY=https://artifactory.example.com/api/npm/npm/ ./install.sh --local
diff --git a/install.sh b/install.sh
index 548e6f702a..7a9f0be87f 100755
--- a/install.sh
+++ b/install.sh
@@ -1636,6 +1636,21 @@ export UV_HTTP_RETRIES
: "${UV_HTTP_TIMEOUT:=180}"
export UV_HTTP_TIMEOUT
+# macOS: trust the system Keychain so uv uses SecureTransport instead of rustls.
+# Required behind TLS-inspecting proxies (Cisco Umbrella, Zscaler, etc.) which
+# present their own CA certificate. rustls (uv's default) ignores the Keychain
+# and rejects intercepted connections with "invalid peer certificate: UnknownIssuer".
+# Set both vars: UV_SYSTEM_CERTS is the modern one (uv >= 0.11), UV_NATIVE_TLS the
+# legacy one understood by uv 0.8.16-0.10.x, which the installer keeps if already
+# present (UV_MIN_VERSION) and which ignores UV_SYSTEM_CERTS. Mirror the choice onto
+# both so it works on either uv. Opt out with UV_SYSTEM_CERTS=0.
+if [ "$OS" = "macos" ]; then
+ : "${UV_SYSTEM_CERTS:=1}"
+ : "${UV_NATIVE_TLS:=$UV_SYSTEM_CERTS}"
+fi
+[ -n "${UV_SYSTEM_CERTS:-}" ] && export UV_SYSTEM_CERTS
+[ -n "${UV_NATIVE_TLS:-}" ] && export UV_NATIVE_TLS
+
version_ge() {
# returns 0 if $1 >= $2
_a=$1
From e594e5d2018618c665009135335562ec35846178 Mon Sep 17 00:00:00 2001
From: Long Yixing
Date: Sat, 27 Jun 2026 06:54:28 +0800
Subject: [PATCH 04/31] fix: stop faking 8bit load flag (#6708)
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
---
unsloth/models/_utils.py | 81 ++++++++++++++++++++++++++++++++++++++++
unsloth/models/llama.py | 12 ++----
unsloth/models/vision.py | 12 ++----
3 files changed, 89 insertions(+), 16 deletions(-)
diff --git a/unsloth/models/_utils.py b/unsloth/models/_utils.py
index 7a056cef82..7ad6e8ea33 100644
--- a/unsloth/models/_utils.py
+++ b/unsloth/models/_utils.py
@@ -86,6 +86,8 @@ __all__ = [
"is_moe_model",
"get_moe_target_parameters",
"make_fast_generate_wrapper",
+ "_mark_unsloth_disable_data_parallel",
+ "_patch_transformers_trainer_data_parallel",
]
import torch
@@ -160,6 +162,85 @@ from unsloth_zoo.training_utils import (
)
+def _iter_wrapped_models(model):
+ seen = set()
+ current = model
+ while current is not None and id(current) not in seen:
+ yield current
+ seen.add(id(current))
+ next_model = getattr(current, "model", None)
+ if next_model is None:
+ next_model = getattr(current, "base_model", None)
+ if next_model is None:
+ next_model = getattr(current, "module", None)
+ current = next_model
+
+
+def _patch_transformers_trainer_data_parallel():
+ try:
+ from transformers.trainer import Trainer
+ except (ImportError, ModuleNotFoundError):
+ return False
+
+ original_wrap_model = getattr(Trainer, "_wrap_model", None)
+ if original_wrap_model is None:
+ return False
+ if getattr(original_wrap_model, "_unsloth_data_parallel_patched", False):
+ return True
+ try:
+ supports_dataloader = "dataloader" in inspect.signature(original_wrap_model).parameters
+ except (TypeError, ValueError):
+ supports_dataloader = True
+
+ def _call_original_wrap_model(self, model, wrap_args, wrap_kwargs):
+ if supports_dataloader:
+ return original_wrap_model(self, model, *wrap_args, **wrap_kwargs)
+
+ if "dataloader" in wrap_kwargs:
+ wrap_kwargs = {k: v for k, v in wrap_kwargs.items() if k != "dataloader"}
+ return original_wrap_model(self, model, *wrap_args, **wrap_kwargs)
+
+ @functools.wraps(original_wrap_model)
+ def _unsloth_wrap_model(self, model, *wrap_args, **wrap_kwargs):
+ args = getattr(self, "args", None)
+ disable_data_parallel = getattr(model, "_unsloth_disable_data_parallel", False)
+ is_real_8bit = getattr(model, "is_loaded_in_8bit", False)
+ if (
+ args is None
+ or not disable_data_parallel
+ or is_real_8bit
+ or getattr(args, "n_gpu", 0) <= 1
+ ):
+ return _call_original_wrap_model(self, model, wrap_args, wrap_kwargs)
+
+ had_n_gpu = hasattr(args, "_n_gpu")
+ old_n_gpu = getattr(args, "_n_gpu", None)
+ args._n_gpu = 1
+ try:
+ return _call_original_wrap_model(self, model, wrap_args, wrap_kwargs)
+ finally:
+ if had_n_gpu:
+ args._n_gpu = old_n_gpu
+ else:
+ try:
+ delattr(args, "_n_gpu")
+ except AttributeError:
+ pass
+
+ _unsloth_wrap_model._unsloth_data_parallel_patched = True
+ _unsloth_wrap_model._unsloth_original_wrap_model = original_wrap_model
+ Trainer._wrap_model = _unsloth_wrap_model
+ return True
+
+
+def _mark_unsloth_disable_data_parallel(model, disable = True):
+ if disable:
+ _patch_transformers_trainer_data_parallel()
+ for module in _iter_wrapped_models(model):
+ setattr(module, "_unsloth_disable_data_parallel", bool(disable))
+ return model
+
+
def resolve_hip_gpu_stats_name(gpu_stats):
name = str(getattr(gpu_stats, "name", "") or "").strip()
name = re.sub(r"\s*\([^)]*\)\s*$", "", name).strip()
diff --git a/unsloth/models/llama.py b/unsloth/models/llama.py
index d5eca8d6df..14ee5ee24e 100644
--- a/unsloth/models/llama.py
+++ b/unsloth/models/llama.py
@@ -2832,13 +2832,11 @@ class FastLlamaModel:
internal_model = model
while hasattr(internal_model, "model"):
internal_model._saved_temp_tokenizer = tokenizer
- # Also set is_loaded_in_8bit to disable incorrect DDP
- internal_model.is_loaded_in_8bit = True
internal_model = internal_model.model
internal_model._saved_temp_tokenizer = tokenizer
- # Also set is_loaded_in_8bit to disable incorrect DDP
- internal_model.is_loaded_in_8bit = True
+ # Prevent Transformers Trainer from auto-wrapping Unsloth LoRA models in DP.
+ _mark_unsloth_disable_data_parallel(model)
# For transformers > 4.47.1, we need to add rotary_emb to all attention layers
if IS_ATTENTION_REFACTOR or hasattr(model.model, "rotary_emb"):
@@ -3379,13 +3377,11 @@ class FastLlamaModel:
while hasattr(internal_model, "model"):
if hasattr(internal_model, "_saved_temp_tokenizer"):
internal_model._saved_temp_tokenizer.padding_side = "right"
- # Also set is_loaded_in_8bit to disable incorrect DDP
- internal_model.is_loaded_in_8bit = True
internal_model = internal_model.model
if hasattr(internal_model, "_saved_temp_tokenizer"):
internal_model._saved_temp_tokenizer.padding_side = "right"
- # Also set is_loaded_in_8bit to disable incorrect DDP
- internal_model.is_loaded_in_8bit = True
+ # Prevent Transformers Trainer from auto-wrapping Unsloth LoRA models in DP.
+ _mark_unsloth_disable_data_parallel(model)
# Clear deleted GPU items
for _ in range(3):
diff --git a/unsloth/models/vision.py b/unsloth/models/vision.py
index 39004b4d45..bdc2bd9ef6 100644
--- a/unsloth/models/vision.py
+++ b/unsloth/models/vision.py
@@ -1440,16 +1440,14 @@ class FastBaseModel:
while hasattr(m, "model"):
m.max_seq_length = max_seq_length
m._saved_temp_tokenizer = tokenizer
- # Also set is_loaded_in_8bit to disable incorrect DDP
- m.is_loaded_in_8bit = True if not full_finetuning else False
m = m.model
m.max_seq_length = max_seq_length
# Save to modules as well
for module in model.modules():
module.max_seq_length = max_seq_length
m._saved_temp_tokenizer = tokenizer
- # Also set is_loaded_in_8bit to disable incorrect DDP
- m.is_loaded_in_8bit = True if not full_finetuning else False
+ # Prevent Transformers Trainer from auto-wrapping Unsloth LoRA models in DP.
+ _mark_unsloth_disable_data_parallel(model, disable = not full_finetuning)
# Patch generate
if os.environ.get("UNSLOTH_DISABLE_FAST_GENERATION", "0") == "0" and hasattr(
@@ -1826,14 +1824,12 @@ class FastBaseModel:
if hasattr(m, "_saved_temp_tokenizer"):
if hasattr(m._saved_temp_tokenizer, "tokenizer"):
m._saved_temp_tokenizer.tokenizer.padding_side = "left"
- # Also set is_loaded_in_8bit to disable incorrect DDP
- m.is_loaded_in_8bit = True if not full_finetuning else False
m = m.model
if hasattr(m, "_saved_temp_tokenizer"):
if hasattr(m._saved_temp_tokenizer, "tokenizer"):
m._saved_temp_tokenizer.tokenizer.padding_side = "left"
- # Also set is_loaded_in_8bit to disable incorrect DDP
- m.is_loaded_in_8bit = True if not full_finetuning else False
+ # Prevent Transformers Trainer from auto-wrapping Unsloth LoRA models in DP.
+ _mark_unsloth_disable_data_parallel(model, disable = not full_finetuning)
# Clear deleted GPU items
for _ in range(3):
From b11966b2db759f933342364c825211f740a9002a Mon Sep 17 00:00:00 2001
From: Daniel Han
Date: Fri, 26 Jun 2026 16:42:06 -0700
Subject: [PATCH 05/31] studio: list the full local model catalog from
/v1/models (#6519)
---------
Co-authored-by: oobabooga <112222186+oobabooga@users.noreply.github.com>
---
studio/backend/core/inference/model_ids.py | 14 ++
studio/backend/routes/inference.py | 105 ++++++++++--
studio/backend/routes/models.py | 161 +++++++++--------
studio/backend/tests/test_model_ids.py | 11 +-
studio/backend/tests/test_openai_catalog.py | 181 ++++++++++++++++++++
5 files changed, 388 insertions(+), 84 deletions(-)
create mode 100644 studio/backend/tests/test_openai_catalog.py
diff --git a/studio/backend/core/inference/model_ids.py b/studio/backend/core/inference/model_ids.py
index 4d409cb4b5..548cc60f94 100644
--- a/studio/backend/core/inference/model_ids.py
+++ b/studio/backend/core/inference/model_ids.py
@@ -55,3 +55,17 @@ def public_model_id(identifier: Optional[str]) -> Optional[str]:
if name.lower().endswith(_GGUF_SUFFIX):
name = name[: -len(_GGUF_SUFFIX)]
return name or identifier
+
+
+def model_id_matches(requested: Optional[str], internal: Optional[str]) -> bool:
+ """Whether a client-supplied *requested* id refers to *internal*.
+
+ Accepts the clean public id (preferred) and, for backward compatibility, the
+ raw internal identifier (e.g. a legacy absolute path a client cached from an
+ older ``/v1/models`` response).
+ """
+ if requested is None or internal is None:
+ return False
+ if requested == internal:
+ return True
+ return public_model_id(internal) == requested
diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py
index 1061542ca6..caf2a262a3 100644
--- a/studio/backend/routes/inference.py
+++ b/studio/backend/routes/inference.py
@@ -6391,6 +6391,9 @@ async def serve_sandbox_file(
# OpenAI-Compatible Models Listing (/models → /v1/models)
# =====================================================================
+# `owned_by` marker on every /v1/models entry (loaded and available alike).
+_OWNED_BY = "unsloth-studio"
+
def _openai_model_objects() -> list[dict]:
"""The model objects GET /v1/models exposes (one per loaded local backend).
@@ -6410,7 +6413,7 @@ def _openai_model_objects() -> list[dict]:
"id": public_model_id(llama_backend.model_identifier),
"object": "model",
"created": _created,
- "owned_by": "local",
+ "owned_by": _OWNED_BY,
}
_ctx = _positive_int_or_none(getattr(llama_backend, "context_length", None))
if _ctx is not None:
@@ -6431,7 +6434,7 @@ def _openai_model_objects() -> list[dict]:
"id": public_model_id(backend.active_model_name),
"object": "model",
"created": _created,
- "owned_by": "local",
+ "owned_by": _OWNED_BY,
}
_ctx = _positive_int_or_none(model_info.get("context_length"))
if _ctx is None:
@@ -6449,15 +6452,86 @@ def _openai_model_objects() -> list[dict]:
return models
+# Brief cache for the local-model filesystem scan so repeated /v1/models calls
+# don't rescan the HF cache and models dirs on every request.
+_CATALOG_CACHE: dict = {"at": 0.0, "models": []}
+_CATALOG_TTL_S = 30.0
+_CATALOG_LOCK = asyncio.Lock()
+
+
+async def _cached_local_catalog() -> list:
+ """Locally available models (models dir + HF caches + LM Studio + scan
+ folders), cached for a few seconds. Returns a list of LocalModelInfo.
+
+ The scan walks several directories and stats many files, so it runs in a
+ worker thread (asyncio.to_thread) -- calling it inline would block the event
+ loop and stall every concurrent request and in-flight inference stream. A
+ lock with a double-check collapses a burst of simultaneous /v1/models calls
+ into a single scan instead of one per request."""
+ # Validity is keyed on "at" (set only after a scan), not on list contents, so
+ # an empty/errored scan is still cached instead of rescanning on every poll.
+ now = time.monotonic()
+ if _CATALOG_CACHE["at"] and (now - _CATALOG_CACHE["at"]) <= _CATALOG_TTL_S:
+ return _CATALOG_CACHE["models"]
+ async with _CATALOG_LOCK:
+ now = time.monotonic()
+ if _CATALOG_CACHE["at"] and (now - _CATALOG_CACHE["at"]) <= _CATALOG_TTL_S:
+ return _CATALOG_CACHE["models"]
+ try:
+ from routes.models import collect_local_models
+ _CATALOG_CACHE["models"] = await asyncio.to_thread(
+ collect_local_models, Path("./models").resolve()
+ )
+ except Exception as exc:
+ logger.debug("model catalog scan failed: %s", exc)
+ _CATALOG_CACHE["models"] = []
+ # Stamp after the scan, not the pre-scan "now": a scan slower than the TTL
+ # would otherwise leave the cache already expired, so every waiter rescans.
+ _CATALOG_CACHE["at"] = time.monotonic()
+ return _CATALOG_CACHE["models"]
+
+
+async def _openai_catalog_objects() -> list[dict]:
+ """Every model the server knows about for ``GET /v1/models``: the loaded
+ model(s) plus locally available (downloaded/cached) models discovered by
+ scanning. Loaded entries keep their context fields and are marked
+ ``loaded: true``. All ids are clean public ids (never absolute paths)."""
+ _created = int(time.time())
+ # Loaded models first (clean ids + context fields), marked loaded.
+ by_id: dict[str, dict] = {}
+ for entry in _openai_model_objects():
+ by_id[entry["id"]] = {**entry, "loaded": True}
+
+ # Locally available (downloaded/cached) models that are not already loaded.
+ for info in await _cached_local_catalog():
+ cid = getattr(info, "model_id", None) or public_model_id(getattr(info, "id", None))
+ if not cid or cid in by_id:
+ continue
+ obj = {
+ "id": cid,
+ "object": "model",
+ "created": _created,
+ "owned_by": _OWNED_BY,
+ "loaded": False,
+ }
+ display = getattr(info, "display_name", None)
+ if display:
+ obj["display_name"] = display
+ by_id[cid] = obj
+
+ return list(by_id.values())
+
+
@router.get("/models")
async def openai_list_models(current_subject: str = Depends(get_current_subject)):
"""
- OpenAI-compatible model listing endpoint.
+ OpenAI-compatible model listing endpoint (``GET /v1/models``).
- Returns the currently loaded model in the format expected by
- OpenAI-compatible clients (``GET /v1/models``).
+ Lists every model available on this server -- the loaded model(s) plus
+ locally available (downloaded/cached) models -- not only what is resident in
+ memory. Each entry carries a clean public id and a ``loaded`` flag.
"""
- return {"object": "list", "data": _openai_model_objects()}
+ return {"object": "list", "data": await _openai_catalog_objects()}
@router.get("/models/{model_id:path}")
@@ -6465,11 +6539,20 @@ async def openai_retrieve_model(model_id: str, current_subject: str = Depends(ge
"""
OpenAI-compatible single-model retrieval endpoint (``GET /v1/models/{id}``).
- Returns the bare model object when ``model_id`` matches a loaded local
- model, or 404 model_not_found otherwise. Defined after the LIST route so
- it does not shadow it; ``{model_id:path}`` keeps ids with slashes intact.
+ Returns the bare model object when ``model_id`` matches a known model
+ (loaded or locally available), or 404 model_not_found otherwise. Defined
+ after the LIST route so it does not shadow it; ``{model_id:path}`` keeps ids
+ with slashes intact.
"""
- objects = _openai_model_objects()
+ from core.inference.model_ids import model_id_matches
+
+ # Loaded models resolve without a catalog scan (the common case); only build
+ # the full catalog -- which may hit the filesystem -- for unloaded ids.
+ for entry in _openai_model_objects():
+ if entry["id"] == model_id:
+ return {**entry, "loaded": True}
+
+ objects = await _openai_catalog_objects()
for model in objects:
if model["id"] == model_id:
return model
@@ -6482,7 +6565,7 @@ async def openai_retrieve_model(model_id: str, current_subject: str = Depends(ge
llama_backend.model_identifier if llama_backend.is_loaded else None,
backend.active_model_name or None,
):
- if raw and model_id == raw:
+ if raw and model_id_matches(model_id, raw):
clean = public_model_id(raw)
for model in objects:
if model["id"] == clean:
diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py
index 951c2960f3..e22f65751c 100644
--- a/studio/backend/routes/models.py
+++ b/studio/backend/routes/models.py
@@ -722,6 +722,94 @@ def _scan_ollama_dir(ollama_dir: Path, limit: Optional[int] = None) -> List[Loca
return found
+def collect_local_models(models_root: Path) -> List[LocalModelInfo]:
+ """Scan ``models_root``, the HF caches, LM Studio dirs, and user scan folders,
+ returning a deduplicated, hidden-filtered list of discovered local models.
+
+ Shared by ``GET /models/local`` (the model picker) and the OpenAI-compatible
+ catalog (``GET /v1/models``) so the UI and the API never drift. ``models_root``
+ must already be validated/trusted by the caller.
+ """
+ from storage.studio_db import list_scan_folders
+ from utils.paths import (
+ hf_default_cache_dir,
+ legacy_hf_cache_dir,
+ lmstudio_model_dirs,
+ )
+
+ hf_cache_dir = _resolve_hf_cache_dir()
+ legacy_hf = legacy_hf_cache_dir()
+ hf_default = hf_default_cache_dir()
+ lm_dirs = lmstudio_model_dirs()
+
+ local_models = _scan_models_dir(models_root) + _scan_hf_cache(hf_cache_dir)
+
+ # Resolve once; an inaccessible aux cache must skip that scan, not 500.
+ hf_cache_real = _safe_resolve(hf_cache_dir)
+ legacy_real = _safe_resolve(legacy_hf)
+ default_real = _safe_resolve(hf_default)
+
+ # Scan legacy Unsloth HF cache for backward compatibility.
+ if _safe_is_dir(legacy_hf) and legacy_real != hf_cache_real:
+ local_models += _scan_hf_cache(legacy_hf)
+
+ # Scan HF system default cache (may differ under env overrides).
+ if _safe_is_dir(hf_default) and default_real != hf_cache_real and default_real != legacy_real:
+ local_models += _scan_hf_cache(hf_default)
+
+ # Scan LM Studio directories.
+ for lm_dir in lm_dirs:
+ local_models += _scan_lmstudio_dir(lm_dir)
+
+ # Scan user-added custom folders (per-folder cap).
+ _MAX_MODELS_PER_FOLDER = 200
+ try:
+ custom_folders = list_scan_folders()
+ except Exception as e:
+ logger.warning("Could not load custom scan folders: %s", e)
+ custom_folders = []
+ for folder in custom_folders:
+ folder_path = Path(folder["path"])
+ try:
+ # Filter Ollama .studio_links/ from generic scanners to
+ # avoid duplicates and leaking internal paths into the UI.
+ _generic = [
+ m
+ for m in (
+ _scan_models_dir(folder_path, limit = _MAX_MODELS_PER_FOLDER)
+ + _scan_hf_cache(folder_path)
+ + _scan_lmstudio_dir(folder_path)
+ )
+ if not any(p in (".studio_links", "ollama_links") for p in Path(m.path).parts)
+ ]
+ custom_models = _generic
+ if len(custom_models) < _MAX_MODELS_PER_FOLDER:
+ custom_models += _scan_ollama_dir(
+ folder_path,
+ limit = _MAX_MODELS_PER_FOLDER - len(custom_models),
+ )
+ except OSError as e:
+ logger.warning("Skipping unreadable scan folder %s: %s", folder_path, e)
+ continue
+ local_models += [m.model_copy(update = {"source": "custom"}) for m in custom_models]
+
+ # Deduplicate, but always keep custom folder entries (keyed by
+ # (id, source)) so they show in the "Custom Folders" UI section
+ # even when the model is also in the HF cache.
+ deduped: dict[str, LocalModelInfo] = {}
+ for model in local_models:
+ key = f"{model.id}\x00custom" if model.source == "custom" else model.id
+ if key not in deduped:
+ deduped[key] = model
+
+ models = sorted(
+ deduped.values(),
+ key = lambda item: (item.updated_at or 0),
+ reverse = True,
+ )
+ return [m for m in models if not _is_hidden_model(m.id, m.path)]
+
+
@router.get("/local", response_model = LocalModelListResponse)
async def list_local_models(
models_dir: str = Query(
@@ -770,78 +858,7 @@ async def list_local_models(
)
try:
- local_models = _scan_models_dir(models_root) + _scan_hf_cache(hf_cache_dir)
-
- # Resolve once; an inaccessible aux cache must skip that scan, not 500.
- hf_cache_real = _safe_resolve(hf_cache_dir)
- legacy_real = _safe_resolve(legacy_hf)
- default_real = _safe_resolve(hf_default)
-
- # Scan legacy Unsloth HF cache for backward compatibility.
- if _safe_is_dir(legacy_hf) and legacy_real != hf_cache_real:
- local_models += _scan_hf_cache(legacy_hf)
-
- # Scan HF system default cache (may differ under env overrides).
- if (
- _safe_is_dir(hf_default)
- and default_real != hf_cache_real
- and default_real != legacy_real
- ):
- local_models += _scan_hf_cache(hf_default)
-
- # Scan LM Studio directories.
- for lm_dir in lm_dirs:
- local_models += _scan_lmstudio_dir(lm_dir)
-
- # Scan user-added custom folders (per-folder cap).
- from storage.studio_db import list_scan_folders
-
- _MAX_MODELS_PER_FOLDER = 200
- try:
- custom_folders = list_scan_folders()
- except Exception as e:
- logger.warning("Could not load custom scan folders: %s", e)
- custom_folders = []
- for folder in custom_folders:
- folder_path = Path(folder["path"])
- try:
- # Filter Ollama .studio_links/ from generic scanners to
- # avoid duplicates and leaking internal paths into the UI.
- _generic = [
- m
- for m in (
- _scan_models_dir(folder_path, limit = _MAX_MODELS_PER_FOLDER)
- + _scan_hf_cache(folder_path)
- + _scan_lmstudio_dir(folder_path)
- )
- if not any(p in (".studio_links", "ollama_links") for p in Path(m.path).parts)
- ]
- custom_models = _generic
- if len(custom_models) < _MAX_MODELS_PER_FOLDER:
- custom_models += _scan_ollama_dir(
- folder_path,
- limit = _MAX_MODELS_PER_FOLDER - len(custom_models),
- )
- except OSError as e:
- logger.warning("Skipping unreadable scan folder %s: %s", folder_path, e)
- continue
- local_models += [m.model_copy(update = {"source": "custom"}) for m in custom_models]
-
- # Deduplicate, but always keep custom folder entries (keyed by
- # (id, source)) so they show in the "Custom Folders" UI section
- # even when the model is also in the HF cache.
- deduped: dict[str, LocalModelInfo] = {}
- for model in local_models:
- key = f"{model.id}\x00custom" if model.source == "custom" else model.id
- if key not in deduped:
- deduped[key] = model
-
- models = sorted(
- deduped.values(),
- key = lambda item: (item.updated_at or 0),
- reverse = True,
- )
- models = [m for m in models if not _is_hidden_model(m.id, m.path)]
+ models = collect_local_models(models_root)
return LocalModelListResponse(
models_dir = str(models_root),
diff --git a/studio/backend/tests/test_model_ids.py b/studio/backend/tests/test_model_ids.py
index 1b0cd927d8..f9116afec3 100644
--- a/studio/backend/tests/test_model_ids.py
+++ b/studio/backend/tests/test_model_ids.py
@@ -8,7 +8,7 @@ _BACKEND = Path(__file__).resolve().parents[1]
if str(_BACKEND) not in sys.path:
sys.path.insert(0, str(_BACKEND))
-from core.inference.model_ids import public_model_id # noqa: E402
+from core.inference.model_ids import model_id_matches, public_model_id # noqa: E402
def test_local_gguf_path_becomes_clean_stem():
@@ -51,3 +51,12 @@ def test_dotted_repo_id_not_mistaken_for_relative_path():
# A leading dot that is not ./ or ../ is an ordinary clean name.
assert public_model_id(".hidden-model") == ".hidden-model"
assert public_model_id("org/.config") == "org/.config"
+
+
+def test_matches_clean_and_legacy():
+ path = "/srv/models/Qwen3-Q4.gguf"
+ assert model_id_matches("Qwen3-Q4", path) # clean public id
+ assert model_id_matches(path, path) # legacy raw path
+ assert not model_id_matches("other", path)
+ assert not model_id_matches(None, path)
+ assert not model_id_matches("x", None)
diff --git a/studio/backend/tests/test_openai_catalog.py b/studio/backend/tests/test_openai_catalog.py
new file mode 100644
index 0000000000..f9baf20a66
--- /dev/null
+++ b/studio/backend/tests/test_openai_catalog.py
@@ -0,0 +1,181 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""GET /v1/models lists the full server catalog (loaded + locally available)."""
+
+import asyncio
+import json
+import sys
+from pathlib import Path
+
+_BACKEND = Path(__file__).resolve().parents[1]
+if str(_BACKEND) not in sys.path:
+ sys.path.insert(0, str(_BACKEND))
+
+import routes.inference as inf # noqa: E402
+
+
+class _Info:
+ def __init__(
+ self,
+ id,
+ display_name,
+ model_id = None,
+ ):
+ self.id = id
+ self.display_name = display_name
+ self.model_id = model_id
+
+
+class _FakeLlama:
+ is_loaded = True
+ model_identifier = "/srv/models/Qwen3-Q4.gguf"
+ context_length = 4096
+ max_context_length = None
+ native_context_length = None
+
+ def __init__(self, loaded = True):
+ self.is_loaded = loaded
+
+
+class _FakeUnsloth:
+ active_model_name = None
+ models: dict = {}
+ context_length = None
+ max_seq_length = None
+
+
+def test_catalog_lists_loaded_and_available(monkeypatch):
+ monkeypatch.setattr(inf, "get_llama_cpp_backend", lambda: _FakeLlama())
+ monkeypatch.setattr(inf, "get_inference_backend", lambda: _FakeUnsloth())
+
+ async def _fake_catalog():
+ return [
+ _Info("/data/models/Qwen3-Q4.gguf", "Qwen3-Q4"), # same as loaded -> dedup
+ _Info("/data/models/Llama-8B-Q8.gguf", "Llama-8B-Q8"), # available, not loaded
+ _Info("models--org--Foo", "Foo", model_id = "org/Foo"), # hf cache repo id
+ ]
+
+ monkeypatch.setattr(inf, "_cached_local_catalog", _fake_catalog)
+
+ data = asyncio.run(inf._openai_catalog_objects())
+ ids = {m["id"]: m for m in data}
+
+ # Loaded model is present, marked loaded, and keeps context fields.
+ assert ids["Qwen3-Q4"]["loaded"] is True
+ assert ids["Qwen3-Q4"]["context_length"] == 4096
+ # Available-but-not-loaded models are listed too.
+ assert ids["Llama-8B-Q8"]["loaded"] is False
+ assert ids["org/Foo"]["loaded"] is False
+ # The loaded gguf and the on-disk copy collapse to one clean id.
+ assert [m["id"] for m in data].count("Qwen3-Q4") == 1
+ # No absolute paths or .gguf suffixes leak anywhere.
+ blob = json.dumps(data)
+ assert ".gguf" not in blob
+ assert "/srv/" not in blob
+ assert "/data/" not in blob
+
+
+def test_empty_and_errored_scans_are_cached(monkeypatch):
+ # Cache validity is keyed on the timestamp, not list contents, so an empty
+ # (fresh install / no local models) or errored scan is still cached for the
+ # TTL instead of rescanning the filesystem on every /v1/models poll.
+ import routes.models as models_mod
+ for outcome in ("empty", "error"):
+ calls = {"n": 0}
+
+ def _scan(_root, _outcome = outcome):
+ calls["n"] += 1
+ if _outcome == "error":
+ raise RuntimeError("scan blew up")
+ return []
+
+ monkeypatch.setattr(models_mod, "collect_local_models", _scan)
+ monkeypatch.setattr(inf, "_CATALOG_CACHE", {"at": 0.0, "models": []})
+
+ async def _run():
+ return [await inf._cached_local_catalog() for _ in range(3)]
+
+ results = asyncio.run(_run())
+ assert results == [[], [], []], outcome
+ assert calls["n"] == 1, f"{outcome} scan ran {calls['n']}x (TTL not honored)"
+
+
+def test_catalog_ttl_starts_after_scan_completes(monkeypatch):
+ # The cache timestamp must be taken AFTER the scan, not before it. A scan that
+ # outlives the TTL would otherwise leave the cache born-expired, so the next
+ # caller rescans instead of reusing the just-computed catalog.
+ import routes.models as models_mod
+
+ clock = {"t": 1000.0}
+ monkeypatch.setattr(inf.time, "monotonic", lambda: clock["t"])
+ monkeypatch.setattr(inf, "_CATALOG_CACHE", {"at": 0.0, "models": []})
+
+ calls = {"n": 0}
+
+ def _slow_scan(_root):
+ calls["n"] += 1
+ clock["t"] += inf._CATALOG_TTL_S + 10 # the scan itself outlives the TTL
+ return [_Info("/m/A.gguf", "A")]
+
+ monkeypatch.setattr(models_mod, "collect_local_models", _slow_scan)
+
+ async def _run():
+ first = await inf._cached_local_catalog()
+ second = await inf._cached_local_catalog() # clock unchanged since scan end
+ return first, second
+
+ first, second = asyncio.run(_run())
+ assert [i.id for i in first] == ["/m/A.gguf"]
+ assert calls["n"] == 1, "TTL started before the scan -> cache born expired, rescanned"
+
+
+def test_retrieve_loaded_model_skips_catalog_scan(monkeypatch):
+ # Retrieving a loaded id must resolve from the loaded set alone, never paying
+ # for the filesystem scan that _cached_local_catalog drives.
+ monkeypatch.setattr(inf, "get_llama_cpp_backend", lambda: _FakeLlama())
+ monkeypatch.setattr(inf, "get_inference_backend", lambda: _FakeUnsloth())
+
+ async def _boom():
+ raise AssertionError("catalog scan must not run for a loaded id")
+
+ monkeypatch.setattr(inf, "_cached_local_catalog", _boom)
+
+ model = asyncio.run(inf.openai_retrieve_model("Qwen3-Q4", current_subject = "t"))
+ assert model["id"] == "Qwen3-Q4"
+ assert model["loaded"] is True
+
+
+def test_cached_local_catalog_offloads_and_caches(monkeypatch):
+ # The filesystem scan must run off the event loop (asyncio.to_thread) and be
+ # cached, so a burst of /v1/models calls does not re-scan or block.
+ calls = {"scan": 0, "threaded": 0}
+
+ def _fake_collect(_root):
+ calls["scan"] += 1
+ return [_Info("/data/models/A.gguf", "A")]
+
+ import routes.models as models_mod
+
+ monkeypatch.setattr(models_mod, "collect_local_models", _fake_collect)
+
+ real_to_thread = inf.asyncio.to_thread
+
+ async def _counting_to_thread(fn, *a, **k):
+ calls["threaded"] += 1
+ return await real_to_thread(fn, *a, **k)
+
+ monkeypatch.setattr(inf.asyncio, "to_thread", _counting_to_thread)
+ # Fresh cache for a deterministic count.
+ monkeypatch.setattr(inf, "_CATALOG_CACHE", {"at": 0.0, "models": []})
+
+ async def _run():
+ first = await inf._cached_local_catalog()
+ second = await inf._cached_local_catalog() # within TTL -> cached
+ return first, second
+
+ first, second = asyncio.run(_run())
+ assert [i.id for i in first] == ["/data/models/A.gguf"]
+ assert second is first or [i.id for i in second] == [i.id for i in first]
+ assert calls["scan"] == 1 # cached: scanned once for two calls
+ assert calls["threaded"] == 1 # offloaded to a worker thread
From 101de1927a60c2a40446ffb47648b092a8a64005 Mon Sep 17 00:00:00 2001
From: Daniel Han
Date: Fri, 26 Jun 2026 19:45:32 -0700
Subject: [PATCH 06/31] Silence torchao _C*.so load-failure WARNING on torch >=
2.11 (#6712)
On torch >= 2.11 torchao tries to dlopen each prebuilt _C*.so and logs a
per-file "Failed to load .../_C*.so" WARNING via the torchao logger when one
cannot load. This happens on an ABI tag mismatch in the prebuilt wheel (for
example a cp310 .so under a cp312 runtime, as on Colab) or when the kernel
targets an arch the GPU does not have (mxfp8 needs FP8 hardware, _C_cutlass_90a
is Hopper/SM90 only). torchao falls back to its non-cpp paths and Unsloth's
bnb-4bit / Triton kernels do not use these, so the warning is cosmetic.
Add a HideLoggingMessage filter on the same torchao logger that already filters
the torch < 2.11 "Skipping import of cpp extensions" message, so only these
records are dropped rather than raising the whole logger to ERROR.
---
unsloth/import_fixes.py | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/unsloth/import_fixes.py b/unsloth/import_fixes.py
index 5afd6f4b37..bff55b4e7b 100644
--- a/unsloth/import_fixes.py
+++ b/unsloth/import_fixes.py
@@ -158,6 +158,11 @@ if not UNSLOTH_ENABLE_LOGGING:
logging.getLogger("torchao").addFilter(
HideLoggingMessage("Skipping import of cpp extensions due to incompatible torch version")
)
+ # torch >= 2.11 path: torchao dlopens each prebuilt _C*.so and logs "Failed to load
+ # .../_C*.so" when one can't (ABI tag mismatch in the wheel, e.g. a cp310 .so under a
+ # cp312 runtime on Colab, or an arch-specific kernel the GPU lacks). It falls back to
+ # non-cpp paths and Unsloth doesn't use these kernels, so drop the cosmetic record.
+ logging.getLogger("torchao").addFilter(HideLoggingMessage("Failed to load "))
# SyntaxWarning: invalid escape sequence '\.'
warnings.filterwarnings("ignore", message = "invalid escape sequence", category = SyntaxWarning)
# PYTORCH_CUDA_ALLOC_CONF is deprecated warning from torch
From 1fcd69e662f233896e075fc03472f45ac60e8819 Mon Sep 17 00:00:00 2001
From: Daniel Han
Date: Fri, 26 Jun 2026 19:45:46 -0700
Subject: [PATCH 07/31] Harden flaky Studio CI: retry VS-hide rename and
tolerate same-URL nav interrupt (#6713)
Two intermittent Studio CI failures, both runner-environment flakes unrelated
to test logic:
Windows 'Studio install + inference without Visual Studio': the 'Hide Visual
Studio + CMake' step renames C:\Program Files\Microsoft Visual Studio to
simulate a host with no build tools. A background handle on a Program Files
directory (Defender scan or an MSBuild node) makes Rename-Item intermittently
fail with 'Access is denied', and $ErrorActionPreference = Stop turns that into
a hard job failure. Wrap the VS and cmake renames in both Hide steps in a short
Rename-WithRetry (6 tries, 3s apart) to ride out the transient lock.
macOS 'Chat UI Tests': the re-login goto to /login can be interrupted by the
SPA auth guard redirecting to the same /login URL, which Playwright reports as
'Navigation to .../login is interrupted by another navigation to .../login'.
The goto already tolerated ERR_ABORTED; broaden it to also tolerate the same-URL
interrupt (the password-field wait right after confirms we landed on /login),
and add the same signature to the two Playwright flake-retry harnesses as a
safety net for any other navigation.
Validated: playwright_chat_ui.py parses + byte-compiles, both workflow YAMLs
parse, bash -n on the retry harnesses, PowerShell AST parse on all pwsh steps,
and a functional check of Rename-WithRetry (succeeds, and rethrows after
exhausting retries).
---
.github/workflows/studio-mac-ui-smoke.yml | 29 ++++++++++---------
.../studio-windows-inference-smoke.yml | 22 ++++++++++++--
tests/studio/playwright_chat_ui.py | 12 ++++----
3 files changed, 42 insertions(+), 21 deletions(-)
diff --git a/.github/workflows/studio-mac-ui-smoke.yml b/.github/workflows/studio-mac-ui-smoke.yml
index 512af54d53..20ca247b9f 100644
--- a/.github/workflows/studio-mac-ui-smoke.yml
+++ b/.github/workflows/studio-mac-ui-smoke.yml
@@ -185,13 +185,14 @@ jobs:
# Retry up to 3 times to absorb known macos-14 free-runner
# flakes: (1) Playwright Node 24 pipeTransport.js 'Unexpected
# end of JSON input' crash when the Chromium browser process
- # dies mid-test, and (2) Chromium net::ERR_NO_BUFFER_SPACE
- # when the runner's kernel briefly runs out of socket buffers.
- # The retry FULLY resets Studio (kill, reset-password, reboot,
- # wait /api/health, re-export bootstrap pw) before re-running
- # the script. A real test failure (assertion / timeout) does
- # NOT match either pattern so it bypasses retry and surfaces
- # immediately.
+ # dies mid-test, (2) Chromium net::ERR_NO_BUFFER_SPACE when the
+ # runner's kernel briefly runs out of socket buffers, and (3) a
+ # goto 'interrupted by another navigation' when the SPA auth
+ # guard redirects mid-navigation. The retry FULLY resets Studio
+ # (kill, reset-password, reboot, wait /api/health, re-export
+ # bootstrap pw) before re-running the script. A real test failure
+ # (assertion / timeout) does NOT match any pattern so it bypasses
+ # retry and surfaces immediately.
run: |
mkdir -p logs/playwright
attempt=1
@@ -204,8 +205,9 @@ jobs:
if [ "$rc" -eq 0 ]; then
break
fi
- if { grep -q "Unexpected end of JSON input" logs/playwright_attempt_${attempt}.log \
- || grep -q "ERR_NO_BUFFER_SPACE" logs/playwright_attempt_${attempt}.log; } \
+ if { grep -q "Unexpected end of JSON input" logs/playwright_attempt_${attempt}.log \
+ || grep -q "ERR_NO_BUFFER_SPACE" logs/playwright_attempt_${attempt}.log \
+ || grep -q "interrupted by another navigation" logs/playwright_attempt_${attempt}.log; } \
&& [ "$attempt" -lt "$max_attempts" ]; then
echo "::warning::Playwright flake on attempt ${attempt}; resetting Studio and retrying..."
kill "${STUDIO_PID}" 2>/dev/null || true
@@ -280,8 +282,8 @@ jobs:
STUDIO_UI_TURN_TIMEOUT_MS: '540000'
GGUF_REPO: ${{ env.GGUF_REPO }}
GGUF_VARIANT: ${{ env.GGUF_VARIANT }}
- # Same flake-retry shape as "Drive the chat UI with Playwright"
- # -- catches pipeTransport JSON crash and ERR_NO_BUFFER_SPACE.
+ # Same flake-retry shape as "Drive the chat UI with Playwright" -- catches
+ # pipeTransport JSON crash, ERR_NO_BUFFER_SPACE, and nav interrupts.
run: |
mkdir -p logs/playwright_extra
attempt=1
@@ -294,8 +296,9 @@ jobs:
if [ "$rc" -eq 0 ]; then
break
fi
- if { grep -q "Unexpected end of JSON input" logs/playwright_extra_attempt_${attempt}.log \
- || grep -q "ERR_NO_BUFFER_SPACE" logs/playwright_extra_attempt_${attempt}.log; } \
+ if { grep -q "Unexpected end of JSON input" logs/playwright_extra_attempt_${attempt}.log \
+ || grep -q "ERR_NO_BUFFER_SPACE" logs/playwright_extra_attempt_${attempt}.log \
+ || grep -q "interrupted by another navigation" logs/playwright_extra_attempt_${attempt}.log; } \
&& [ "$attempt" -lt "$max_attempts" ]; then
echo "::warning::Playwright flake on attempt ${attempt}; resetting Studio and retrying..."
kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true
diff --git a/.github/workflows/studio-windows-inference-smoke.yml b/.github/workflows/studio-windows-inference-smoke.yml
index c44c68278d..08a0ee782d 100644
--- a/.github/workflows/studio-windows-inference-smoke.yml
+++ b/.github/workflows/studio-windows-inference-smoke.yml
@@ -1338,11 +1338,19 @@ jobs:
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
+ # A Program Files dir can hold a transient handle (Defender / MSBuild node)
+ # so Rename-Item intermittently fails with "Access is denied"; retry to ride it out.
+ function Rename-WithRetry($Path, $NewName) {
+ for ($i = 1; $i -le 6; $i++) {
+ try { Rename-Item -LiteralPath $Path -NewName $NewName -ErrorAction Stop; return }
+ catch { if ($i -eq 6) { throw }; Start-Sleep -Seconds 3 }
+ }
+ }
# Rename the Visual Studio install roots (incl. the Installer that holds
# vswhere.exe) so Find-VsBuildTools' vswhere + filesystem scan both miss.
foreach ($d in @("$env:ProgramFiles\Microsoft Visual Studio", "${env:ProgramFiles(x86)}\Microsoft Visual Studio")) {
if (Test-Path -LiteralPath $d) {
- Rename-Item -LiteralPath $d -NewName ((Split-Path $d -Leaf) + '.vsoff')
+ Rename-WithRetry $d ((Split-Path $d -Leaf) + '.vsoff')
Write-Host "Hid VS: $d"
}
}
@@ -1351,7 +1359,7 @@ jobs:
$hidden = @()
foreach ($c in (Get-Command cmake -All -ErrorAction SilentlyContinue)) {
if ($c.Source -and (Test-Path -LiteralPath $c.Source)) {
- Rename-Item -LiteralPath $c.Source -NewName ((Split-Path $c.Source -Leaf) + '.off')
+ Rename-WithRetry $c.Source ((Split-Path $c.Source -Leaf) + '.off')
$hidden += $c.Source
Write-Host "Hid cmake: $($c.Source)"
}
@@ -1536,8 +1544,16 @@ jobs:
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
+ # Retry the rename: a Program Files dir can hold a transient handle that
+ # makes Rename-Item intermittently fail with "Access is denied".
+ function Rename-WithRetry($Path, $NewName) {
+ for ($i = 1; $i -le 6; $i++) {
+ try { Rename-Item -LiteralPath $Path -NewName $NewName -ErrorAction Stop; return }
+ catch { if ($i -eq 6) { throw }; Start-Sleep -Seconds 3 }
+ }
+ }
foreach ($d in @("$env:ProgramFiles\Microsoft Visual Studio", "${env:ProgramFiles(x86)}\Microsoft Visual Studio")) {
- if (Test-Path -LiteralPath $d) { Rename-Item -LiteralPath $d -NewName ((Split-Path $d -Leaf) + '.vsoff'); Write-Host "Hid VS: $d" }
+ if (Test-Path -LiteralPath $d) { Rename-WithRetry $d ((Split-Path $d -Leaf) + '.vsoff'); Write-Host "Hid VS: $d" }
}
- name: Windows CUDA and ROCm prebuilts exist in unslothai/llama.cpp (what GPU users download, no VS)
diff --git a/tests/studio/playwright_chat_ui.py b/tests/studio/playwright_chat_ui.py
index a892d3414d..a53534acc0 100644
--- a/tests/studio/playwright_chat_ui.py
+++ b/tests/studio/playwright_chat_ui.py
@@ -1225,15 +1225,17 @@ with sync_playwright() as p:
# ─────────────────────────────────────────────────────
step("Shutdown via account menu")
# Re-login with NEW2 for a valid /api/shutdown token (CLI rotation
- # invalidated the old one). The stale token can make the SPA auth
- # guard abort this goto with ERR_ABORTED; resolve on
- # domcontentloaded and tolerate it -- the pw-field wait confirms /login.
+ # invalidated the old one). The stale token can make the SPA auth guard
+ # abort this goto with ERR_ABORTED, or redirect to the same /login URL
+ # ("interrupted by another navigation"); resolve on domcontentloaded and
+ # tolerate either -- the pw-field wait below confirms we are on /login.
+ _tolerated_nav = ("ERR_ABORTED", "interrupted by another navigation")
try:
page.goto(f"{BASE}/login", wait_until = "domcontentloaded", timeout = 60_000)
except Exception as exc:
- if "ERR_ABORTED" not in str(exc):
+ if not any(t in str(exc) for t in _tolerated_nav):
raise
- info(f"goto /login aborted ({exc!r}); password-field wait will confirm /login")
+ info(f"goto /login interrupted ({exc!r}); password-field wait will confirm /login")
pw_field = page.locator("#password")
pw_field.wait_for(state = "visible", timeout = 60_000)
pw_field.fill(NEW2)
From c8bcacc3fea27e97bea0ea09c9ad7554c729724f Mon Sep 17 00:00:00 2001
From: oobabooga
Date: Sat, 27 Jun 2026 02:43:36 -0300
Subject: [PATCH 08/31] Fix fast_inference crash on ABI-broken vLLM: probe
compiled extensions, not just import vllm (#6621)
* Fix fast_inference crash on ABI-broken vLLM: force-load compiled extensions in the broken-vLLM probe
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Broaden broken-vLLM probe: catch non-libcudart .so failures and _moe_C_stable_libtorch
* Revert stray reformat of the PDL fix log line
* Trim verbose comments in the broken-vLLM probe
* Drop non-existent vllm._moe_C_stable_libtorch from the broken-vLLM probe
* Shorten comments in broken vLLM extension detection
Condense the docstrings and inline comments for the lazy-loaded vLLM probe
and the new regression test while keeping the rationale. Comments only, no
code changes (verified with an AST signature check and the existing tests).
---------
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han
---
tests/test_vllm_broken_detection.py | 166 ++++++++++++++++++++++++++++
unsloth/_gpu_init.py | 19 ++--
unsloth/import_fixes.py | 27 ++++-
3 files changed, 197 insertions(+), 15 deletions(-)
create mode 100644 tests/test_vllm_broken_detection.py
diff --git a/tests/test_vllm_broken_detection.py b/tests/test_vllm_broken_detection.py
new file mode 100644
index 0000000000..ee89ddbedf
--- /dev/null
+++ b/tests/test_vllm_broken_detection.py
@@ -0,0 +1,166 @@
+# Unsloth - 2x faster, 60% less VRAM LLM training and finetuning
+# Copyright 2023-present Daniel Han-Chen, Michael Han-Chen & the Unsloth team. All rights reserved.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+
+"""Regression test for #6590: modern vLLM lazy-loads its compiled extensions, so
+a bare ``import vllm`` succeeds even when ``vllm._C`` (or a sibling) is ABI-broken
+and ``disable_broken_vllm`` missed it. GPU-free, via a synthetic vLLM."""
+
+from __future__ import annotations
+
+import contextlib
+import importlib.abc
+import importlib.machinery
+import importlib.util
+import sys
+import types
+
+import pytest
+
+
+_LIBCUDART_ERROR = "libcudart.so.13: cannot open shared object file: No such file or directory"
+
+
+class _ExtensionLoader(importlib.abc.Loader):
+ """A compiled extension that loads cleanly or fails on dlopen."""
+
+ def __init__(self, broken, error):
+ self.broken = broken
+ self.error = error
+
+ def create_module(self, spec):
+ return None
+
+ def exec_module(self, module):
+ if self.broken:
+ raise ImportError(self.error)
+
+
+class _FakeVllmFinder(importlib.abc.MetaPathFinder):
+ """Lazy vLLM: ``import vllm`` succeeds; each ``vllm._*`` ext is healthy,
+ ABI-broken, or absent, as real vLLM only loads ``_C`` & friends on use."""
+
+ def __init__(self, present, broken, error):
+ self.present = present
+ self.broken = broken
+ self.error = error
+
+ def find_spec(
+ self,
+ fullname,
+ path = None,
+ target = None,
+ ):
+ if fullname in self.present:
+ return importlib.machinery.ModuleSpec(
+ name = fullname,
+ loader = _ExtensionLoader(broken = fullname in self.broken, error = self.error),
+ is_package = False,
+ )
+ return None # absent -> ModuleNotFoundError, which the guard ignores
+
+
+@contextlib.contextmanager
+def _fake_vllm(
+ present,
+ broken,
+ error = _LIBCUDART_ERROR,
+):
+ """Install a synthetic lazy vLLM, restoring VLLM_BROKEN, find_spec,
+ meta_path, and the vllm* sys.modules entries on exit."""
+ from unsloth import import_fixes
+
+ submodules = import_fixes._VLLM_COMPILED_EXTENSIONS
+ saved_meta_path = list(sys.meta_path)
+ saved_find_spec = importlib.util.find_spec
+ saved_broken = import_fixes.VLLM_BROKEN
+ saved_modules = {n: sys.modules.get(n) for n in ("vllm", *submodules)}
+ try:
+ import_fixes.VLLM_BROKEN = False
+ fake_vllm = types.ModuleType("vllm")
+ fake_vllm.__path__ = []
+ fake_vllm.__spec__ = importlib.machinery.ModuleSpec("vllm", loader = None, is_package = True)
+ sys.modules["vllm"] = fake_vllm
+ for name in submodules:
+ sys.modules.pop(name, None)
+ sys.meta_path.insert(0, _FakeVllmFinder(present, broken, error))
+ yield import_fixes
+ finally:
+ import_fixes.VLLM_BROKEN = saved_broken
+ sys.meta_path[:] = saved_meta_path
+ importlib.util.find_spec = saved_find_spec
+ for name, module in saved_modules.items():
+ if module is None:
+ sys.modules.pop(name, None)
+ else:
+ sys.modules[name] = module
+
+
+@pytest.mark.parametrize(
+ "broken_ext",
+ ["vllm._C", "vllm._C_stable_libtorch"],
+ ids = ["core_C", "sibling_C_stable_libtorch"],
+)
+def test_disable_broken_vllm_detects_lazy_loaded_broken_extension(broken_ext):
+ # A CUDA-major mismatch breaks every ext; whichever one loads first must trip detection.
+ present = {"vllm._C", "vllm._C_stable_libtorch"}
+ with _fake_vllm(present = present, broken = {broken_ext}) as import_fixes:
+ detected = import_fixes.disable_broken_vllm()
+
+ assert detected is True, (
+ f"disable_broken_vllm missed an ABI-broken {broken_ext} behind a "
+ "lazily-importable vllm package — issue #6590 would resurface."
+ )
+ assert import_fixes.VLLM_BROKEN is True
+ # Once disabled, vLLM must look absent so callers fall back cleanly.
+ assert importlib.util.find_spec("vllm") is None
+
+
+@pytest.mark.parametrize(
+ "error",
+ [
+ "libnccl.so.2: cannot open shared object file: No such file or directory",
+ "libcuda.so.1: cannot open shared object file: No such file or directory",
+ ],
+ ids = ["libnccl", "libcuda"],
+)
+def test_disable_broken_vllm_detects_non_cudart_so_failure(error):
+ # A CUDA mismatch can surface through a non-libcudart .so (libnccl, libcuda),
+ # which the old libcudart/libcublas/libnvrtc allow-list let slip through.
+ with _fake_vllm(present = {"vllm._C"}, broken = {"vllm._C"}, error = error) as import_fixes:
+ detected = import_fixes.disable_broken_vllm()
+
+ assert detected is True, (
+ f"disable_broken_vllm missed a present-but-broken vllm._C raising "
+ f"{error!r} — vLLM would be left enabled and crash later."
+ )
+ assert import_fixes.VLLM_BROKEN is True
+
+
+@pytest.mark.parametrize(
+ "present",
+ [{"vllm._C"}, {"vllm._C", "vllm._C_stable_libtorch", "vllm._moe_C"}],
+ ids = ["core_only", "all_present"],
+)
+def test_disable_broken_vllm_keeps_healthy_vllm_enabled(present):
+ # Healthy install: an absent sibling (ModuleNotFoundError) or an extra present
+ # ext that loads cleanly must NOT be mistaken for an ABI break.
+ with _fake_vllm(present = present, broken = set()) as import_fixes:
+ detected = import_fixes.disable_broken_vllm()
+
+ assert detected is False
+ assert import_fixes.VLLM_BROKEN is False
+ assert importlib.util.find_spec("vllm") is not None
+
+
+if __name__ == "__main__":
+ raise SystemExit(pytest.main([__file__, "-v"]))
diff --git a/unsloth/_gpu_init.py b/unsloth/_gpu_init.py
index 917717e08e..7f080336aa 100644
--- a/unsloth/_gpu_init.py
+++ b/unsloth/_gpu_init.py
@@ -36,16 +36,15 @@ from .import_fixes import (
fix_huggingface_hub,
)
-# Redirect a read-only Hugging Face cache before anything below can import
-# huggingface_hub / transformers / vllm (disable_broken_vllm probes
-# `import vllm`, check_fbgemm_gpu_version imports transformers, and
-# fix_huggingface_hub imports huggingface_hub itself), all of which can
-# freeze Hub's cache constants with the un-redirected paths. unsloth_zoo
-# runs the same redirect at import, but that happens after these probes.
-# hf_cache.py is stdlib-only, so load it straight from its file without
-# triggering the full unsloth_zoo package init this early; the zoo's own
-# call later is an idempotent no-op. Older unsloth_zoo without hf_cache.py
-# is skipped silently.
+# Redirect a read-only Hugging Face cache before anything below imports
+# huggingface_hub / transformers / vllm (disable_broken_vllm probes `import vllm`
+# and its compiled extensions, check_fbgemm_gpu_version imports transformers,
+# fix_huggingface_hub imports huggingface_hub) -- any of which would freeze Hub's
+# cache constants with the un-redirected paths. unsloth_zoo runs the same redirect
+# at import, but only after these probes. hf_cache.py is stdlib-only, so load it
+# straight from its file without triggering the full unsloth_zoo init this early;
+# the zoo's later call is an idempotent no-op. Older unsloth_zoo without it is
+# skipped silently.
try:
import importlib.util as _importlib_util
from pathlib import Path as _Path
diff --git a/unsloth/import_fixes.py b/unsloth/import_fixes.py
index bff55b4e7b..d979e5f22f 100644
--- a/unsloth/import_fixes.py
+++ b/unsloth/import_fixes.py
@@ -2354,11 +2354,9 @@ def _is_broken_vllm_error(error) -> bool:
)
) or ("vllm" in message and "undefined symbol" in message):
return True
- # Also catch CUDA shared library mismatches during vllm import
- # e.g. "libcudart.so.12: cannot open shared object file"
- if (
- "libcudart" in message or "libcublas" in message or "libnvrtc" in message
- ) and "cannot open shared object file" in message:
+ # Forced extension load raises the bare loader error (no "vllm._C"
+ # wrapper); match any .so failure as callers feed only vLLM imports.
+ if "cannot open shared object file" in message:
return True
current = getattr(current, "__cause__", None) or getattr(current, "__context__", None)
return False
@@ -2550,6 +2548,16 @@ def _clear_vllm_modules():
sys.modules.pop(module_name, None)
+# vLLM's compiled extensions. A CUDA-major ABI break hits all of them, so
+# probing the eagerly-loaded _C and its siblings reliably trips it.
+_VLLM_COMPILED_EXTENSIONS = (
+ "vllm._C",
+ "vllm._C_stable_libtorch",
+ "vllm._moe_C",
+ "vllm._rocm_C",
+)
+
+
def disable_broken_vllm(error = None):
"""Disable vLLM dynamically when its shared library is ABI-broken."""
global VLLM_BROKEN
@@ -2567,6 +2575,15 @@ def disable_broken_vllm(error = None):
try:
import vllm # noqa: F401
+
+ # Lazy vLLM lets a bare `import vllm` succeed even when an extension
+ # is ABI-broken; force-load each to surface the .so failure here.
+ # A missing one raises ModuleNotFoundError (skipped below).
+ for _ext in _VLLM_COMPILED_EXTENSIONS:
+ try:
+ importlib.import_module(_ext)
+ except ModuleNotFoundError:
+ pass
return False
except Exception as import_error:
failure = import_error
From 98a01e70cd8a4fac4be61b0bb50aac1b95dfc648 Mon Sep 17 00:00:00 2001
From: Daniel Han
Date: Sat, 27 Jun 2026 01:52:18 -0700
Subject: [PATCH 09/31] Studio: restore tensor parallelism for vision/mmproj
GGUFs (#6659)
* Studio: restore tensor parallelism for vision/mmproj GGUFs
#6416 disabled --split-mode tensor for any GGUF that ships an mmproj projector to
dodge a GGML_ASSERT crash (#6415) seen on an older llama.cpp build with consumer
Blackwell (sm_120). The blanket skip silently dropped tensor_parallel=true for
every multimodal/MTP GGUF (e.g. Qwen3.6-35B-A3B-MTP); on hardware where the model
fits on one GPU the load then collapsed to a single GPU. mmproj + --split-mode
tensor works on current builds (verified end to end on B200/sm_100), so the skip
was disabling a working configuration.
Make the vision skip self-healing per binary:
- attempt tensor for vision models by default
- skip upfront only on a binary already seen to abort on tensor + mmproj this
session (_vision_tensor_split_aborts), recorded when such a launch crashes at
startup (_record_vision_tensor_split_abort). Process scoped, so a studio update
re-probes the new build. The route-level layer-split fallback stays the net.
- add _select_gpus(min_gpus=...) so a downgraded tensor request can keep multiple
GPUs instead of collapsing to one (default 1, no behavior change).
Add tests/test_tp_vision_regression.py: an AST allowlist guard over the
tensor_parallel drop sites (which would have flagged #6416), plus cache and
_select_gpus coverage. No GPU required.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: address review on vision tensor-parallel self-healing
Three fixes from the PR review:
- Record a vision-tensor abort only after every startup retry fails. The first
version cached the binary on the first spawn crash, which on every build
(including capable ones) is the benign --fit step abort that the existing
--fit off retry resolves. That poisoned the cache so the next vision load in
the same process skipped tensor. Recording now happens at the post-retry
failure block (after fit-off, flash-attn-off and MTP-drop), so a binary that
actually works is never cached.
- Gate the record on the tensor/mmproj crash signature: a hard signal fault
(_is_signal_crash) with no non-tensor cause (_output_has_nonprojector_diagnostic
excludes OOM and unknown-arch), so an OOM, bad extra args, or MTP/flash-attn
crash no longer marks an otherwise capable binary incompatible.
- Preserve the multi-GPU request on the cached downgrade. The vision gate now
raises _layer_min_gpus to the visible GPU count and threads it through the
layer-split GPU selection (_select_gpus min_gpus and the subset loops), so a
downgraded tensor request still spreads across GPUs instead of collapsing to a
single card the model happens to fit.
Verified two vision+tensor loads in one backend process both tensor-split across
4 GPUs (the benign fit abort no longer poisons the cache). Tests updated.
* Studio: harden vision tensor-parallel self-healing (review round 2)
Address the second review round on the vision/mmproj tensor-parallel fix:
- Preserve vision on the first load: a --split-mode tensor + --mmproj
GGML_ASSERT now raises so the route-level tensor->layer fallback retries
layer split with the projector intact, instead of stripping --mmproj and
silently loading text-only (which returned success and skipped the fallback,
losing vision on the first load until the next cached load).
- Symmetric multi-GPU preservation: the pooled-VRAM tensor downgrade now raises
_layer_min_gpus from the usable tensor GPUs like the vision downgrade, so it
no longer collapses a multi-GPU request to a single card.
- Base the layer fallback minimum on usable GPUs: _select_gpus caps min_gpus to
the count of cards with usable VRAM, so a downgrade never forces a nearly-full
card in (or trips --fit) just to hit the count.
- Re-probe after in-app updates: key the per-binary abort cache on (path, mtime)
like _capability_cache, so POST /api/llama/update swapping the binary in place
(no backend restart) re-probes the new build instead of inheriting the old
build's abort.
- Bump _layer_min_gpus for a known-bad vision binary independent of the tensor
drop, so the route fallback's layer retry (tensor already off) still spreads
across GPUs.
Adds deterministic non-GPU regression tests for each.
* Studio: gate cached-vision layer minimum on the current tensor request
The cached-vision _layer_min_gpus bump fired for every later vision load on a
binary recorded as tensor+mmproj-incompatible, including loads that did not
request tensor parallelism. A plain non-tensor vision load that fits on one card
would then grab every GPU just because an earlier TP attempt aborted in the same
backend process.
Re-tie the bump to the current tensor request (back inside the tensor-drop
guard), so only a downgraded tensor request preserves the multi-GPU spread; a
non-tensor vision load minimizes device count as before.
* Studio: preserve GPU count + confirm assert on vision tensor fallback
Third review round on the vision/mmproj tensor-parallel fix:
- Preserve multi-GPU on the first tensor->layer fallback. The route-level retry
runs tensor-off, so the in-function downgrades can't see the original tensor
request and a fits-on-one-card model loaded the first successful fallback on a
single GPU. The GGUF load closure now passes preserve_multi_gpu_on_layer (the
toggle asked for tensor, this attempt is layer) and load_model raises
_layer_min_gpus for it, so the downgrade still spreads across GPUs.
- Cap the auto-context layer loops to usable GPUs. They bypass _select_gpus, so a
raised _layer_min_gpus could force a nearly-full card into the subset (or trip
--fit). They now start from _auto_min_gpus, capped to the GPUs with usable VRAM.
- Confirm the tensor/mmproj assert before caching. Recording (and the layer-retry
raise) now require the ggml assert marker via _is_tensor_split_assert, not the
bare-signal predicate shared with the projector-incompat branch, so a corrupt
or too-new projector that SIGSEGVs independent of split mode is no longer cached
as tensor/mmproj-incompatible.
Adds deterministic non-GPU regression tests for each.
* Studio: extend multi-GPU fallback to extra/env tensor + overhead-aware cap
Fourth review round on the vision/mmproj tensor-parallel fix:
- Preserve multi-GPU fallback for all tensor requests, not just the UI toggle.
Tensor can also be requested via --split-mode tensor in extra args or an
inherited LLAMA_ARG_SPLIT_MODE=tensor env; the fallback retries those too, so
the preserve_multi_gpu_on_layer hint now keys off _effective_tensor_parallel
(the same check the fallback uses), comparing the overall request against the
current attempt instead of only request.tensor_parallel.
- Cap the auto-context layer fallback to GPUs that can pay the per-device layer
overhead. The cap counted any card with positive usable VRAM, so a nearly-full
GPU with a few MiB free stayed eligible and could be exposed to llama.cpp and
OOM. It now mirrors _select_gpus: a card counts only if usable VRAM exceeds the
per-device pipeline overhead.
Adds deterministic non-GPU regression tests for both.
* Studio: match the #6415 split-axis assert + replay layer-preserve hint
Fifth review round on the vision/mmproj tensor-parallel fix:
- Narrow the tensor/mmproj crash signature. _is_tensor_split_assert matched any
GGML_ASSERT/GGML_ABORT, so an unrelated invariant a corrupt GGUF or projector
trips with --mmproj present could be cached as tensor/mmproj-incompatible. It
now matches the specific #6415 warmup assertion
(GGML_ASSERT(src_ss[0].axis != GGML_BACKEND_SPLIT_AXIS_0) in ggml-backend-meta),
whose split-axis signature is inherent to tensor splitting. A reworded future
assert just re-crashes-then-falls-back (vision preserved via layer split)
instead of poisoning the cache for other models.
- Persist the layer-preserve hint for respawns. A successful tensor->layer
fallback committed _last_load_kwargs without preserve_multi_gpu_on_layer, so
_respawn_if_dead replayed only --split-mode layer + tensor_parallel=False and a
mid-session respawn of a fits-on-one-card model came back single-GPU. The hint
is now in the replay snapshot, so recovery keeps the multi-GPU placement.
Adds deterministic non-GPU regression tests for both.
* Studio: tighten comments on the vision tensor-parallel fix
Make the comments and docstrings added by this PR succinct: collapse the
multi-line block comments in llama_cpp.py / inference.py to one or two lines,
trim the verbose test docstrings (the names and assert messages already carry the
intent), and shorten the module docstring. No code changes; verified comment-only
with scripts/comment_tools.py check --strip-docstrings.
* Studio: cache vision tensor abort only on the split-axis token
_is_tensor_split_assert also accepted any GGML_ASSERT/GGML_ABORT from
ggml-backend-meta, but that file holds many asserts, so an unrelated
scheduler/projector/model invariant on an --mmproj launch could cache the binary
as tensor/mmproj-incompatible and make later compatible vision models skip tensor
parallelism. Match the GGML_BACKEND_SPLIT_AXIS_* token itself (unique to the
#6415 warmup assert), not the source file name.
* Studio: don't leak the httpx test stub into later tests
The regression module stubbed httpx via sys.modules.setdefault, which installs
the lightweight stub even when real httpx is present but not yet imported. The
stub then persists for the whole pytest process, so provider/HF tests collected
later (importing httpx or huggingface_hub.errors) got a module missing
HTTPError/Response. Mirror the neighboring llama_cpp helper tests: import real
httpx first and only fall back to a stub on ImportError.
* Studio: latch the #6415 tensor-split abort on the first spawn, key it per model
The self-heal recorded the --split-mode tensor abort only in the post-retry
failure block, after the flash-attn-off retry. But SPLIT_MODE_TENSOR requires
flash_attn, so the flash-off retry can't run tensor and its output no longer
carries the warmup split-axis assert (ggml-backend-meta :541). The record
therefore never fired on the real reproducer and the crash loop repeated on
every load (reported by oobabooga on #6659).
Latch instead on the first spawn that shows the signal crash + split-axis
marker: record it, kill the process, and raise straight to the route's layer
fallback, skipping the futile flash-attn/MTP retry ladder for this crash.
The crash is a tensor-split geometry limit (e.g. MQA n_head_kv=1 splitting to
GGML_BACKEND_SPLIT_AXIS_0), not a vision/mmproj property: it reproduces without
--mmproj and even single-GPU tensor. So drop the vision/mmproj scoping, rename
_vision_tensor_* -> _tensor_split_*, and key the session cache on
(binary, mtime, model) rather than (binary, mtime) so one model's abort no
longer skips tensor for every other model on the same build.
Regression tests updated to pin the early-spawn record, the per-model cache,
and that an unrelated ggml-backend-meta assert is not treated as the marker.
* Studio: reload on explicit tensor-off after a multi-GPU layer fallback
When a tensor load is downgraded to layer but kept multi-GPU to honor the
tensor request (preserve_multi_gpu_on_layer, the geometry-cache gate, or the
budget downgrade), the server reports tensor_parallel=False with --split-mode
layer stored. A later Apply that explicitly turns the tensor toggle off then
matched the loaded state and deduped to already_loaded, so Studio kept the
fallback's all-GPU CUDA_VISIBLE_DEVICES placement instead of re-selecting
normal placement (a single GPU for a model that fits on one card).
Latch a _layer_preserves_tensor_intent flag in load_model whenever a tensor
request is downgraded to layer with the multi-GPU floor raised
(_layer_min_gpus > 1), clear it when tensor stays on or on unload, and force a
reload in _request_matches_loaded_settings when the user explicitly turns the
tensor toggle off while that flag is set. An Apply that does not touch the
toggle still dedupes, so a working multi-GPU layer server is not churned.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: address reviewer.py findings on the tensor-split self-heal
P1 (dedup): tensor intent can be dropped via extras, not only the toggle. An
explicit llama_extra_args=["--split-mode", "layer"] matches the stored fallback
extras, so _request_matches_loaded_settings deduped to the preserved all-GPU
placement instead of reloading. Now reload when layer_preserves_tensor_intent
and the user explicitly drops tensor via the toggle OR via extras
(_effective_tensor_parallel of the explicit extras is false).
P1 (downgrade symmetry): the len(tp_gpus) < 2 compute-buffer downgrade cleared
tensor_parallel without raising _layer_min_gpus, unlike the budget and geometry
downgrades. GPUs below tensor's replicated compute-buffer reserve can still take
layer split's lower overhead, so keep the multi-GPU request (len(gpus) >= 2) and
let _select_gpus cap unusable cards.
P2 (cache key): key the tensor-split abort cache on st_mtime_ns, so a binary
replaced in place within the same second after an abort is re-probed instead of
inheriting the stale entry.
P2 (test hygiene): load routes/inference.py via importlib in the regression
tests instead of importing the routes package, which runs routes/__init__.py and
pulls in every router (e.g. python-multipart). Added regression coverage for the
extras-off reload, the compute-buffer multi-GPU preservation, and the same-second
nanosecond cache invalidation.
* Studio: record the tensor-split abort on the Windows CRT abort exit too
The first-spawn split-axis latch only recorded when _is_signal_crash matched
(POSIX signal or 0xC0000000+ NTSTATUS). On MSVC builds GGML_ASSERT terminates
through the CRT abort() path with exit code 3, which is neither, so the cache
never filled on Windows and every later load of the same bad binary/model
repeated the tensor crash before falling back to layer.
The split-axis marker is definitive, so accept either a signal crash or the
Windows abort() exit (3) when the marker is present. Add _is_abort_exit and a
unit test, and assert the early latch honors it.
* Studio: fix UnboundLocalError on --fit-on fallback, reload backend fast path
Two follow-ups from review on the tensor-split self-heal:
UnboundLocalError: _layer_min_gpus was initialized inside the GPU-selection try.
If NVML probing or GGUF/mmproj sizing raised, the except path logged "using
--fit on" and fell through to the command builder, where the new
self._layer_preserves_tensor_intent = _layer_min_gpus > 1 then raised, turning a
safe --fit-on layer fallback into a hard load failure. Bind _layer_min_gpus
before the try so the except path always has it.
Backend fast path: _request_matches_loaded_settings forces a reload when a
preserved tensor->layer fallback gets an explicit tensor-off request, but
load_model's own _already_in_target_state still matched the tensor-off/layer
settings and short-circuited, so the placement re-selection never ran. Mirror
the guard there: reload when layer_preserves_tensor_intent and the request drops
tensor intent. The flag clears on that reload, so there's no loop.
Added regression coverage for both.
* Studio: testable tensor-split record decision; skip futile fit-off retry
Follow-ups from a deeper review of the tensor-split self-heal:
Extract the record decision into _should_record_tensor_split_abort(rc, output)
(marker AND (signal crash OR Windows abort)) and call it from the early latch.
The combined boolean was only covered by source-inspection substring checks, so
an or->and typo would silently stop recording on Windows (CRT abort exit 3 is
not a signal) with every test still green. Add a behavioral test over the
POSIX / Windows / NTSTATUS / clean-exit / SIGKILL / no-marker matrix.
Skip the --fit off retry inside _spawn_and_wait when the crash already shows the
split-axis marker: that abort is fit-independent, so the retry just warms up and
crashes a second time before the latch records it. Skipping it lets the caller
latch immediately and corrects the latch comment.
Also clarify the dedup-guard comments (toggle read from model_fields_set vs
extras via _effective_tensor_parallel without env; the backend fast path is
intentionally broader and only ever forces a reload).
* Studio: don't reload-loop tensor-off requests under env tensor
The preserved-fallback reload guard fired on the raw tensor toggle, ignoring
LLAMA_ARG_SPLIT_MODE=tensor. For an env-driven tensor user, an explicit
tensor_parallel=false request then forced a reload that re-engaged tensor via
the env and re-created the same preserved layer fallback, so every /load
reloaded -- bypassing the env-downgrade matching that exists to avoid exactly
this loop.
Gate the guard on the env-aware effective tensor state: reload only when an
explicit toggle/extras change leaves _effective_tensor_parallel (which consults
the env) off. If the env still forces tensor, fall through to the existing
env-downgrade match, which dedupes instead of looping. Added a regression test
with LLAMA_ARG_SPLIT_MODE=tensor set.
* Studio: tighten comments and test docstrings on the TP self-heal
Condense the verbose comments and test docstrings added across the review rounds
into fewer, succinct lines without changing their intent: the early-latch and
downgrade-site rationale, the cache/key and helper docstrings, the dedup-guard
comments, and the per-test docstrings. No code changes (AST-verified comments
and docstrings only); tests and lint unchanged.
* Studio: clear preserved tensor flag on diffusion; carry it across non-drop reloads
Two follow-ups on the preserved-fallback machinery:
Diffusion: the DiffusionGemma path early-returns from load_model before the
command builder that sets/clears _layer_preserves_tensor_intent, so the flag
from a prior tensor->layer fallback leaked onto a later diffusion load and
forced needless reloads of the diffusion server on tensor-off/extra Applies.
Clear it when starting diffusion.
Settings reload: the preserve hint was recomputed only from the new request, so
a reload for an unrelated setting (e.g. max_seq_length) with the tensor toggle
omitted dropped a preserved multi-GPU layer placement back to one GPU. Carry
llama_backend.layer_preserves_tensor_intent into the hint when the request is
not an explicit tensor-off/extras-off drop, so a fitting model stays multi-GPU.
Added regression tests for the diffusion clear, the carry-forward, and the
updated tensor-intent computation.
* Studio: gate the preserve carry-forward on the same model being loaded
The tensor-intent carry-forward read llama_backend.layer_preserves_tensor_intent
without checking it belonged to the model being loaded. On a direct model switch
(load B without an explicit /unload of A), the flag is still set from A's
downgrade (it isn't reset until B's load_model reaches the command builder, after
the route reads it), so a plain load of B got preserve_multi_gpu_on_layer=True
and was spread across all GPUs even though it fits on one and the user never
requested tensor for it. The backend dedup doesn't have this leak (it checks
model_identifier first); the leak was only in the route hint.
Extract the decision into _carry_preserved_tensor_intent(preserved, same_model,
explicit_drop) and gate it on the backend still holding the same model. Add a
behavioral truth-table test (catches a `not` inversion and a missing same-model
guard) and tighten the compute-buffer downgrade test to bound its source window.
* Studio: match the HF quant too when carrying preserved tensor intent
The same-model guard on the preserve carry-forward compared only model_identifier,
which is variant-agnostic for HF repos. A later load of the same repo with a
different gguf_variant (which already bypassed dedupe on the variant mismatch)
was treated as the same model, so a request that omits tensor settings inherited
the prior variant's preserved intent and forced multi-GPU layer placement for a
quant that never requested tensor. Also require the loaded hf_variant to match for
HF repos (local direct-file loads already differ by model_identifier path). Added
a regression test for the variant guard.
* Studio: match the loaded GGUF by path too when carrying preserved tensor intent
A local directory holding multiple GGUF variants keeps one variant-agnostic
model_identifier (the directory) while config.gguf_file selects the file, so the
same-model guard let variant B inherit variant A's preserved tensor->layer
fallback and forced B onto multi-GPU. Mirror _already_in_target_state's identity
logic: match by resolved path when both sides have a local file, else by HF
variant. #6659
* Studio: let implicit same-settings reloads dedupe after a preserved fallback
The backend _already_in_target_state mirror forced a reload on ANY effective
tensor-off request once a tensor->layer fallback was preserved. In the HF
auto-pick / local-directory flows the route-level dedup is skipped, so an
identical /load with tensor omitted reached this guard and reloaded every time
even without an explicit drop. Thread the route's preserve_multi_gpu_on_layer
decision in so only an explicit drop reloads; implicit carry-forward dedupes. #6659
* Studio: only an explicit tensor/split-mode change drops preserved intent
The explicit-drop test treated request.llama_extra_args is not None as a drop,
so a same-model reload that merely added an unrelated pass-through arg (e.g.
--top-k 20) without touching the tensor field or --split-mode disabled the
carry-forward and collapsed a fitting model back to one GPU. A drop now requires
an explicit tensor_parallel field change or a non-tensor --split-mode override,
via a shared _is_explicit_tensor_drop helper used by both the already-loaded
dedup and the load carry-forward so the two readers agree. #6659
* Studio: treat an explicit clear of extras as a tensor drop
When tensor intent was extras-driven (--split-mode tensor) and fell back to a
preserved layer split, a later request that explicitly clears extras
(llama_extra_args=[]) but omits tensor_parallel left the empty list with no
split-mode override, so the carry-forward kept the model pinned multi-GPU instead
of returning to normal layer selection. _is_explicit_tensor_drop now also counts
an explicit empty-list clear as a drop, while an unrelated extra (--top-k) or
inherit (None) still carries the preserved intent. #6659
* Studio: don't treat the UI's tensor_parallel echo as a tensor drop
The Studio frontend always sends tensor_parallel and copies the /load response's
resolved value back into its state, so after a tensor->layer fallback every
ctx/settings reload carries tensor_parallel=false even though the user never
changed it. Keying the drop on the field (or on an empty extras clear) collapsed
the preserved multi-GPU placement on the next reload. A fallback also always
stores --split-mode layer, never a tensor split mode, so a clear never wipes
tensor intent. _is_explicit_tensor_drop now drops only on an explicit non-tensor
--split-mode override; the bare field echo, an empty clear, an unrelated extra,
and inherit all keep the preserved placement, and --split-mode tensor /
tensor_parallel=true re-engage tensor. #6659
* Studio: match the resolved config.identifier when carrying tensor intent
The same-model guard for the carry-forward compared the raw request id, but
ModelConfig.from_identifier normalizes it (adds the unsloth/ prefix for a
shorthand, fixes repo-id case) before load_model stores config.identifier. So a
ctx/settings reload using the shorthand id missed the match, dropped
_carry_preserved_tensor_intent, and could collapse a preserved multi-GPU layer
placement to one GPU. Compare against config.identifier (what the backend stores),
keeping it symmetric with _already_in_target_state. #6659
---------
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
---
studio/backend/core/inference/llama_cpp.py | 203 ++++-
studio/backend/routes/inference.py | 85 ++
.../tests/test_tp_vision_regression.py | 805 ++++++++++++++++++
3 files changed, 1073 insertions(+), 20 deletions(-)
create mode 100644 studio/backend/tests/test_tp_vision_regression.py
diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py
index 31969afb14..8ec11fa79f 100644
--- a/studio/backend/core/inference/llama_cpp.py
+++ b/studio/backend/core/inference/llama_cpp.py
@@ -1271,6 +1271,9 @@ class LlamaCppBackend:
self._cache_type_kv: Optional[str] = None
# Whether --split-mode tensor was applied on the active load.
self._tensor_parallel: bool = False
+ # Layer load kept multi-GPU only to honor a downgraded tensor request, so a
+ # later explicit tensor-off reloads instead of deduping to it (#6659).
+ self._layer_preserves_tensor_intent: bool = False
self._reasoning_default: bool = True
self._speculative_type: Optional[str] = None
# Canonical UI-facing mode the user requested
@@ -1643,6 +1646,11 @@ class LlamaCppBackend:
"""Whether --split-mode tensor is active on the loaded server."""
return self._tensor_parallel
+ @property
+ def layer_preserves_tensor_intent(self) -> bool:
+ """True when a downgraded tensor request kept this layer load multi-GPU."""
+ return self._layer_preserves_tensor_intent
+
@property
def speculative_type(self) -> Optional[str]:
return self._speculative_type
@@ -2430,6 +2438,37 @@ class LlamaCppBackend:
# aborts a --split-mode tensor load, so it's dropped for the tensor attempt.
_TENSOR_PARALLEL_KV_TYPES = frozenset({"f16", "bf16", "f32"})
+ # (binary, mtime, model) that aborted on --split-mode tensor this process (#6415
+ # geometry limit, e.g. MQA n_head_kv=1). Model-keyed so one model's abort doesn't
+ # skip tensor for others; tensor is tried by default, recorded only on a real abort.
+ _tensor_split_abort_keys: set[tuple[str, int, str]] = set()
+
+ @classmethod
+ def _tensor_split_cache_key(
+ cls, binary: Optional[str], model: Optional[str]
+ ) -> Optional[tuple[str, int, str]]:
+ """(path, mtime_ns, model) key; ns mtime re-probes a same-second binary swap."""
+ if not binary or not model:
+ return None
+ try:
+ mtime = Path(binary).stat().st_mtime_ns
+ except OSError:
+ mtime = 0
+ return (binary, mtime, model)
+
+ @classmethod
+ def _tensor_split_aborts(cls, binary: Optional[str], model: Optional[str]) -> bool:
+ """True if (binary, model) aborted on --split-mode tensor this session."""
+ key = cls._tensor_split_cache_key(binary, model)
+ return key is not None and key in cls._tensor_split_abort_keys
+
+ @classmethod
+ def _record_tensor_split_abort(cls, binary: Optional[str], model: Optional[str]) -> None:
+ """Remember a (binary, model) that aborts on --split-mode tensor."""
+ key = cls._tensor_split_cache_key(binary, model)
+ if key is not None:
+ cls._tensor_split_abort_keys.add(key)
+
@staticmethod
def _windows_pip_nvidia_dll_dirs(prefix: str) -> list[str]:
"""Return DLL dirs from pip-installed CUDA wheels under
@@ -2569,9 +2608,13 @@ class LlamaCppBackend:
usable_fraction: Optional[float] = None,
total_by_idx: Optional[dict[int, int]] = None,
per_device_overhead_bytes: int = 0,
+ min_gpus: int = 1,
) -> tuple[Optional[list[int]], bool]:
"""Pick GPU(s) for a model from estimated VRAM and free memory.
+ ``min_gpus`` (default 1, capped at ``len(gpus)``) keeps a downgraded
+ tensor/multi-GPU request spread instead of collapsing to one card.
+
``model_size_bytes`` should include weights and estimated KV cache.
``usable_fraction`` (default ``_GPU_PIN_VRAM_FRACTION``) provides
headroom for compute buffers, CUDA context, and other runtime
@@ -2590,9 +2633,11 @@ class LlamaCppBackend:
if not gpus:
return None, True
+ min_gpus = max(1, min(min_gpus, len(gpus)))
model_size_mib = model_size_bytes / (1024 * 1024)
if usable_fraction is None:
usable_fraction = LlamaCppBackend._GPU_PIN_VRAM_FRACTION
+ overhead_mib = per_device_overhead_bytes / (1024 * 1024)
# Per-GPU usable budget: free - (1-frac)*total when total is known, else
# the legacy free*frac (also covers a total-0 two-column probe).
@@ -2606,19 +2651,26 @@ class LlamaCppBackend:
# card can have less usable room than a less-used small one.
ranked = sorted(gpus, key = lambda g: _usable(g[0], g[1]), reverse = True)
- # Try 1 GPU at the usable-VRAM threshold.
- if _usable(ranked[0][0], ranked[0][1]) >= model_size_mib:
+ # Cap a downgraded multi-GPU request to the usable count so it doesn't pull
+ # in a near-full card to hit min_gpus. No-op for the default min_gpus == 1.
+ usable_count = sum(1 for idx, free_mib in ranked if _usable(idx, free_mib) > overhead_mib)
+ min_gpus = max(1, min(min_gpus, usable_count or 1))
+
+ # Try 1 GPU at the usable-VRAM threshold (only when one device is allowed).
+ if min_gpus <= 1 and _usable(ranked[0][0], ranked[0][1]) >= model_size_mib:
return [ranked[0][0]], False
- # Try N GPUs (accumulate usable memory from most-free). Each GPU past the
- # first adds a fixed per-device overhead the pool must hold.
- overhead_mib = per_device_overhead_bytes / (1024 * 1024)
+ # Try N GPUs (most-free first); each past the first adds per-device overhead.
+ # Require at least min_gpus devices before accepting a fit.
cumulative = 0.0
selected = []
for idx, free_mib in ranked:
selected.append(idx)
cumulative += _usable(idx, free_mib)
- if cumulative >= model_size_mib + (len(selected) - 1) * overhead_mib:
+ if (
+ len(selected) >= min_gpus
+ and cumulative >= model_size_mib + (len(selected) - 1) * overhead_mib
+ ):
return sorted(selected), False
# Too large even for all GPUs; let --fit handle it
@@ -3868,7 +3920,7 @@ class LlamaCppBackend:
logger.debug(f"Could not list repo files for {label}: {e}")
break
logger.debug(
- f"Could not list repo files for {label} " f"(attempt {attempt + 1}/3): {e}"
+ f"Could not list repo files for {label} (attempt {attempt + 1}/3): {e}"
)
if attempt < 2:
self._cancel_event.wait(2**attempt)
@@ -4332,6 +4384,17 @@ class LlamaCppBackend:
)
)
+ @staticmethod
+ def _is_tensor_split_assert(output: str) -> bool:
+ """True only for the #6415 split-axis warmup assert (GGML_BACKEND_SPLIT_AXIS_*),
+ not any ggml assert/abort, so an unrelated invariant isn't cached. stderr is
+ merged into output."""
+ text = (output or "").lower()
+ if "ggml_assert" not in text and "ggml_abort" not in text:
+ return False
+ # the split-axis enum token, unique to this assert (not the source file).
+ return "split_axis" in text
+
@staticmethod
def _is_signal_crash(returncode: Optional[int]) -> bool:
"""True only on a hard fault (SIGSEGV/SIGABRT/SIGILL/SIGFPE/SIGBUS or a
@@ -4344,6 +4407,20 @@ class LlamaCppBackend:
return True
return -returncode in (4, 6, 7, 8, 11) # SIGILL SIGABRT SIGBUS SIGFPE SIGSEGV
+ @staticmethod
+ def _is_abort_exit(returncode: Optional[int]) -> bool:
+ """Windows CRT abort() exit code (3) from GGML_ASSERT on MSVC -- not a POSIX
+ signal or 0xC0000000+ NTSTATUS."""
+ return returncode == 3
+
+ @classmethod
+ def _should_record_tensor_split_abort(cls, returncode: Optional[int], output: str) -> bool:
+ """The #6415 split-axis abort: the marker plus a hard crash (POSIX signal or
+ Windows abort exit). Marker required so a generic crash isn't cached."""
+ return cls._is_tensor_split_assert(output) and (
+ cls._is_signal_crash(returncode) or cls._is_abort_exit(returncode)
+ )
+
@staticmethod
def _with_flash_attn_off(cmd: list[str]) -> Optional[list[str]]:
"""Return cmd with flash attention forced off, or None when its effective
@@ -4488,6 +4565,8 @@ class LlamaCppBackend:
n_gpu_layers: Optional[int] = None, # caller compat, unused
n_parallel: int = 1,
extra_args: Optional[List[str]] = None,
+ # Route-level tensor->layer fallback retry: keep the layer split multi-GPU.
+ preserve_multi_gpu_on_layer: bool = False,
) -> bool:
"""Start llama-server with a GGUF model.
@@ -4518,6 +4597,8 @@ class LlamaCppBackend:
"n_gpu_layers": n_gpu_layers,
"n_parallel": n_parallel,
"extra_args": list(extra_args) if extra_args is not None else None,
+ # Replayed by _respawn_if_dead so a downgraded model stays multi-GPU.
+ "preserve_multi_gpu_on_layer": preserve_multi_gpu_on_layer,
}
# Serialise the whole load so concurrent /load calls never leave two
# llama-server processes alive (#5401 / #5161). Doesn't block /unload.
@@ -4541,6 +4622,7 @@ class LlamaCppBackend:
chat_template_override = chat_template_override,
extra_args = extra_args,
is_vision = is_vision,
+ preserve_multi_gpu_on_layer = preserve_multi_gpu_on_layer,
):
logger.info(
f"load_model: backend already in target state for "
@@ -4626,6 +4708,9 @@ class LlamaCppBackend:
# Block-diffusion GGUFs (DiffusionGemma) cannot run on llama-server;
# serve them with the diffusion runner (same OpenAI-compat interface).
if self._is_diffusion:
+ # Not a tensor/layer GGUF: clear any preserved-fallback flag from a
+ # prior load (this path skips the command builder that clears it).
+ self._layer_preserves_tensor_intent = False
with self._lock:
if self._cancel_event.is_set():
logger.info("Load cancelled before diffusion server start")
@@ -4780,6 +4865,9 @@ class LlamaCppBackend:
"image input will be disabled for this session"
)
model_size = None # set in the fit try; used by the APU RAM guard
+ # Layer-fallback min GPUs; raised below on a tensor downgrade. Bound
+ # before the try so the --fit-on except path still has it (no UnboundLocal).
+ _layer_min_gpus = 1
try:
gguf_size = self._get_gguf_size_bytes(model_path)
# Include GPU-loaded mmproj in the fit budget (#5825).
@@ -5064,10 +5152,8 @@ class LlamaCppBackend:
_apple_budget_mib = self._apple_metal_memory_budget_bytes() // (1024 * 1024)
def _restore_after_tensor_downgrade():
- # Tensor mode dropped a quantized KV and stripped the cache
- # extras (it rejects quantized); layer split supports them, so
- # restore the original type + extras (minus --split-mode) and
- # clear the env flag so the layer launch re-emits them.
+ # Restore the quantized KV + extras tensor dropped (layer
+ # split supports them), minus --split-mode.
nonlocal cache_type_kv, _cache_type_from_env, extra_args
if _tensor_dropped_cache_type_kv is not None:
cache_type_kv = _tensor_dropped_cache_type_kv
@@ -5078,13 +5164,22 @@ class LlamaCppBackend:
else extra_args
)
- if tensor_parallel and effective_is_vision:
+ # The route fallback retry is tensor-off; keep it multi-GPU.
+ if preserve_multi_gpu_on_layer:
+ _layer_min_gpus = max(_layer_min_gpus, len(gpus))
+
+ if tensor_parallel and self._tensor_split_aborts(binary, model_identifier):
+ # Aborted on tensor for this model this session (#6415); skip
+ # tensor upfront, layer split serves it.
logger.info(
- "Tensor parallelism skipped for vision model: "
- "--split-mode tensor is incompatible with --mmproj "
- "in the current llama.cpp build; using layer split."
+ "Tensor parallelism skipped: this llama.cpp build aborted "
+ "on --split-mode tensor for this model earlier this "
+ "session; using layer split across %d GPU(s).",
+ len(gpus),
)
tensor_parallel = False
+ # Keep the multi-GPU request (gated on it, not the cache).
+ _layer_min_gpus = max(_layer_min_gpus, len(gpus))
_restore_after_tensor_downgrade()
# Tensor mode replicates a compute buffer on every GPU, so drop
@@ -5124,6 +5219,11 @@ class LlamaCppBackend:
len(gpus),
)
tensor_parallel = False
+ # GPUs below tensor's compute-buffer reserve can still do layer
+ # split, so keep multi-GPU (mirrors the budget/geometry drops);
+ # _select_gpus caps unusable cards.
+ if len(gpus) >= 2:
+ _layer_min_gpus = max(_layer_min_gpus, len(gpus))
# Layer split supports a quantized KV the tensor attempt
# dropped; restore the original cache type + extras (minus
# --split-mode) so the layer launch re-emits them.
@@ -5160,8 +5260,12 @@ class LlamaCppBackend:
"per-device compute buffers; falling back to layer split."
)
tensor_parallel = False
- # Restore the dropped quantized KV + original cache extras
- # (minus --split-mode); layer split supports them.
+ # Weights needed >1 card, so keep multi-GPU across the
+ # usable tensor GPUs.
+ if len(tp_gpus) >= 2:
+ _layer_min_gpus = max(_layer_min_gpus, len(tp_gpus))
+ # Restore the dropped quantized KV + cache extras (minus
+ # --split-mode); layer split supports them.
_restore_after_tensor_downgrade()
if tensor_parallel and tp_gpus:
@@ -5263,6 +5367,7 @@ class LlamaCppBackend:
usable_fraction = _pin_fraction,
total_by_idx = total_by_idx,
per_device_overhead_bytes = _pipeline_overhead_bytes,
+ min_gpus = _layer_min_gpus,
)
# No silent shrink: effective_ctx stays == requested_ctx.
else:
@@ -5273,7 +5378,22 @@ class LlamaCppBackend:
ranked = sorted(
gpus, key = lambda g: _gpu_usable(g, pin_fraction), reverse = True
)
- for n_gpus in range(1, len(ranked) + 1):
+ # Skips _select_gpus, so apply its cap: count only cards
+ # whose usable VRAM clears the per-device layer overhead.
+ _pipeline_overhead_mib = _pipeline_overhead_bytes / (1024 * 1024)
+ _auto_min_gpus = max(
+ 1,
+ min(
+ _layer_min_gpus,
+ sum(
+ 1
+ for g in ranked
+ if _gpu_usable(g, pin_fraction) > _pipeline_overhead_mib
+ )
+ or 1,
+ ),
+ )
+ for n_gpus in range(_auto_min_gpus, len(ranked) + 1):
subset = ranked[:n_gpus]
pool_budget = _pool_budget_mib(subset, pin_fraction)
_ms = _subset_model_size(n_gpus)
@@ -5303,7 +5423,7 @@ class LlamaCppBackend:
# at 131k may pin fine with a 4096 KV (#5106).
effective_ctx = min(4096, effective_ctx)
if effective_ctx > 0:
- for n_gpus in range(1, len(ranked) + 1):
+ for n_gpus in range(_auto_min_gpus, len(ranked) + 1):
subset = ranked[:n_gpus]
kv = self._estimate_kv_cache_bytes(
effective_ctx,
@@ -5339,6 +5459,7 @@ class LlamaCppBackend:
usable_fraction = _pin_fraction,
total_by_idx = total_by_idx,
per_device_overhead_bytes = _pipeline_overhead_bytes,
+ min_gpus = _layer_min_gpus,
)
if use_fit and not explicit_ctx:
# Weights don't fit on any subset; default UI to 4096
@@ -5578,12 +5699,15 @@ class LlamaCppBackend:
]
)
self._tensor_parallel = True
+ self._layer_preserves_tensor_intent = False
logger.info(
"Tensor parallelism: --split-mode tensor, --tensor-split %s",
tp_tensor_split,
)
else:
self._tensor_parallel = False
+ # > 1 only when a tensor request was downgraded but kept multi-GPU.
+ self._layer_preserves_tensor_intent = _layer_min_gpus > 1
# Speculative decoding. See _build_speculative_flags for the
# mode resolution, benchmarks, and llama.cpp references.
@@ -5867,7 +5991,17 @@ class LlamaCppBackend:
_startup_crashed = (
self._process.poll() is not None and self._process.returncode != 0
)
- if _spawn_attempt == 0 and _fit_retry_allowed and _startup_crashed:
+ # A split-axis abort (#6415) is fit-independent: skip the
+ # --fit off retry and let the caller latch it.
+ _split_axis_crash = self._is_tensor_split_assert(
+ "\n".join(self._stdout_lines[-50:])
+ )
+ if (
+ _spawn_attempt == 0
+ and _fit_retry_allowed
+ and _startup_crashed
+ and not _split_axis_crash
+ ):
logger.warning(
"llama-server crashed during startup (exit code %s) "
"with the default memory-fit step enabled; Studio "
@@ -5913,6 +6047,21 @@ class LlamaCppBackend:
)
healthy = _spawn_and_wait(cmd)
+ # #6415 split-mode tensor warmup abort. Latch it on THIS first spawn:
+ # the flash-attn-off retry below can't run tensor (needs flash_attn),
+ # so its output drops the marker and recording later would miss it,
+ # looping every load. Record and raise to the route's layer fallback,
+ # skipping the futile flash-attn/MTP retries.
+ if not healthy and self._tensor_parallel and not self._cancel_event.is_set():
+ _ts_out = "\n".join(self._stdout_lines[-50:])
+ _ts_rc = self._process.poll() if self._process is not None else None
+ if self._should_record_tensor_split_abort(_ts_rc, _ts_out):
+ LlamaCppBackend._record_tensor_split_abort(binary, model_identifier)
+ self._kill_process()
+ raise RuntimeError(
+ "llama-server aborted on --split-mode tensor "
+ "(split-axis geometry); retrying with layer split."
+ )
# Flash-attention kernels hard-crash at startup on some ROCm/GPU
# builds (frequently inside the vision tower). Disabling FA keeps
# both vision and MTP, so retry that way before dropping either.
@@ -6057,6 +6206,7 @@ class LlamaCppBackend:
# Read the crash code before _kill_process() clears _process.
_crash_rc = self._process.poll() if self._process is not None else None
self._kill_process()
+ # The #6415 split-axis abort is latched earlier (first spawn).
# Skip if a cancel/unload is pending (mirrors the MTP guard).
if (
launched_with_mmproj
@@ -6488,6 +6638,7 @@ class LlamaCppBackend:
spec_draft_n_max: Optional[int] = None,
tensor_parallel: bool = False,
mtp_draft_path: Optional[str] = None,
+ preserve_multi_gpu_on_layer: bool = False,
) -> bool:
"""True iff the live server already satisfies these load kwargs.
@@ -6530,6 +6681,17 @@ class LlamaCppBackend:
# server. An identical request would downgrade the same way.
if not _tensor_parallel_matches_loaded(extra_args, tensor_parallel, self._tensor_parallel):
return False
+ # Preserved tensor->layer fallback + an EXPLICIT tensor drop: reload so
+ # placement re-selects instead of keeping the all-GPU mask (mirrors the route,
+ # #6659). preserve_multi_gpu_on_layer carries the route's carry-forward decision
+ # (True for an implicit same-settings reload), so those still dedupe -- the HF
+ # auto-pick / local-dir flows skip the route guard and only reach here.
+ if (
+ self._layer_preserves_tensor_intent
+ and not _effective_tensor_parallel(extra_args, tensor_parallel)
+ and not preserve_multi_gpu_on_layer
+ ):
+ return False
# Compare on the canonical requested mode. With --spec-type in
# extra_args the backend stores None; mirror that here.
@@ -6641,6 +6803,7 @@ class LlamaCppBackend:
self._supports_tools = False
self._cache_type_kv = None
self._tensor_parallel = False
+ self._layer_preserves_tensor_intent = False
self._speculative_type = None
self._requested_spec_mode = None
self._spec_draft_n_max = None
diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py
index caf2a262a3..d3f56edec5 100644
--- a/studio/backend/routes/inference.py
+++ b/studio/backend/routes/inference.py
@@ -683,7 +683,9 @@ try:
detect_reasoning_flags,
)
from core.inference.llama_server_args import (
+ _effective_tensor_parallel,
_tensor_parallel_matches_loaded,
+ parse_split_mode_override,
resolve_tensor_parallel,
strip_shadowing_flags,
validate_extra_args,
@@ -718,7 +720,9 @@ except ImportError:
detect_reasoning_flags,
)
from core.inference.llama_server_args import (
+ _effective_tensor_parallel,
_tensor_parallel_matches_loaded,
+ parse_split_mode_override,
resolve_tensor_parallel,
strip_shadowing_flags,
validate_extra_args,
@@ -2078,6 +2082,32 @@ def _should_strip_split_mode(request: LoadRequest, backend_extra: Optional[list[
)
+def _carry_preserved_tensor_intent(
+ *, preserved: bool, same_model: bool, explicit_drop: bool
+) -> bool:
+ """Carry a preserved multi-GPU layer fallback forward only for a reload of the
+ SAME loaded model that doesn't explicitly drop tensor intent, so a fitting model
+ isn't collapsed to one GPU on a ctx-only change -- but an unrelated model switch
+ (without /unload) or an explicit tensor-off doesn't inherit it (#6659)."""
+ return preserved and same_model and not explicit_drop
+
+
+def _is_explicit_tensor_drop(request: LoadRequest) -> bool:
+ """True only when the request explicitly selects a non-tensor --split-mode (e.g.
+ layer/row/none), a deliberate departure from a preserved tensor->layer fallback.
+
+ A bare tensor_parallel field is NOT a drop: the Studio UI always sends it and echoes
+ the /load response's resolved value back, so after a fallback every reload carries
+ tensor_parallel=false even though the user never changed it -- treating that as a drop
+ would collapse the preserved multi-GPU placement on the next ctx/settings reload. An
+ empty clear is not a drop either (a fallback always stores --split-mode layer, never a
+ tensor split mode, so a clear never wipes tensor intent), nor is an unrelated extra
+ (--top-k) or inherit (None). tensor_parallel=true / --split-mode tensor re-engage
+ tensor. Shared by the already-loaded dedup and the load carry-forward (#6659)."""
+ override = parse_split_mode_override(request.llama_extra_args)
+ return override is not None and override.strip().lower() != "tensor"
+
+
def _request_matches_loaded_settings(
request: LoadRequest,
llama_backend: LlamaCppBackend,
@@ -2116,6 +2146,13 @@ def _request_matches_loaded_settings(
effective_extra, request.tensor_parallel, llama_backend.tensor_parallel
):
return False
+ # Preserved tensor->layer fallback (both report tensor=off, so the check above
+ # matches): if the user now explicitly drops tensor intent, reload so placement
+ # re-selects instead of keeping the all-GPU mask (#6659). The effective check
+ # includes the env, so an env-only tensor (LLAMA_ARG_SPLIT_MODE=tensor) that
+ # can't actually be dropped falls through to the env-downgrade match, not a loop.
+ if llama_backend.layer_preserves_tensor_intent and _is_explicit_tensor_drop(request):
+ return False
# Spec decoding works on vision models too (MTP is mmproj-compatible,
# llama.cpp #22673; the old ``not is_vision`` gate is gone), so compare
# the real requested mode -- coercing vision to ``off`` here used to
@@ -2810,6 +2847,48 @@ async def load_model(
hf_variant = config.gguf_variant,
)
+ # Tensor intent for this load: the request itself, or a preserved
+ # multi-GPU layer fallback carried across a reload of the SAME model that
+ # doesn't drop it (e.g. a ctx-only change), so a fitting model doesn't
+ # silently collapse to one GPU. Only an explicit non-tensor --split-mode
+ # override counts as the drop -- the tensor field echo / unrelated extras keep
+ # the preserved placement; the same-model guard stops a switch-without-unload
+ # inheriting the prior model's intent.
+ _explicit_tensor_drop = _is_explicit_tensor_drop(request)
+ # Compare the resolved config.identifier (what load_model stores), not the
+ # raw request id: from_identifier normalizes shorthands (adds unsloth/, fixes
+ # case), so a reload with the shorthand would otherwise miss the match and
+ # drop the carry-forward. #6659
+ _same_model_loaded = (
+ llama_backend.is_loaded
+ and (llama_backend.model_identifier or "").lower()
+ == (config.identifier or "").lower()
+ )
+ # model_identifier is variant-agnostic for HF repos and dir-level for a
+ # local multi-variant directory, so also require the loaded quant to match
+ # (path else variant, mirroring _already_in_target_state) -- otherwise a
+ # different variant inherits the prior one's preserved intent. #6659
+ if _same_model_loaded:
+ if config.gguf_file and llama_backend.gguf_path:
+ try:
+ _same_model_loaded = (
+ Path(llama_backend.gguf_path).resolve()
+ == Path(config.gguf_file).resolve()
+ )
+ except OSError:
+ _same_model_loaded = False
+ else:
+ _same_model_loaded = (llama_backend.hf_variant or "").lower() == (
+ config.gguf_variant or ""
+ ).lower()
+ _tensor_intent_overall = _effective_tensor_parallel(
+ extra_llama_args, request.tensor_parallel
+ ) or _carry_preserved_tensor_intent(
+ preserved = llama_backend.layer_preserves_tensor_intent,
+ same_model = _same_model_loaded,
+ explicit_drop = _explicit_tensor_drop,
+ )
+
# Run a single load attempt with the given tensor flag + extras.
async def _attempt_gguf_load(
tensor_parallel: bool, attempt_extra_args: Optional[list[str]]
@@ -2823,6 +2902,12 @@ async def load_model(
**_source_load_kwargs,
**attempt_kwargs,
tensor_parallel = tensor_parallel,
+ # True on the layer fallback retry (tensor wanted overall but not on
+ # this attempt): keep multi-GPU. Mirrors the fallback's key.
+ preserve_multi_gpu_on_layer = bool(
+ _tensor_intent_overall
+ and not _effective_tensor_parallel(attempt_extra_args, tensor_parallel)
+ ),
)
# Tensor parallelism is arch-gated in llama.cpp and crashes some loads
diff --git a/studio/backend/tests/test_tp_vision_regression.py b/studio/backend/tests/test_tp_vision_regression.py
new file mode 100644
index 0000000000..09af876da6
--- /dev/null
+++ b/studio/backend/tests/test_tp_vision_regression.py
@@ -0,0 +1,805 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
+
+"""Regression guards for silent tensor-parallel downgrades in load_model.
+
+PR #6416 blanket-disabled tensor parallelism for vision models to dodge a
+--split-mode tensor + --mmproj GGML_ASSERT (#6415), which silently single-GPU'd
+any mmproj/MTP GGUF that fit on one card. The fix makes the skip self-healing:
+tensor is tried by default and recorded per (binary, model) only on a real abort.
+
+load_model is too entangled to drive end-to-end, so these tests inspect the
+source / drive the pure helpers. The headline test pins the set of TP-drop
+conditions, so a new silent drop fails CI. No GPU; fully deterministic.
+"""
+
+from __future__ import annotations
+
+import ast
+import importlib.util
+import inspect
+import os
+import sys
+import textwrap
+import types as _types
+from pathlib import Path
+
+_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
+if _BACKEND_DIR not in sys.path:
+ sys.path.insert(0, _BACKEND_DIR)
+
+# External-dep stubs so importing the backend doesn't require structlog / httpx /
+# loggers -- but only when the real module is missing, so a lightweight stub never
+# shadows the real package (or `loggers.handlers` submodule) for tests collected
+# later in the same pytest process.
+try:
+ import structlog # noqa: F401
+except ImportError:
+ _structlog_stub = _types.ModuleType("structlog")
+ _structlog_stub.get_logger = lambda *a, **k: __import__("logging").getLogger("stub")
+ sys.modules["structlog"] = _structlog_stub
+try:
+ import loggers # noqa: F401
+except ImportError:
+ _loggers_stub = _types.ModuleType("loggers")
+ _loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
+ sys.modules["loggers"] = _loggers_stub
+try:
+ import httpx as _httpx_real # noqa: F401
+except ImportError:
+ _httpx_stub = _types.ModuleType("httpx")
+ for _exc in (
+ "ConnectError",
+ "TimeoutException",
+ "ReadTimeout",
+ "ReadError",
+ "RemoteProtocolError",
+ "CloseError",
+ "HTTPError",
+ "RequestError",
+ ):
+ setattr(_httpx_stub, _exc, type(_exc, (Exception,), {}))
+ _httpx_stub.Timeout = type("T", (), {"__init__": lambda s, *a, **k: None})
+ _httpx_stub.Response = type("Response", (), {})
+ _httpx_stub.Client = type(
+ "C",
+ (),
+ {
+ "__init__": lambda s, **kw: None,
+ "__enter__": lambda s: s,
+ "__exit__": lambda s, *a: None,
+ },
+ )
+ sys.modules["httpx"] = _httpx_stub
+
+from core.inference.llama_cpp import LlamaCppBackend # noqa: E402
+
+_GB = 1024**3
+
+
+def _load_inference_routes_module():
+ """Load routes/inference.py directly, bypassing routes/__init__.py (which imports
+ every router, dragging in unrelated deps like python-multipart) (Codex #6659)."""
+ route_path = Path(_BACKEND_DIR) / "routes" / "inference.py"
+ spec = importlib.util.spec_from_file_location(
+ "tp_vision_regression_inference_routes", route_path
+ )
+ assert spec is not None and spec.loader is not None
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[spec.name] = module
+ spec.loader.exec_module(module)
+ return module
+
+
+def _load_model_ast() -> ast.FunctionDef:
+ """Parse load_model into an AST FunctionDef (no import side effects)."""
+ src = textwrap.dedent(inspect.getsource(LlamaCppBackend.load_model))
+ return ast.parse(src).body[0]
+
+
+def _tensor_parallel_false_drop_guards() -> list[str]:
+ """Source of the guard expression for every `if ...: tensor_parallel = False`
+ (the LOCAL variable, not self._tensor_parallel) inside load_model."""
+ fn = _load_model_ast()
+
+ def _body_drops_tp(body) -> bool:
+ for n in body:
+ if (
+ isinstance(n, ast.Assign)
+ and any(isinstance(t, ast.Name) and t.id == "tensor_parallel" for t in n.targets)
+ and isinstance(n.value, ast.Constant)
+ and n.value.value is False
+ ):
+ return True
+ return False
+
+ return [
+ ast.unparse(node.test)
+ for node in ast.walk(fn)
+ if isinstance(node, ast.If) and _body_drops_tp(node.body)
+ ]
+
+
+# Every condition that may flip a requested tensor_parallel back to False. Adding
+# one must be conscious: update this allowlist and keep multi-GPU where possible.
+_ALLOWED_TP_DROP_GUARDS = {
+ # Capability: --split-mode tensor aborted for this (binary, model) (#6415).
+ # Self-healing -- tried by default, skipped only after a real abort (vs #6416).
+ "tensor_parallel and self._tensor_split_aborts(binary, model_identifier)",
+ # Capacity: tensor needs >= 2 GPUs clearing the compute-buffer reserve.
+ "tensor_parallel and len(tp_gpus) < 2",
+ # Capacity: pooled usable VRAM can't hold weights + MTP reserve -> layer split.
+ "_tp_weight_budget_mib <= _tp_required_mib",
+}
+
+
+def test_tensor_parallel_drop_sites_match_allowlist():
+ """The set of reasons a requested TP can be dropped is fixed and reviewed: a new
+ drop site fails this set-equality until consciously allowlisted (would catch #6416)."""
+ found = set(_tensor_parallel_false_drop_guards())
+ assert found == _ALLOWED_TP_DROP_GUARDS, (
+ "tensor_parallel drop sites changed.\n"
+ f" unexpected (new) : {sorted(found - _ALLOWED_TP_DROP_GUARDS)}\n"
+ f" missing (removed): {sorted(_ALLOWED_TP_DROP_GUARDS - found)}\n"
+ "A new drop means a user's TP request is ignored for a new reason -- "
+ "review it, keep multi-GPU where possible, surface it, then update "
+ "_ALLOWED_TP_DROP_GUARDS."
+ )
+
+
+def test_every_tp_drop_is_logged_not_silent():
+ """Each tensor_parallel downgrade must log why, so it never disappears silently."""
+ fn = _load_model_ast()
+
+ def _body_drops_tp(body):
+ return any(
+ isinstance(n, ast.Assign)
+ and any(isinstance(t, ast.Name) and t.id == "tensor_parallel" for t in n.targets)
+ and isinstance(n.value, ast.Constant)
+ and n.value.value is False
+ for n in body
+ )
+
+ def _body_logs(body) -> bool:
+ for n in ast.walk(ast.Module(body = list(body), type_ignores = [])):
+ if (
+ isinstance(n, ast.Call)
+ and isinstance(n.func, ast.Attribute)
+ and isinstance(n.func.value, ast.Name)
+ and n.func.value.id == "logger"
+ ):
+ return True
+ return False
+
+ for node in ast.walk(fn):
+ if isinstance(node, ast.If) and _body_drops_tp(node.body):
+ assert _body_logs(node.body), (
+ f"TP drop under `{ast.unparse(node.test)}` has no logger call -- "
+ "downgrades must explain themselves."
+ )
+
+
+def test_tensor_split_gate_is_self_healing_not_blanket():
+ """Skip is conditional on a recorded (binary, model) abort, not a blanket
+ is_vision disable (the #6416 regression)."""
+ src = inspect.getsource(LlamaCppBackend.load_model)
+ assert "self._tensor_split_aborts(binary, model_identifier)" in src
+ assert "if tensor_parallel and is_vision:" not in src
+ assert "if tensor_parallel and effective_is_vision:" not in src
+
+
+def test_tensor_split_skip_documents_layer_split_fallback():
+ """When the skip fires (known-bad binary+model), it states the fallback."""
+ src = inspect.getsource(LlamaCppBackend.load_model)
+ gate = src.find("self._tensor_split_aborts(binary, model_identifier)")
+ assert gate != -1
+ block = src[gate : gate + 600]
+ assert "layer split" in block, "the skip should state it falls back to layer split"
+
+
+def test_tensor_split_abort_recorded_early_on_first_spawn():
+ """Recorded on the first spawn showing the marker, before the flash-attn-off
+ retry (which can't run tensor so drops the marker) -- else it loops (oobabooga, #6659)."""
+ src = inspect.getsource(LlamaCppBackend.load_model)
+ idx = src.find("_record_tensor_split_abort(binary, model_identifier)")
+ assert idx != -1, "load_model must record a (binary, model) tensor-split abort"
+ guard = src[max(0, idx - 600) : idx]
+ assert "self._tensor_parallel" in guard
+ assert (
+ "_should_record_tensor_split_abort" in guard
+ ), "record must be gated on the marker-plus-hard-crash decision helper"
+ # Recorded before the flash-attn-off retry, not after the full ladder.
+ fa_off = src.find("_with_flash_attn_off")
+ assert 0 <= idx < fa_off, "recording must latch on the first spawn, before flash-off"
+
+
+def test_vision_downgrade_preserves_multi_gpu_intent():
+ """The vision downgrade raises _layer_min_gpus and threads it into both the
+ _select_gpus and auto-context layer paths, so a fitting model still spreads."""
+ src = inspect.getsource(LlamaCppBackend.load_model)
+ assert "_layer_min_gpus = max(_layer_min_gpus, len(gpus))" in src
+ assert src.count("min_gpus = _layer_min_gpus") >= 2
+ assert "range(_auto_min_gpus, len(ranked) + 1)" in src
+ auto = src.find("_auto_min_gpus = max(")
+ assert auto != -1 and "_layer_min_gpus" in src[auto : auto + 200]
+
+
+# ── per-binary capability cache (pure) ───────────────────────────────
+
+
+def test_tensor_attempted_by_default_for_unknown_binary():
+ """A (binary, model) not seen to abort -> tensor is attempted (not skipped)."""
+ assert LlamaCppBackend._tensor_split_aborts("/never/seen/llama-server", "m") is False
+ assert LlamaCppBackend._tensor_split_aborts(None, "m") is False
+ assert LlamaCppBackend._tensor_split_aborts("/x", None) is False
+
+
+def test_recorded_tensor_abort_is_per_model():
+ """A recorded (binary, model) abort trips the gate for that model only -- a
+ different model on the same binary still attempts tensor (oobabooga, #6659)."""
+ b = f"/tmp/llama-server-{id(object())}"
+ try:
+ assert LlamaCppBackend._tensor_split_aborts(b, "model-a") is False
+ LlamaCppBackend._record_tensor_split_abort(b, "model-a")
+ assert LlamaCppBackend._tensor_split_aborts(b, "model-a") is True
+ # a different model on the same binary is unaffected
+ assert LlamaCppBackend._tensor_split_aborts(b, "model-b") is False
+ finally:
+ LlamaCppBackend._tensor_split_abort_keys.discard(
+ LlamaCppBackend._tensor_split_cache_key(b, "model-a")
+ )
+
+
+# ── _select_gpus: single-GPU collapse vs honored multi-GPU intent (pure) ──
+
+
+def test_select_gpus_collapses_to_single_gpu_when_model_fits():
+ """Default (min_gpus=1): a 39 GB model on four 183 GB GPUs pins ONE GPU -- the
+ 'single GPU' symptom once TP drops, and why the downgrade needs min_gpus."""
+ gpus = [(0, 180000), (1, 180000), (2, 180000), (3, 180000)] # (idx, free MiB)
+ gpu_indices, _use_fit = LlamaCppBackend._select_gpus(int(39 * _GB), gpus)
+ assert gpu_indices is not None and len(gpu_indices) == 1
+
+
+def test_select_gpus_min_gpus_keeps_multi_gpu_for_fitting_model():
+ """min_gpus>=2 must NOT collapse to one GPU for a model that fits on one."""
+ gpus = [(0, 180000), (1, 180000), (2, 180000), (3, 180000)]
+ gpu_indices, _ = LlamaCppBackend._select_gpus(int(39 * _GB), gpus, min_gpus = 2)
+ assert gpu_indices is not None and len(gpu_indices) >= 2
+
+
+def test_select_gpus_min_gpus_capped_to_available():
+ """min_gpus larger than the GPU count is capped, not an error."""
+ gpus = [(0, 180000), (1, 180000)]
+ gi, _ = LlamaCppBackend._select_gpus(int(10 * _GB), gpus, min_gpus = 8)
+ assert gi is not None and len(gi) == 2
+
+
+def test_select_gpus_uses_multiple_gpus_when_model_does_not_fit():
+ """Sanity: selection spreads across GPUs when one card can't hold the model."""
+ gpus = [(0, 40000), (1, 40000), (2, 40000), (3, 40000)] # 40 GB free each
+ gpu_indices, _use_fit = LlamaCppBackend._select_gpus(int(120 * _GB), gpus)
+ assert gpu_indices is not None and len(gpu_indices) >= 2
+
+
+def test_select_gpus_min_gpus_excludes_unusable_gpu():
+ """min_gpus caps to usable cards: 2 free + 1 nearly-full -> 2-GPU split, not
+ forcing the full card (OOM) or tripping --fit (#6659)."""
+ gpus = [(0, 180000), (1, 180000), (2, 500)] # GPU 2 is nearly full
+ total = {0: 180000, 1: 180000, 2: 180000}
+ gi, _ = LlamaCppBackend._select_gpus(
+ int(39 * _GB),
+ gpus,
+ min_gpus = 3,
+ total_by_idx = total,
+ per_device_overhead_bytes = int(1 * _GB),
+ )
+ assert gi is not None
+ assert 2 not in gi, "a nearly-full GPU must not be forced in to satisfy min_gpus"
+ assert len(gi) == 2
+
+
+def test_tensor_abort_cache_invalidated_on_binary_mtime_change(tmp_path):
+ """Cache keys on (path, mtime, model), so a binary swapped in place (in-app
+ update, no restart) is re-probed instead of inheriting the old abort (#6659)."""
+ binp = tmp_path / "llama-server"
+ binp.write_text("v1")
+ p = str(binp)
+ try:
+ LlamaCppBackend._record_tensor_split_abort(p, "m")
+ assert LlamaCppBackend._tensor_split_aborts(p, "m") is True
+ # Simulate an in-place update bumping the binary's mtime.
+ st = binp.stat()
+ os.utime(p, (st.st_atime, st.st_mtime + 10))
+ assert (
+ LlamaCppBackend._tensor_split_aborts(p, "m") is False
+ ), "a binary swapped in place (new mtime) must be re-probed"
+ # A same-second replacement (sub-second mtime bump) must also re-probe:
+ # second-resolution mtime would inherit the stale abort (reviewer.py P2).
+ sec_ns = (binp.stat().st_mtime_ns // 1_000_000_000) * 1_000_000_000
+ os.utime(p, ns = (sec_ns, sec_ns))
+ LlamaCppBackend._record_tensor_split_abort(p, "m")
+ binp.write_text("v2")
+ os.utime(p, ns = (sec_ns, sec_ns + 1))
+ assert (
+ LlamaCppBackend._tensor_split_aborts(p, "m") is False
+ ), "a same-second in-place swap (ns mtime bump) must be re-probed"
+ finally:
+ for key in list(LlamaCppBackend._tensor_split_abort_keys):
+ if key and key[0] == p:
+ LlamaCppBackend._tensor_split_abort_keys.discard(key)
+
+
+def test_tensor_split_abort_raises_early_to_layer_fallback():
+ """The first-spawn abort raises to the route's layer fallback (not the text-only
+ mmproj strip), before the flash-attn-off retry, preserving the projector (#6659)."""
+ src = inspect.getsource(LlamaCppBackend.load_model)
+ raise_idx = src.find("(split-axis geometry); retrying with layer split")
+ assert raise_idx != -1, "the split-axis abort must raise to trigger a layer retry"
+ # raises before both the flash-attn-off retry and the text-only mmproj strip
+ assert raise_idx < src.find("_with_flash_attn_off")
+ assert raise_idx < src.find("_strip_mmproj_args(_last_spawn_cmd)")
+ # gated on the marker-plus-crash helper, which also drives the record just above
+ guard = src[max(0, raise_idx - 600) : raise_idx]
+ assert "_should_record_tensor_split_abort" in guard
+ rec_idx = src.find("_record_tensor_split_abort(binary, model_identifier)")
+ assert rec_idx != -1 and rec_idx < raise_idx
+
+
+def test_budget_downgrade_preserves_multi_gpu_intent():
+ """The pooled-VRAM downgrade raises _layer_min_gpus from the usable tensor GPUs
+ too, symmetric with the vision downgrade (reviewer.py asymmetric fix, #6659)."""
+ src = inspect.getsource(LlamaCppBackend.load_model)
+ budget = src.find("_tp_weight_budget_mib <= _tp_required_mib")
+ assert budget != -1
+ block = src[budget : budget + 1000]
+ assert "tensor_parallel = False" in block
+ assert (
+ "_layer_min_gpus = max(_layer_min_gpus, len(tp_gpus))" in block
+ ), "the budget downgrade must preserve multi-GPU intent like the vision gate"
+
+
+def test_compute_buffer_downgrade_preserves_multi_gpu_intent():
+ """The len(tp_gpus) < 2 compute-buffer downgrade raises _layer_min_gpus from the
+ full GPU set too, so it is symmetric with the budget/geometry downgrades and
+ doesn't collapse a multi-GPU layer load to one card (reviewer.py P1 on #6659)."""
+ src = inspect.getsource(LlamaCppBackend.load_model)
+ gate = src.find("tensor_parallel and len(tp_gpus) < 2")
+ assert gate != -1
+ # Bound to exactly this block: from its gate to the next (budget) downgrade.
+ nxt = src.find("_tp_weight_budget_mib <= _tp_required_mib", gate)
+ assert nxt != -1
+ block = src[gate:nxt]
+ assert "tensor_parallel = False" in block
+ assert (
+ "_layer_min_gpus = max(_layer_min_gpus, len(gpus))" in block
+ ), "the compute-buffer downgrade must preserve multi-GPU intent like the others"
+
+
+def test_tensor_split_layer_min_gpus_bump_requires_tensor_request():
+ """Every guard that bumps _layer_min_gpus off the abort cache also tests
+ tensor_parallel, so a non-tensor load on a known-bad binary doesn't grab every
+ GPU for a fitting model (#6659)."""
+ fn = _load_model_ast()
+ checked = 0
+ for node in ast.walk(fn):
+ if isinstance(node, ast.If):
+ test_src = ast.unparse(node.test)
+ if "self._tensor_split_aborts(binary, model_identifier)" not in test_src:
+ continue
+ body = "\n".join(ast.unparse(n) for n in node.body)
+ if "_layer_min_gpus" in body:
+ checked += 1
+ assert "tensor_parallel" in test_src, (
+ "the cached _layer_min_gpus bump must require a current tensor "
+ f"request, but fires under `{test_src}`"
+ )
+ assert checked >= 1, "expected an abort-cache guard that bumps _layer_min_gpus"
+
+
+# ── round-2 follow-up: route-fallback retry + auto-context cap + assert marker ──
+
+
+def test_layer_fallback_retry_preserves_multi_gpu_intent():
+ """load_model takes a preserve_multi_gpu_on_layer hint and raises _layer_min_gpus
+ for it, so the tensor-off fallback retry still spreads a fitting model (#6659)."""
+ sig = inspect.signature(LlamaCppBackend.load_model)
+ assert "preserve_multi_gpu_on_layer" in sig.parameters
+ assert sig.parameters["preserve_multi_gpu_on_layer"].default is False
+ fn = _load_model_ast()
+ found = any(
+ isinstance(n, ast.If)
+ and "preserve_multi_gpu_on_layer" in ast.unparse(n.test)
+ and "_layer_min_gpus" in "\n".join(ast.unparse(b) for b in n.body)
+ for n in ast.walk(fn)
+ )
+ assert found, "preserve_multi_gpu_on_layer must raise _layer_min_gpus"
+
+
+def test_auto_context_layer_loops_capped_to_usable_gpus():
+ """The auto-context loops bypass _select_gpus, so they apply its cap: a card
+ counts only if usable VRAM clears the per-device layer overhead (#6659)."""
+ src = inspect.getsource(LlamaCppBackend.load_model)
+ assert (
+ "range(max(1, _layer_min_gpus), len(ranked) + 1)" not in src
+ ), "auto-context loops must cap _layer_min_gpus to usable GPUs, not use it raw"
+ assert "_auto_min_gpus" in src
+ assert "range(_auto_min_gpus, len(ranked) + 1)" in src
+ # the eligibility threshold is the per-device layer overhead, not bare > 0
+ auto = src.find("_auto_min_gpus = max(")
+ assert auto != -1
+ block = src[auto : auto + 400]
+ assert "_pipeline_overhead_mib" in block, (
+ "a card must clear the per-device layer overhead to count, mirroring "
+ "_select_gpus, so a nearly-full GPU is not exposed and OOMs"
+ )
+
+
+def test_fallback_hint_uses_effective_tensor_request_not_just_toggle():
+ """Tensor intent keys off _effective_tensor_parallel (toggle + extras + env), not
+ just the toggle, so extra/env-driven tensor users keep multi-GPU (#6659)."""
+ route = Path(_BACKEND_DIR) / "routes" / "inference.py"
+ src = route.read_text()
+ idx = src.find("_tensor_intent_overall = _effective_tensor_parallel(")
+ assert idx != -1, "the GGUF load closure must compute tensor intent"
+ block = src[idx : idx + 300]
+ assert "extra_llama_args, request.tensor_parallel" in block
+ pres = src.find("preserve_multi_gpu_on_layer = bool(")
+ assert (
+ "_effective_tensor_parallel(attempt_extra_args, tensor_parallel)" in src[pres : pres + 200]
+ )
+ # not the toggle-only form this replaced
+ assert (
+ "bool(\n request.tensor_parallel and not tensor_parallel" not in src
+ )
+
+
+def test_carry_preserved_tensor_intent_truth_table():
+ """Behavioral check of the carry-forward decision: carried only for the SAME
+ model, preserved, and not an explicit drop. Catches a `not` inversion (ctx-only
+ collapse) and a missing same-model guard (cross-model leak) (#6659)."""
+ inference_routes = _load_inference_routes_module()
+ f = inference_routes._carry_preserved_tensor_intent
+ assert f(preserved = True, same_model = True, explicit_drop = False) is True
+ assert f(preserved = True, same_model = True, explicit_drop = True) is False # explicit drop
+ assert f(preserved = True, same_model = False, explicit_drop = False) is False # model switch
+ assert f(preserved = False, same_model = True, explicit_drop = False) is False # not a fallback
+
+
+def test_preserved_fallback_carried_across_non_drop_reload():
+ """The hint carries the preserved fallback via _carry_preserved_tensor_intent,
+ gated on the same model loaded, so a ctx-only reload keeps multi-GPU but a model
+ switch / explicit drop doesn't inherit it (#6659)."""
+ route = Path(_BACKEND_DIR) / "routes" / "inference.py"
+ src = route.read_text()
+ idx = src.find("_tensor_intent_overall = _effective_tensor_parallel(")
+ assert idx != -1
+ block = src[idx : idx + 400]
+ assert "_carry_preserved_tensor_intent(" in block
+ assert "preserved = llama_backend.layer_preserves_tensor_intent" in block
+ assert "same_model = _same_model_loaded" in block
+ assert "explicit_drop = _explicit_tensor_drop" in block
+
+
+def test_same_model_guard_checks_path_and_variant():
+ """The same-model guard matches the resolved config.identifier (what load_model
+ stores, after from_identifier normalizes shorthands) -- not the raw request id --
+ and also matches the loaded quant by path (local multi-variant dir) else variant (HF
+ repo), so a reload keeps the carry-forward and a different variant doesn't inherit
+ the prior one's preserved tensor intent (#6659)."""
+ route = Path(_BACKEND_DIR) / "routes" / "inference.py"
+ src = route.read_text()
+ idx = src.find("_same_model_loaded = (")
+ assert idx != -1
+ block = src[idx : idx + 1300]
+ # Identity compares the normalized config.identifier, not the raw model_identifier.
+ head = src[idx : idx + 200]
+ assert "config.identifier" in head and "== (model_identifier" not in head
+ assert "llama_backend.gguf_path" in block and "config.gguf_file" in block
+ assert "llama_backend.hf_variant" in block and "config.gguf_variant" in block
+
+
+def test_diffusion_load_clears_preserved_tensor_flag():
+ """The diffusion early-return path (skips the command builder) clears the
+ preserved-fallback flag, so a prior tensor fallback doesn't churn it (#6659)."""
+ src = inspect.getsource(LlamaCppBackend.load_model)
+ diff = src.find("if self._is_diffusion:")
+ assert diff != -1
+ start = src.find("return self._start_diffusion_server", diff)
+ assert start != -1
+ assert "self._layer_preserves_tensor_intent = False" in src[diff:start]
+
+
+def test_is_tensor_split_assert_marker():
+ """Matches the specific #6415 split-axis assert, not any ggml assert/abort, so
+ an unrelated invariant a corrupt GGUF/projector trips isn't cached (#6659)."""
+ f = LlamaCppBackend._is_tensor_split_assert
+ # the real #6415 warmup assert (split-axis enum, in ggml-backend-meta)
+ assert (
+ f(
+ "ggml-backend-meta.cpp:541: GGML_ASSERT(src_ss[0].axis != "
+ "GGML_BACKEND_SPLIT_AXIS_0) failed"
+ )
+ is True
+ )
+ # the split-axis token alone (file path elided / reworded) still matches
+ assert f("GGML_ASSERT(x.axis != GGML_BACKEND_SPLIT_AXIS_1) failed") is True
+ # UNRELATED asserts must NOT match -- including a different invariant from the
+ # same multi-assert source file (matched on the token, not the file name).
+ assert f("ggml-backend-meta.cpp:99: GGML_ASSERT(buf != NULL) failed") is False
+ assert f("/x/ggml.c:1234: GGML_ASSERT(ne == 1) failed") is False
+ assert f("ggml_abort: something else entirely") is False
+ assert f("Segmentation fault (core dumped)") is False
+ assert f("") is False
+ assert f(None) is False
+
+
+def test_layer_preserve_hint_replayed_on_respawn():
+ """The preserve hint is in the replay snapshot (_pending_load_kwargs), so a
+ respawn keeps the downgraded model multi-GPU (Codex review on #6659)."""
+ src = inspect.getsource(LlamaCppBackend.load_model)
+ pend = src.find("_pending_load_kwargs = {")
+ assert pend != -1
+ block = src[pend : src.find("}", pend) + 1]
+ assert '"preserve_multi_gpu_on_layer": preserve_multi_gpu_on_layer' in block, (
+ "the layer-preserve hint must be in the replay snapshot so _respawn_if_dead "
+ "keeps the multi-GPU placement"
+ )
+
+
+def test_should_record_tensor_split_abort_decision():
+ """Behavioral check of marker AND (signal crash OR Windows abort), so an
+ or->and typo or caching a generic crash fails here, not just the source pins."""
+ f = LlamaCppBackend._should_record_tensor_split_abort
+ marker = "ggml-backend-meta.cpp:541: GGML_ASSERT(x.axis != GGML_BACKEND_SPLIT_AXIS_0) failed"
+ # marker + a hard crash records, across every platform's abort encoding
+ assert f(-6, marker) is True # POSIX SIGABRT
+ assert f(-11, marker) is True # POSIX SIGSEGV
+ assert f(3, marker) is True # Windows CRT abort() exit (not a signal)
+ assert f(0xC0000005, marker) is True # Windows NTSTATUS access violation
+ # marker present but no hard crash -> not recorded
+ assert f(0, marker) is False # clean exit
+ assert f(-9, marker) is False # SIGKILL (OOM / unload), not a fault
+ assert f(None, marker) is False # still running
+ # hard crash but not the split-axis marker -> not recorded (no over-caching)
+ assert f(3, "some other failure") is False
+ assert f(-6, "GGML_ASSERT(buf != NULL) failed") is False
+ assert f(0xC0000005, "") is False
+
+
+def test_fit_off_retry_skipped_on_split_axis_abort():
+ """The fit-independent --fit off retry is skipped on the split-axis marker, else
+ the model crashes a second time before the latch records it (reviewer.py, #6659)."""
+ src = inspect.getsource(LlamaCppBackend.load_model)
+ retry = src.find('run_cmd = [*run_cmd, "--fit", "off"]')
+ assert retry != -1
+ guard = src[max(0, retry - 1000) : retry]
+ assert "_fit_retry_allowed" in guard and "_startup_crashed" in guard
+ assert (
+ "not _split_axis_crash" in guard
+ ), "the fit-off retry must be skipped when the crash is a split-axis abort"
+
+
+def test_is_abort_exit_recognizes_windows_crt_abort():
+ """exit code 3 (MSVC abort()) counts as a crash; signals / clean exits do not."""
+ f = LlamaCppBackend._is_abort_exit
+ assert f(3) is True
+ assert f(0) is False
+ assert f(-6) is False # POSIX SIGABRT is handled by _is_signal_crash, not here
+ assert f(None) is False
+
+
+# ── tensor-off after a multi-GPU fallback forces a reload (route dedup) ─
+
+
+class _NoopProcess:
+ """Stand-in for Popen so is_loaded is True and atexit cleanup doesn't crash."""
+
+ def terminate(self):
+ pass
+
+ def wait(self, timeout = None):
+ return 0
+
+ def kill(self):
+ pass
+
+ def poll(self):
+ return 0
+
+
+def _fallback_loaded_backend(layer_preserves_tensor_intent: bool) -> LlamaCppBackend:
+ """A loaded backend in the tensor->layer fallback state (tensor off, --split-mode
+ layer stored), differing only in the preserved-multi-GPU flag."""
+ b = LlamaCppBackend()
+ b._model_identifier = "owner/repo"
+ b._requested_n_ctx = 0
+ b._cache_type_kv = None
+ b._tensor_parallel = False
+ b._layer_preserves_tensor_intent = layer_preserves_tensor_intent
+ b._extra_args = ["--split-mode", "layer"]
+ b._requested_spec_mode = "auto"
+ b._chat_template_override = None
+ b._gguf_path = None
+ return b
+
+
+def test_tensor_off_echo_preserves_multi_gpu_fallback():
+ """The Studio UI always sends tensor_parallel and echoes the /load response's
+ resolved value, so after a fallback a ctx/settings reload carries tensor_parallel=
+ false even though the user never changed it. That echo must NOT collapse the
+ preserved multi-GPU placement -- it dedupes (Codex #6659)."""
+ from models.inference import LoadRequest
+
+ inference_routes = _load_inference_routes_module()
+
+ req = LoadRequest(model_path = "owner/repo", tensor_parallel = False)
+ assert "tensor_parallel" in req.model_fields_set, "the UI always sends the field"
+
+ # Preserved fallback + bare tensor=false echo: dedupe, keep multi-GPU (no collapse).
+ assert (
+ inference_routes._request_matches_loaded_settings(
+ req, _fallback_loaded_backend(layer_preserves_tensor_intent = True)
+ )
+ is True
+ )
+ # A genuine layer load (no preserved intent): tensor-off also dedupes, no churn.
+ assert (
+ inference_routes._request_matches_loaded_settings(
+ req, _fallback_loaded_backend(layer_preserves_tensor_intent = False)
+ )
+ is True
+ )
+
+
+def test_explicit_split_mode_layer_extras_reloads_after_multi_gpu_fallback():
+ """Tensor intent can be dropped via extras too: an explicit --split-mode layer
+ matches the stored fallback extras but must still reload (reviewer.py P1, #6659)."""
+ from models.inference import LoadRequest
+
+ inference_routes = _load_inference_routes_module()
+
+ req = LoadRequest(model_path = "owner/repo", llama_extra_args = ["--split-mode", "layer"])
+ assert "llama_extra_args" in req.model_fields_set
+ assert (
+ inference_routes._request_matches_loaded_settings(
+ req, _fallback_loaded_backend(layer_preserves_tensor_intent = True)
+ )
+ is False
+ )
+
+
+def test_tensor_off_reload_requires_explicit_toggle():
+ """An Apply that doesn't touch the toggle (e.g. a context change) isn't churned
+ by the preserved-fallback reload -- the working server is kept (Codex #6659)."""
+ from models.inference import LoadRequest
+
+ inference_routes = _load_inference_routes_module()
+
+ req = LoadRequest(model_path = "owner/repo") # tensor_parallel left unset
+ assert "tensor_parallel" not in req.model_fields_set
+ assert (
+ inference_routes._request_matches_loaded_settings(
+ req, _fallback_loaded_backend(layer_preserves_tensor_intent = True)
+ )
+ is True
+ )
+
+
+def test_tensor_off_under_env_tensor_does_not_reload_loop(monkeypatch):
+ """With LLAMA_ARG_SPLIT_MODE=tensor set, a tensor-off request can't drop tensor
+ intent, so the env-aware guard dedupes instead of reload-looping (Codex #6659)."""
+ from models.inference import LoadRequest
+
+ inference_routes = _load_inference_routes_module()
+ monkeypatch.setenv("LLAMA_ARG_SPLIT_MODE", "tensor")
+
+ req = LoadRequest(model_path = "owner/repo", tensor_parallel = False)
+ assert "tensor_parallel" in req.model_fields_set
+ # env still forces tensor -> not a real drop -> dedupe (no reload loop).
+ assert (
+ inference_routes._request_matches_loaded_settings(
+ req, _fallback_loaded_backend(layer_preserves_tensor_intent = True)
+ )
+ is True
+ )
+
+
+def test_is_explicit_tensor_drop_truth_table():
+ """Only an explicit non-tensor --split-mode override is a drop. A bare
+ tensor_parallel field (the UI always sends it and echoes the fallback's false), an
+ empty clear, an unrelated extra (--top-k), or inherit (None) must NOT collapse a
+ preserved fallback; --split-mode tensor / tensor_parallel=true re-engage (Codex
+ #6659)."""
+ from models.inference import LoadRequest
+
+ f = _load_inference_routes_module()._is_explicit_tensor_drop
+ # A non-tensor split-mode override is the one deliberate departure -> drop.
+ assert (
+ f(LoadRequest(model_path = "owner/repo", llama_extra_args = ["--split-mode", "layer"])) is True
+ )
+ # tensor / retry re-engages, never a drop.
+ assert (
+ f(LoadRequest(model_path = "owner/repo", llama_extra_args = ["--split-mode", "tensor"]))
+ is False
+ )
+ # A bare tensor_parallel field is the UI echo, not a drop (would collapse on reload).
+ assert f(LoadRequest(model_path = "owner/repo", tensor_parallel = False)) is False
+ assert f(LoadRequest(model_path = "owner/repo", tensor_parallel = True)) is False
+ # Unrelated extra / empty clear / inherit all keep the preserved placement.
+ assert f(LoadRequest(model_path = "owner/repo", llama_extra_args = ["--top-k", "20"])) is False
+ assert f(LoadRequest(model_path = "owner/repo", llama_extra_args = [])) is False
+ assert f(LoadRequest(model_path = "owner/repo")) is False
+
+
+def test_explicit_tensor_drop_uses_shared_helper_in_both_readers():
+ """Both the already-loaded dedup and the load carry-forward derive the drop from
+ _is_explicit_tensor_drop, so they agree on what counts as a drop -- a reload for
+ an unrelated extra still carries the preserved intent rather than collapsing to one
+ GPU (Codex #6659)."""
+ src = (Path(_BACKEND_DIR) / "routes" / "inference.py").read_text()
+ # Dedup reader (the preserved-fallback reload guard).
+ assert "layer_preserves_tensor_intent and _is_explicit_tensor_drop(request)" in src
+ # Load carry-forward reader feeds the same decision into the carry-forward.
+ assert "_explicit_tensor_drop = _is_explicit_tensor_drop(request)" in src
+
+
+def test_layer_preserves_tensor_intent_set_only_on_preserved_downgrade():
+ """load_model latches the flag from _layer_min_gpus (raised only when a tensor
+ request is downgraded but kept multi-GPU), and clears it when tensor stays on."""
+ src = inspect.getsource(LlamaCppBackend.load_model)
+ on = src.find("self._tensor_parallel = True")
+ off = src.find("self._tensor_parallel = False")
+ assert 0 <= on and 0 <= off
+ assert "self._layer_preserves_tensor_intent = False" in src[on : on + 120]
+ assert "self._layer_preserves_tensor_intent = _layer_min_gpus > 1" in src[off : off + 400]
+
+
+def test_layer_min_gpus_bound_before_gpu_selection_try():
+ """_layer_min_gpus is bound before the GPU-selection try, so the --fit-on except
+ path can't UnboundLocalError when the command builder reads it (Codex #6659)."""
+ src = inspect.getsource(LlamaCppBackend.load_model)
+ assert src.count("_layer_min_gpus = 1") == 1, "exactly one init, before the try"
+ init = src.find("_layer_min_gpus = 1")
+ try_body = src.find("gguf_size = self._get_gguf_size_bytes")
+ fit_except = src.find("GPU selection failed")
+ use_after = src.find("self._layer_preserves_tensor_intent = _layer_min_gpus > 1")
+ assert (
+ -1 < init < try_body < fit_except < use_after
+ ), "the init must precede the try body, the except, and the command-builder use"
+
+
+def test_already_in_target_state_reloads_on_tensor_off_after_fallback():
+ """The backend fast path mirrors the route dedup: a preserved fallback reloads on
+ an EXPLICIT tensor-off request, but an implicit same-settings reload (carry-forward
+ preserve_multi_gpu_on_layer=True) still dedupes (Codex #6659)."""
+
+ def _backend(layer_preserves: bool) -> LlamaCppBackend:
+ b = _fallback_loaded_backend(layer_preserves_tensor_intent = layer_preserves)
+ b._process = _NoopProcess()
+ b._healthy = True
+ return b
+
+ kwargs = dict(
+ gguf_path = None,
+ mtp_draft_path = None,
+ model_identifier = "owner/repo",
+ hf_variant = None,
+ n_ctx = 0,
+ cache_type_kv = None,
+ speculative_type = None,
+ spec_draft_n_max = None,
+ tensor_parallel = False,
+ chat_template_override = None,
+ extra_args = ["--split-mode", "layer"],
+ is_vision = False,
+ )
+ # Preserved fallback + EXPLICIT tensor drop -> reload (not already in target state).
+ assert _backend(True)._already_in_target_state(**kwargs) is False
+ # Same preserved fallback but an implicit reload that carries the intent forward
+ # (HF auto-pick / local-dir flows skip the route guard and reach here) -> dedupe.
+ assert (
+ _backend(True)._already_in_target_state(**kwargs, preserve_multi_gpu_on_layer = True) is True
+ )
+ # A genuine layer load (no preserved intent) -> dedupe, no churn.
+ assert _backend(False)._already_in_target_state(**kwargs) is True
From 4c72e09480d5f0c21d6739c62b23e7d501464ffc Mon Sep 17 00:00:00 2001
From: Daniel Han
Date: Sat, 27 Jun 2026 05:21:05 -0700
Subject: [PATCH 10/31] Studio: stop handing CI/user secrets to downloaded
llama.cpp binaries (#6696)
* Studio: stop handing CI/user secrets to downloaded llama.cpp binaries
The macOS prebuilt path installs llama.cpp from the unslothai/llama.cpp
fork's latest (unpinned, mutable) release and then executes the
downloaded llama-server / llama-quantize binaries during install-time
validation. binary_env() built that child environment from a full
os.environ.copy(), so a compromised or tampered prebuilt would inherit
every secret in the process: HF_TOKEN and the workflow GitHub tokens in
CI, and HF / cloud credentials for end users running install.sh /
setup.sh.
We publish prebuilts daily, so pinning a release tag is not workable.
Instead, neutralise the impact: these binaries have no reason to read any
token, so strip secret-bearing variables (exact names plus
TOKEN/SECRET/PASSWORD/CREDENTIAL/PRIVATE_KEY/API_KEY markers) before
handing the env to a downloaded binary. The installer's own GitHub and
Hugging Face API calls read os.environ directly, so authentication and
release-API rate limiting are unaffected; PATH, LD_LIBRARY_PATH,
DYLD_LIBRARY_PATH and CUDA/ROCm vars are preserved. One change covers the
install-time validation path for all six macOS workflows and end users.
Follow-up (separate, sequenced): publish build-provenance attestations
from the fork's prebuilt workflows and verify them in CI, so a forged
release is rejected rather than merely starved of secrets.
* Strip KUBECONFIG, SSH_AUTH_SOCK, and PASSPHRASE-marked vars from binary env
Extend the deny-list per PR review: KUBECONFIG and SSH_AUTH_SOCK are
credential pointers/capabilities a downloaded binary never needs, and a
PASSPHRASE marker catches SSH_PASSPHRASE / GPG_PASSPHRASE. Tests updated.
* Studio: also scrub proxy/index env vars and URL-embedded credentials before running prebuilt binaries
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Scope mlx-ci secrets to the install + download commands for PR #6696
Drop the ambient step-level env block and pass GH/GITHUB/HF tokens only
on the installer and GGUF-download commands, so the directly invoked
llama-quantize / llama-server smoke runs see no secrets. The installer
still reads tokens from os.environ for the releases API and probe fetch.
* Trim verbose comments around the secret-env scrubber for PR #6696
Comment-only: condense the block comments added across this PR. Logic
unchanged (comment_tools.py check confirms code-only signature equal).
* Redirect HOME / cache pointers to an empty dir for prebuilt binaries (PR #6696)
Address Codex P2: stripping token env vars still let a tampered binary
read on-disk token stores (~/.cache/huggingface/token, ~/.aws/credentials,
~/.config/gh) through $HOME and the cache/config pointers. Point HOME plus
the HF / XDG / Windows home pointers at a single empty throwaway dir for
the downloaded-binary env. Defense in depth: a binary resolving the real
home via getpwuid is out of scope and needs OS sandboxing.
* Close residual credential-probe gaps for PR #6696
Address the latest Codex review:
- Strip token-only URL userinfo too (scheme://ghp_token@host), not just
the user:pass form.
- Redirect HOMEDRIVE/HOMEPATH alongside USERPROFILE so a Windows binary
cannot reconstruct the real profile from %HOMEDRIVE%%HOMEPATH%.
- Drop explicit credential-file pointers (NETRC, PIP_CONFIG_FILE,
DOCKER_CONFIG, GIT_CONFIG_GLOBAL) that live outside HOME.
- Probe ldd with a secret-free env: linux_runtime_dirs ran ldd on the
untrusted prebuilt with the inherited os.environ, and ldd may execute
the binary, so it could observe HF_TOKEN/GITHUB_TOKEN during the probe.
Factored the shared scrub into secret_free_environ().
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Separate token-bearing install from binary smoke; drop CI command files (PR #6696)
Address the two P1s in the latest review:
- mlx-ci: GitHub bakes secrets into the run-script text, so inline token
assignments in a step that later runs the prebuilt let a tampered binary
read them from the script. Split into a token-bearing install + download
step that never launches a binary, and a secret-free smoke step that runs
llama-quantize / llama-server.
- secret_free_environ now drops the GitHub Actions command files
(GITHUB_ENV, GITHUB_PATH, GITHUB_OUTPUT, GITHUB_STEP_SUMMARY, BASH_ENV) and
the smoke step unsets them, so a tampered prebuilt cannot inject PATH/env
into the later token-bearing MLX steps.
* Run the prebuilt smoke last, after all token-bearing steps (PR #6696)
Address the P1 workspace-poisoning vector: even with no secrets in its env,
a tampered prebuilt could edit the checkout or installed modules, and the
later HF_TOKEN MLX steps would then execute that poisoned code on push
builds. Move the prebuilt install + smoke to the end of the job so the
untrusted binary runs after every token-bearing step, leaving nothing for it
to corrupt. The MLX GGUF reload uses a source-built llama-cli, not this
prebuilt, so nothing depends on the earlier position.
* Trim comments around the secret-env scrubber and prebuilt CI steps (PR #6696)
Comment-only: condense the security-rationale block comments and merge the
duplicated prebuilt-step description in mlx-ci. Logic unchanged
(comment_tools.py check confirms the code-only signature is equal; install
suite still passes).
* Authenticate the GGUF export release-API lookup with the read-only GITHUB_TOKEN (PR #6696)
* Rename env scrubber off the secret-named identifier CodeQL flags as a clear-text sink (PR #6696)
---------
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
---
.github/workflows/mlx-ci.yml | 181 ++++++-------
studio/install_llama_prebuilt.py | 140 +++++++++-
.../test_install_llama_prebuilt_logic.py | 256 ++++++++++++++++++
3 files changed, 482 insertions(+), 95 deletions(-)
diff --git a/.github/workflows/mlx-ci.yml b/.github/workflows/mlx-ci.yml
index 864630f9f0..424a706d7c 100644
--- a/.github/workflows/mlx-ci.yml
+++ b/.github/workflows/mlx-ci.yml
@@ -231,99 +231,6 @@ jobs:
tests/studio/test_is_mlx_dispatch_gate.py \
tests/studio/test_mlx_training_worker_behaviors.py
- # Studio prebuilt llama.cpp install + GGUF inference. Mirrors the
- # path Studio's setup.sh takes on macOS since #5963: plan against
- # the unslothai/llama.cpp fork's latest release, which ships the
- # bin-macos-arm64 bundle plus the llama-prebuilt-manifest.json the
- # default policy reads. After install, downloads a small published
- # GGUF (unsloth/gemma-3-270m-it-GGUF, Q4_K_M) and validates
- # llama-server /completion end to end. An install failure or a
- # non-zero binary exit is an Unsloth/Studio bug.
- - name: Studio prebuilt llama.cpp install + GGUF inference (Mac M1)
- env:
- # Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
- HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
- # install_llama_prebuilt.py hits the GitHub releases API to
- # resolve the asset URL. Anonymous calls share the runner-IP
- # rate-limit bucket and 403 quickly -- pass the workflow's
- # automatic GITHUB_TOKEN to bump us to the 5000/hr authenticated
- # bucket.
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- set -euo pipefail
- INSTALL_DIR="$HOME/.unsloth-studio-prebuilt-test/llama.cpp"
- rm -rf "$INSTALL_DIR"
- # Mirror studio/setup.sh on macOS (the install.sh user path):
- # it plans against the unslothai/llama.cpp fork's latest
- # release with no policy or tag flags.
- python studio/install_llama_prebuilt.py \
- --install-dir "$INSTALL_DIR" \
- --published-repo unslothai/llama.cpp
-
- # Studio bundles only llama-server + llama-quantize from the
- # prebuilt (not llama-cli) -- inference goes through
- # llama-server's HTTP /completion endpoint. Validate both:
- # llama-quantize --help proves the dynamic libs link, then
- # spin up llama-server and POST a /completion request on a
- # tiny published GGUF.
- LLAMA_SERVER="$INSTALL_DIR/build/bin/llama-server"
- LLAMA_QUANT="$INSTALL_DIR/build/bin/llama-quantize"
- [ -x "$LLAMA_SERVER" ] || { echo "::error::llama-server missing at $LLAMA_SERVER"; find "$INSTALL_DIR/build" -type f | head -40; exit 1; }
- [ -x "$LLAMA_QUANT" ] || { echo "::error::llama-quantize missing at $LLAMA_QUANT"; exit 1; }
- echo "llama-server : $LLAMA_SERVER"
- echo "llama-quantize: $LLAMA_QUANT"
- "$LLAMA_QUANT" --help >/dev/null && echo " llama-quantize loads OK"
-
- mkdir -p /tmp/ggufs
- bash .github/scripts/hf-download-with-retry.sh \
- 'unsloth/gemma-3-270m-it-GGUF' \
- 'gemma-3-270m-it-Q4_K_M.gguf' \
- /tmp/ggufs
-
- PORT=18080
- echo "=== starting llama-server on 127.0.0.1:$PORT ==="
- "$LLAMA_SERVER" \
- -m /tmp/ggufs/gemma-3-270m-it-Q4_K_M.gguf \
- --host 127.0.0.1 \
- --port "$PORT" \
- -c 256 \
- -n 16 \
- --no-warmup \
- > /tmp/llama-server.log 2>&1 &
- SERVER_PID=$!
- trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT
-
- # Wait for /health to come up
- for i in $(seq 1 30); do
- if curl -sf "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then
- echo " server up after ${i}s"
- break
- fi
- sleep 1
- done
- if ! curl -sf "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then
- echo "::error::llama-server never became healthy"
- tail -40 /tmp/llama-server.log
- exit 1
- fi
-
- PROMPT="Hello, my name is"
- echo "=== POST /completion ==="
- RESP=$(curl -sf -X POST "http://127.0.0.1:$PORT/completion" \
- -H 'Content-Type: application/json' \
- -d "{\"prompt\":\"$PROMPT\",\"n_predict\":16,\"temperature\":0,\"seed\":3407}")
- echo "raw response (head): $(echo "$RESP" | head -c 600)"
- CONTENT=$(echo "$RESP" | python -c "import json,sys; print(json.loads(sys.stdin.read()).get('content',''))")
- echo "completion content: $CONTENT"
-
- if [ -z "$CONTENT" ]; then
- echo "::error::llama-server /completion returned empty content"
- tail -40 /tmp/llama-server.log
- exit 1
- fi
- echo "OK: Studio prebuilt llama.cpp on Mac M1 + GGUF /completion works"
-
# Real MLX training + inference smoke test. Trains
# unsloth/gemma-3-270m-it for 7 deterministic LoRA steps
# (batch_size=2, gradient_accumulation_steps=3) on a single
@@ -338,6 +245,9 @@ jobs:
UNSLOTH_COMPILE_DISABLE: '1'
run: |
mkdir -p mlx_workdir
+ # Authenticate llama.cpp's release-API lookup (anonymous 403s on rate-limit);
+ # read-only GITHUB_TOKEN scoped here only, never to steps that run binaries.
+ GH_TOKEN="${{ secrets.GITHUB_TOKEN }}" GITHUB_TOKEN="${{ secrets.GITHUB_TOKEN }}" \
python tests/studio/run_real_mlx_smoke.py train \
--workdir "$PWD/mlx_workdir"
@@ -406,3 +316,88 @@ jobs:
cat "$f" 2>/dev/null || echo "(missing)"
echo
done
+
+ # Validates the macOS prebuilt path Studio's setup.sh uses (#5963): install the
+ # unslothai/llama.cpp fork's latest release, download a small public GGUF, and
+ # check llama-server /completion end to end. Split and placed last so the
+ # untrusted binary runs only in the final smoke step, after every HF_TOKEN step,
+ # leaving no token-bearing step or shared workspace for a tampered prebuilt to
+ # corrupt. GH_TOKEN: releases API; HF_TOKEN (withheld on PR): probe + GGUF fetch.
+ - name: Studio prebuilt llama.cpp install + GGUF download (Mac M1)
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
+ run: |
+ set -euo pipefail
+ INSTALL_DIR="$HOME/.unsloth-studio-prebuilt-test/llama.cpp"
+ rm -rf "$INSTALL_DIR"
+ # Download only -- no llama-quantize / llama-server launch in this step.
+ python studio/install_llama_prebuilt.py \
+ --install-dir "$INSTALL_DIR" \
+ --published-repo unslothai/llama.cpp
+ mkdir -p /tmp/ggufs
+ bash .github/scripts/hf-download-with-retry.sh \
+ 'unsloth/gemma-3-270m-it-GGUF' \
+ 'gemma-3-270m-it-Q4_K_M.gguf' \
+ /tmp/ggufs
+
+ # Final step: runs the downloaded binaries with no secrets present, and clears
+ # the GitHub Actions command files so a tampered prebuilt cannot influence the job.
+ - name: Studio prebuilt llama.cpp GGUF inference smoke (Mac M1)
+ run: |
+ set -euo pipefail
+ unset GITHUB_ENV GITHUB_PATH GITHUB_OUTPUT GITHUB_STEP_SUMMARY
+ INSTALL_DIR="$HOME/.unsloth-studio-prebuilt-test/llama.cpp"
+ # Studio bundles only llama-server + llama-quantize (not llama-cli);
+ # inference goes through llama-server's HTTP /completion endpoint.
+ LLAMA_SERVER="$INSTALL_DIR/build/bin/llama-server"
+ LLAMA_QUANT="$INSTALL_DIR/build/bin/llama-quantize"
+ [ -x "$LLAMA_SERVER" ] || { echo "::error::llama-server missing at $LLAMA_SERVER"; find "$INSTALL_DIR/build" -type f | head -40; exit 1; }
+ [ -x "$LLAMA_QUANT" ] || { echo "::error::llama-quantize missing at $LLAMA_QUANT"; exit 1; }
+ echo "llama-server : $LLAMA_SERVER"
+ echo "llama-quantize: $LLAMA_QUANT"
+ "$LLAMA_QUANT" --help >/dev/null && echo " llama-quantize loads OK"
+
+ PORT=18080
+ echo "=== starting llama-server on 127.0.0.1:$PORT ==="
+ "$LLAMA_SERVER" \
+ -m /tmp/ggufs/gemma-3-270m-it-Q4_K_M.gguf \
+ --host 127.0.0.1 \
+ --port "$PORT" \
+ -c 256 \
+ -n 16 \
+ --no-warmup \
+ > /tmp/llama-server.log 2>&1 &
+ SERVER_PID=$!
+ trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT
+
+ # Wait for /health to come up
+ for i in $(seq 1 30); do
+ if curl -sf "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then
+ echo " server up after ${i}s"
+ break
+ fi
+ sleep 1
+ done
+ if ! curl -sf "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then
+ echo "::error::llama-server never became healthy"
+ tail -40 /tmp/llama-server.log
+ exit 1
+ fi
+
+ PROMPT="Hello, my name is"
+ echo "=== POST /completion ==="
+ RESP=$(curl -sf -X POST "http://127.0.0.1:$PORT/completion" \
+ -H 'Content-Type: application/json' \
+ -d "{\"prompt\":\"$PROMPT\",\"n_predict\":16,\"temperature\":0,\"seed\":3407}")
+ echo "raw response (head): $(echo "$RESP" | head -c 600)"
+ CONTENT=$(echo "$RESP" | python -c "import json,sys; print(json.loads(sys.stdin.read()).get('content',''))")
+ echo "completion content: $CONTENT"
+
+ if [ -z "$CONTENT" ]; then
+ echo "::error::llama-server /completion returned empty content"
+ tail -40 /tmp/llama-server.log
+ exit 1
+ fi
+ echo "OK: Studio prebuilt llama.cpp on Mac M1 + GGUF /completion works"
diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py
index c7f34e39f2..e40cb3083e 100644
--- a/studio/install_llama_prebuilt.py
+++ b/studio/install_llama_prebuilt.py
@@ -7,6 +7,7 @@
from __future__ import annotations
import argparse
+import atexit
import errno
import fnmatch
import hashlib
@@ -5203,7 +5204,8 @@ def ldconfig_runtime_dirs(required_libraries: Iterable[str]) -> list[str]:
def linux_runtime_dirs(binary_path: Path) -> list[str]:
- missing = linux_missing_libraries(binary_path)
+ # ldd may execute the binary, so probe it with a secret-free env.
+ missing = linux_missing_libraries(binary_path, env = scrubbed_environ())
if not missing:
return []
return linux_runtime_dirs_for_required_libraries(missing)
@@ -5499,6 +5501,140 @@ def _wsl_system_rocm_lib_dirs() -> list[str]:
return out
+# Secrets a downloaded llama.cpp binary never needs; keep them out of binary_env().
+# The installer's own API calls read os.environ directly, so auth is unaffected.
+_SECRET_ENV_EXACT_NAMES = frozenset(
+ {
+ "HF_TOKEN",
+ "HUGGING_FACE_HUB_TOKEN",
+ "GH_TOKEN",
+ "GITHUB_TOKEN",
+ "WANDB_API_KEY",
+ "OPENAI_API_KEY",
+ "ANTHROPIC_API_KEY",
+ "AWS_ACCESS_KEY_ID",
+ "AWS_SECRET_ACCESS_KEY",
+ "AWS_SESSION_TOKEN",
+ "GOOGLE_APPLICATION_CREDENTIALS",
+ "AZURE_CLIENT_SECRET",
+ "ACTIONS_ID_TOKEN_REQUEST_TOKEN",
+ "ACTIONS_ID_TOKEN_REQUEST_URL",
+ "ACTIONS_RUNTIME_TOKEN",
+ # Credential pointers (cluster / remote-host access).
+ "KUBECONFIG",
+ "SSH_AUTH_SOCK",
+ }
+)
+# Case-insensitive substring markers for names we do not enumerate (no bare "KEY",
+# which would hit benign runtime vars).
+_SECRET_ENV_MARKERS = (
+ "TOKEN",
+ "SECRET",
+ "PASSWORD",
+ "PASSWD",
+ "PASSPHRASE",
+ "CREDENTIAL",
+ "PRIVATE_KEY",
+ "API_KEY",
+)
+# Proxy / index URLs embed creds in their value; the offline binaries never need them.
+_SECRET_ENV_URL_NAMES = frozenset(
+ {
+ "HTTP_PROXY",
+ "HTTPS_PROXY",
+ "ALL_PROXY",
+ "FTP_PROXY",
+ "RSYNC_PROXY",
+ "PIP_INDEX_URL",
+ "PIP_EXTRA_INDEX_URL",
+ "UV_INDEX_URL",
+ "UV_DEFAULT_INDEX",
+ "UV_EXTRA_INDEX_URL",
+ }
+)
+# Also drop values with URL userinfo creds (scheme://user:secret@host or token@host).
+_URL_USERINFO_CREDENTIAL_RE = re.compile(r"://[^/@\s]+@")
+
+
+def is_secret_env_name(name: str) -> bool:
+ upper = name.upper()
+ return (
+ upper in _SECRET_ENV_EXACT_NAMES
+ or upper in _SECRET_ENV_URL_NAMES
+ or any(marker in upper for marker in _SECRET_ENV_MARKERS)
+ )
+
+
+def scrub_env(env: dict[str, str]) -> dict[str, str]:
+ """Drop secret-bearing variables before handing an env to a downloaded binary."""
+ return {
+ key: value
+ for key, value in env.items()
+ if not is_secret_env_name(key) and not _URL_USERINFO_CREDENTIAL_RE.search(value or "")
+ }
+
+
+# Home / cache pointers to on-disk token stores (~/.cache/huggingface/token,
+# ~/.aws/credentials, ...). Stripping env tokens is not enough; point these at an
+# empty home so the binary cannot read those files via $HOME.
+_RUNTIME_HOME_POINTER_VARS = (
+ "HOME",
+ "USERPROFILE",
+ "APPDATA",
+ "LOCALAPPDATA",
+ "XDG_CACHE_HOME",
+ "XDG_CONFIG_HOME",
+ "XDG_DATA_HOME",
+ "HF_HOME",
+ "HUGGINGFACE_HUB_CACHE",
+ "HF_HUB_CACHE",
+)
+# Credential / config file pointers outside HOME; drop so lookups fall back to the
+# empty home.
+_CREDENTIAL_FILE_POINTER_VARS = (
+ "NETRC",
+ "PIP_CONFIG_FILE",
+ "DOCKER_CONFIG",
+ "GIT_CONFIG_GLOBAL",
+)
+# GitHub Actions command files: appending to these injects PATH/env into later steps.
+_CI_COMMAND_FILE_VARS = (
+ "GITHUB_ENV",
+ "GITHUB_PATH",
+ "GITHUB_OUTPUT",
+ "GITHUB_STEP_SUMMARY",
+ "BASH_ENV",
+)
+
+_isolated_runtime_home_dir: str | None = None
+
+
+def isolated_runtime_home() -> str:
+ # Empty dir, created lazily and removed at exit. (A binary resolving the real
+ # home via getpwuid is out of scope; that needs OS sandboxing.)
+ global _isolated_runtime_home_dir
+ if _isolated_runtime_home_dir is None:
+ path = tempfile.mkdtemp(prefix = "unsloth-prebuilt-home-")
+ atexit.register(shutil.rmtree, path, ignore_errors = True)
+ _isolated_runtime_home_dir = path
+ return _isolated_runtime_home_dir
+
+
+def scrubbed_environ() -> dict[str, str]:
+ # os.environ minus secrets, with home / credential pointers neutralised. Used for
+ # the binary env and any probe (e.g. ldd) that runs the untrusted binary.
+ env = scrub_env(os.environ.copy())
+ runtime_home = isolated_runtime_home()
+ for pointer in _RUNTIME_HOME_POINTER_VARS:
+ env[pointer] = runtime_home
+ # Windows rebuilds the profile from %HOMEDRIVE%%HOMEPATH% (no-op pair on POSIX).
+ drive, tail = os.path.splitdrive(runtime_home)
+ env["HOMEDRIVE"], env["HOMEPATH"] = drive, tail or runtime_home
+ for pointer in (*_CREDENTIAL_FILE_POINTER_VARS, *_CI_COMMAND_FILE_VARS):
+ env.pop(pointer, None)
+ return env
+
+
def binary_env(
binary_path: Path,
install_dir: Path,
@@ -5506,7 +5642,7 @@ def binary_env(
*,
runtime_line: str | None = None,
) -> dict[str, str]:
- env = os.environ.copy()
+ env = scrubbed_environ()
if host.is_windows:
path_dirs = [
str(binary_path.parent),
diff --git a/tests/studio/install/test_install_llama_prebuilt_logic.py b/tests/studio/install/test_install_llama_prebuilt_logic.py
index 9ee8759bb4..c852d7c495 100644
--- a/tests/studio/install/test_install_llama_prebuilt_logic.py
+++ b/tests/studio/install/test_install_llama_prebuilt_logic.py
@@ -22,6 +22,9 @@ SPEC.loader.exec_module(INSTALL_LLAMA_PREBUILT)
PrebuiltFallback = INSTALL_LLAMA_PREBUILT.PrebuiltFallback
extract_archive = INSTALL_LLAMA_PREBUILT.extract_archive
binary_env = INSTALL_LLAMA_PREBUILT.binary_env
+is_secret_env_name = INSTALL_LLAMA_PREBUILT.is_secret_env_name
+scrub_env = INSTALL_LLAMA_PREBUILT.scrub_env
+isolated_runtime_home = INSTALL_LLAMA_PREBUILT.isolated_runtime_home
HostInfo = INSTALL_LLAMA_PREBUILT.HostInfo
AssetChoice = INSTALL_LLAMA_PREBUILT.AssetChoice
ApprovedArtifactHash = INSTALL_LLAMA_PREBUILT.ApprovedArtifactHash
@@ -779,6 +782,259 @@ def test_binary_env_linux_includes_binary_parent_in_ld_library_path(
assert str(install_dir) in ld_dirs
+def test_scrub_env_drops_secrets_and_keeps_runtime_vars():
+ raw = {
+ # secrets
+ "HF_TOKEN": "hf_x",
+ "HUGGING_FACE_HUB_TOKEN": "hf_y",
+ "GH_TOKEN": "gh_x",
+ "GITHUB_TOKEN": "gh_y",
+ "WANDB_API_KEY": "wandb_x",
+ "AWS_SECRET_ACCESS_KEY": "aws_x",
+ "ACTIONS_ID_TOKEN_REQUEST_TOKEN": "oidc_x",
+ "ACTIONS_ID_TOKEN_REQUEST_URL": "https://oidc",
+ "SOME_VENDOR_API_KEY": "vendor_x",
+ "DB_PASSWORD": "pw",
+ "MY_PRIVATE_KEY": "pk",
+ "KUBECONFIG": "/home/runner/.kube/config",
+ "SSH_AUTH_SOCK": "/tmp/ssh-agent.sock",
+ "SSH_PASSPHRASE": "ssh_pass",
+ # runtime vars to keep
+ "PATH": "/usr/bin",
+ "LD_LIBRARY_PATH": "/opt/lib",
+ "DYLD_LIBRARY_PATH": "/opt/dyld",
+ "HOME": "/home/runner",
+ "TMPDIR": "/tmp",
+ "CUDA_VISIBLE_DEVICES": "0",
+ "HSA_OVERRIDE_GFX_VERSION": "11.0.0",
+ }
+
+ cleaned = scrub_env(raw)
+
+ for secret in (
+ "HF_TOKEN",
+ "HUGGING_FACE_HUB_TOKEN",
+ "GH_TOKEN",
+ "GITHUB_TOKEN",
+ "WANDB_API_KEY",
+ "AWS_SECRET_ACCESS_KEY",
+ "ACTIONS_ID_TOKEN_REQUEST_TOKEN",
+ "ACTIONS_ID_TOKEN_REQUEST_URL",
+ "SOME_VENDOR_API_KEY",
+ "DB_PASSWORD",
+ "MY_PRIVATE_KEY",
+ "KUBECONFIG",
+ "SSH_AUTH_SOCK",
+ "SSH_PASSPHRASE",
+ ):
+ assert secret not in cleaned, f"{secret} must be stripped from binary env"
+
+ for keep in (
+ "PATH",
+ "LD_LIBRARY_PATH",
+ "DYLD_LIBRARY_PATH",
+ "HOME",
+ "TMPDIR",
+ "CUDA_VISIBLE_DEVICES",
+ "HSA_OVERRIDE_GFX_VERSION",
+ ):
+ assert cleaned[keep] == raw[keep], f"{keep} must be preserved for the binary"
+
+ # no bare "KEY" marker: benign KEY-containing names survive
+ assert is_secret_env_name("API_KEY") is True
+ assert is_secret_env_name("SSH_KEYFILE_PATH") is False
+ assert is_secret_env_name("PATH") is False
+
+
+def test_scrub_env_drops_proxy_index_and_embedded_url_credentials():
+ raw = {
+ # proxy / package-index URLs whose values commonly embed credentials
+ "HTTPS_PROXY": "https://user:secret@proxy:8080",
+ "https_proxy": "https://user:secret@proxy:8080", # lower-case variant
+ "ALL_PROXY": "socks5://user:secret@proxy:1080",
+ "PIP_INDEX_URL": "https://u:p@pypi.internal/simple",
+ "UV_INDEX_URL": "https://u:p@index.internal/simple",
+ # credentials embedded in an otherwise benign-named variable's value
+ "MY_DB_DSN": "postgres://admin:secret@db:5432/app",
+ # benign vars the binary needs, including a URL with no userinfo
+ "PATH": "/usr/bin",
+ "CUDA_VISIBLE_DEVICES": "0",
+ "NO_PROXY": "localhost,127.0.0.1",
+ "SOME_ENDPOINT": "https://example.com:8080/v1",
+ }
+
+ cleaned = scrub_env(raw)
+
+ for secret in (
+ "HTTPS_PROXY",
+ "https_proxy",
+ "ALL_PROXY",
+ "PIP_INDEX_URL",
+ "UV_INDEX_URL",
+ "MY_DB_DSN",
+ ):
+ assert secret not in cleaned, f"{secret} must be stripped from binary env"
+ for keep in ("PATH", "CUDA_VISIBLE_DEVICES", "NO_PROXY", "SOME_ENDPOINT"):
+ assert cleaned[keep] == raw[keep], f"{keep} must be preserved for the binary"
+
+ assert is_secret_env_name("HTTPS_PROXY") is True
+ assert is_secret_env_name("https_proxy") is True
+ assert is_secret_env_name("NO_PROXY") is False
+
+
+def test_binary_env_strips_secrets_from_downloaded_binary_environment(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+):
+ install_dir = tmp_path / "llama.cpp"
+ bin_dir = install_dir / "build" / "bin"
+ bin_dir.mkdir(parents = True)
+ binary_path = bin_dir / "llama-server"
+ binary_path.write_bytes(b"fake")
+
+ host = HostInfo(
+ system = "Linux",
+ machine = "x86_64",
+ is_windows = False,
+ is_linux = True,
+ is_macos = False,
+ is_x86_64 = True,
+ is_arm64 = False,
+ nvidia_smi = None,
+ driver_cuda_version = None,
+ compute_caps = [],
+ visible_cuda_devices = None,
+ has_physical_nvidia = False,
+ has_usable_nvidia = False,
+ )
+ monkeypatch.setattr(INSTALL_LLAMA_PREBUILT, "linux_runtime_dirs", lambda _bp: [])
+
+ monkeypatch.setenv("HF_TOKEN", "hf_secret_from_ci")
+ monkeypatch.setenv("GITHUB_TOKEN", "gh_secret_from_ci")
+ monkeypatch.setenv("GH_TOKEN", "gh_secret_from_ci")
+ monkeypatch.setenv("WANDB_API_KEY", "wandb_secret_from_ci")
+ monkeypatch.setenv("CUDA_VISIBLE_DEVICES", "1")
+
+ env = binary_env(binary_path, install_dir, host)
+
+ assert "HF_TOKEN" not in env
+ assert "GITHUB_TOKEN" not in env
+ assert "GH_TOKEN" not in env
+ assert "WANDB_API_KEY" not in env
+ # library/runtime resolution unaffected
+ assert str(bin_dir) in env["LD_LIBRARY_PATH"].split(os.pathsep)
+ assert env["CUDA_VISIBLE_DEVICES"] == "1"
+
+
+def test_binary_env_redirects_home_away_from_real_credential_stores(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+):
+ install_dir = tmp_path / "llama.cpp"
+ bin_dir = install_dir / "build" / "bin"
+ bin_dir.mkdir(parents = True)
+ binary_path = bin_dir / "llama-server"
+ binary_path.write_bytes(b"fake")
+
+ host = HostInfo(
+ system = "Linux",
+ machine = "x86_64",
+ is_windows = False,
+ is_linux = True,
+ is_macos = False,
+ is_x86_64 = True,
+ is_arm64 = False,
+ nvidia_smi = None,
+ driver_cuda_version = None,
+ compute_caps = [],
+ visible_cuda_devices = None,
+ has_physical_nvidia = False,
+ has_usable_nvidia = False,
+ )
+ monkeypatch.setattr(INSTALL_LLAMA_PREBUILT, "linux_runtime_dirs", lambda _bp: [])
+
+ real_home = str(tmp_path / "real_home")
+ monkeypatch.setenv("HOME", real_home)
+ monkeypatch.setenv("HF_HOME", real_home + "/.cache/huggingface")
+
+ env = binary_env(binary_path, install_dir, host)
+
+ # HOME and the cache pointers are redirected to a single empty, existing dir.
+ assert env["HOME"] != real_home
+ assert env["HF_HOME"] == env["HOME"]
+ assert env["HOME"] == isolated_runtime_home()
+ assert os.path.isdir(env["HOME"])
+ assert os.listdir(env["HOME"]) == []
+ # Windows reconstructs the profile from HOMEDRIVE + HOMEPATH.
+ assert env["HOMEDRIVE"] + env["HOMEPATH"] == env["HOME"]
+
+
+def test_scrub_env_drops_token_only_url_userinfo():
+ raw = {
+ "GENERIC_REPO": "https://ghp_tokenonly@github.com/org/repo",
+ "GENERIC_OK": "https://example.com:8080/v1",
+ }
+ cleaned = scrub_env(raw)
+ assert "GENERIC_REPO" not in cleaned
+ assert cleaned["GENERIC_OK"] == raw["GENERIC_OK"]
+
+
+def test_binary_env_drops_explicit_credential_file_pointers(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+):
+ host = HostInfo(
+ system = "Linux",
+ machine = "x86_64",
+ is_windows = False,
+ is_linux = True,
+ is_macos = False,
+ is_x86_64 = True,
+ is_arm64 = False,
+ nvidia_smi = None,
+ driver_cuda_version = None,
+ compute_caps = [],
+ visible_cuda_devices = None,
+ has_physical_nvidia = False,
+ has_usable_nvidia = False,
+ )
+ monkeypatch.setattr(INSTALL_LLAMA_PREBUILT, "linux_runtime_dirs", lambda _bp: [])
+ dropped = (
+ "NETRC",
+ "PIP_CONFIG_FILE",
+ "DOCKER_CONFIG",
+ "GIT_CONFIG_GLOBAL",
+ "GITHUB_ENV",
+ "GITHUB_PATH",
+ "GITHUB_OUTPUT",
+ "GITHUB_STEP_SUMMARY",
+ "BASH_ENV",
+ )
+ for var in dropped:
+ monkeypatch.setenv(var, "/home/realuser/secret")
+
+ env = binary_env(tmp_path / "llama-server", tmp_path, host)
+
+ for var in dropped:
+ assert var not in env
+
+
+def test_linux_runtime_dirs_probes_with_secret_free_env(monkeypatch: pytest.MonkeyPatch):
+ captured: dict[str, object] = {}
+
+ def fake_missing(binary_path, *, env = None):
+ captured["env"] = env
+ return []
+
+ monkeypatch.setattr(INSTALL_LLAMA_PREBUILT, "linux_missing_libraries", fake_missing)
+ monkeypatch.setenv("HF_TOKEN", "hf_secret")
+ monkeypatch.setenv("GITHUB_TOKEN", "gh_secret")
+
+ INSTALL_LLAMA_PREBUILT.linux_runtime_dirs(Path("/fake/llama-server"))
+
+ probe_env = captured["env"]
+ assert probe_env is not None
+ assert "HF_TOKEN" not in probe_env
+ assert "GITHUB_TOKEN" not in probe_env
+
+
def test_install_prebuilt_falls_back_to_older_release_plan(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
From 0ad814a45228999d95857f8e38a484f9ce107c92 Mon Sep 17 00:00:00 2001
From: Daniel Han
Date: Sat, 27 Jun 2026 17:48:16 -0700
Subject: [PATCH 11/31] =?UTF-8?q?Revert=20"feat:=20add=20GPU-aware=20model?=
=?UTF-8?q?=20filtering=20and=20For=20You=20section-=20Add=20fit=20filt?=
=?UTF-8?q?=E2=80=A6"=20(#6722)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This reverts commit a636693019f33c1acf9477dbd4ad714792683c74.
---
.../model-selector/recommended-fit.ts | 39 +++------
.../src/features/hub/catalog/model-card.tsx | 26 ++----
.../src/features/hub/catalog/models-table.tsx | 46 ----------
.../features/hub/catalog/models-toolbar.tsx | 24 ------
studio/frontend/src/features/hub/hub-page.tsx | 85 +++----------------
.../src/features/hub/lib/gpu-fit-filter.ts | 80 -----------------
.../src/features/hub/lib/view-models.ts | 10 ---
studio/frontend/src/features/hub/types.ts | 4 -
8 files changed, 27 insertions(+), 287 deletions(-)
delete mode 100644 studio/frontend/src/features/hub/lib/gpu-fit-filter.ts
diff --git a/studio/frontend/src/components/assistant-ui/model-selector/recommended-fit.ts b/studio/frontend/src/components/assistant-ui/model-selector/recommended-fit.ts
index cc96665df4..24f0edc784 100644
--- a/studio/frontend/src/components/assistant-ui/model-selector/recommended-fit.ts
+++ b/studio/frontend/src/components/assistant-ui/model-selector/recommended-fit.ts
@@ -64,38 +64,19 @@ export function matchesFormatFilter(
}
}
-// Model-size extraction from repo id, matching the backend's 3-regex priority:
-// active params (MoE "A3B") > effective params (Gemma "E4B") > total ("8B").
-// Bounded by separators so we never read "16" from "bf16" or "2" from "Kimi-K2".
-// Examples: "Qwen3.5-35B-A3B" -> 3, "gemma-4-E4B" -> 4, "Llama-3-8B" -> 8.
-const ACTIVE_PARAM_RE = /(?:^|[-_/. ])a(\d+(?:\.\d+)?)\s*[bB](?=$|[-_/. ])/i;
-const EFFECTIVE_PARAM_RE = /(?:^|[-_/. ])e(\d+(?:\.\d+)?)\s*[bB](?=$|[-_/. ])/i;
-const TOTAL_PARAM_RE = /(?:^|[-_/. ])(\d+(?:\.\d+)?)\s*[bB](?=$|[-_/. ])/;
-
-function paramsFromMatch(match: RegExpExecArray | null): number | undefined {
- if (!match) return undefined;
- const billions = parseFloat(match[1]);
- return Number.isFinite(billions) && billions > 0
- ? billions * 1e9
- : undefined;
-}
-
-/** Active/effective parameter count parsed from a repo id, if it uses explicit
- * MoE/Gemma-style notation such as A3B or E4B. */
-export function activeOrEffectiveParamsFromId(id: string): number | undefined {
- return (
- paramsFromMatch(ACTIVE_PARAM_RE.exec(id)) ??
- paramsFromMatch(EFFECTIVE_PARAM_RE.exec(id))
- );
-}
+// First "B" token in a repo id, e.g. "Qwen3-4B-GGUF" -> 4, "gpt-oss-20b" ->
+// 20, "Qwen3-30B-A3B" -> 30 (MoE total), "gemma-4-E4B" -> 4 (effective-param
+// "E" series). The digits must be bounded by a separator so we never read "16"
+// from "bf16" or the "2" in "Kimi-K2".
+const PARAM_RE = /(?:^|[-_/. ])[eE]?(\d+(?:\.\d+)?)\s*[bB](?=$|[-_./ ])/;
/** Parameter count (absolute, e.g. 4e9) parsed from a repo id, or undefined
- * when the id has no size token (so callers can treat the size as unknown).
- * Prefers MoE active-param notation (A3B) over effective (E4B) over total. */
+ * when the id has no size token (so callers can treat the size as unknown). */
export function paramsFromId(id: string): number | undefined {
- return (
- activeOrEffectiveParamsFromId(id) ?? paramsFromMatch(TOTAL_PARAM_RE.exec(id))
- );
+ const match = PARAM_RE.exec(id);
+ if (!match) return undefined;
+ const billions = parseFloat(match[1]);
+ return Number.isFinite(billions) && billions > 0 ? billions * 1e9 : undefined;
}
// Smallest practical GGUF/MLX quant (~Q2_K, low-bit). The fit check asks whether
diff --git a/studio/frontend/src/features/hub/catalog/model-card.tsx b/studio/frontend/src/features/hub/catalog/model-card.tsx
index c3c526cac3..358a3409b5 100644
--- a/studio/frontend/src/features/hub/catalog/model-card.tsx
+++ b/studio/frontend/src/features/hub/catalog/model-card.tsx
@@ -11,7 +11,7 @@ import { ownerPaletteColor } from "@/features/hub/lib/avatar-theme";
import { buildAdaptiveCardAccentStyle } from "@/features/hub/lib/card-accent";
import { useDominantColor } from "@/features/hub/lib/use-dominant-color";
import { formatModelParamLabel } from "@/features/hub/lib/view-models";
-import { cn, formatCompact } from "@/lib/utils";
+import { formatCompact } from "@/lib/utils";
import { Download01Icon, FavouriteIcon } from "@hugeicons/core-free-icons";
import { type CSSProperties, memo, useMemo } from "react";
import type { DiscoverRow } from "../types";
@@ -339,25 +339,11 @@ export const ModelCard = memo(function ModelCard({
value={formatCompact(row.result.likes)}
/>
-