Studio: improve AMD NPU discovery, CLI support, and chat feedback by oobabooga · Pull Request #13797 · unslothai/unsloth · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
c2a09e1
Studio: improve AMD NPU discovery, CLI support, and chat feedback
oobabooga Oct 15, 2026
5906fa6
Keep NPU auto-switches behind the GGUF switch safeguards
oobabooga Oct 15, 2026
b6f1f81
Trim comments in the NPU changes
oobabooga Oct 15, 2026
10d2d3c
Apply the saved context length when switching to an NPU model
oobabooga Oct 15, 2026
d0f51d0
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 15, 2026
6ba050a
Keep deferred NPU switches preview-owned and honor the entry setting
oobabooga Oct 15, 2026
3f303f6
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 15, 2026
0036174
Refuse NPU chat requests before switching and load bare NPU ids by path
oobabooga Oct 15, 2026
c7c99e0
Accept --verbose for NPU runs
oobabooga Oct 15, 2026
f9fd764
Refuse NPU models in unsloth inference without a running Studio
oobabooga Oct 15, 2026
ac74d98
Cancel an NPU load on Stop and report lost Studio connections from th…
oobabooga Oct 15, 2026
82a2e2b
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 15, 2026
d0c29a9
Refuse an MCP image before switching to an NPU model
danielhanchen Oct 15, 2026
75d3f5d
Count the NPU switch check among the MCP image guards
danielhanchen Oct 15, 2026
2bac7cb
Recheck the sidecar swap and shutdown latch before an NPU load unloads
oobabooga Oct 15, 2026
25bcd4d
Remove the FastFlowLM / Lemonade credit line from the NPU notices
danielhanchen Oct 15, 2026
f39640f
Detect a full NPU context from the KV position, not the token count
danielhanchen Oct 15, 2026
d5f12ed
Merge branch 'main' of https://github.com/unslothai/unsloth into r13797
danielhanchen Oct 15, 2026
4d3d1cc
Keep asking to cancel an NPU load until it is registered
oobabooga Oct 15, 2026
1b9b726
Keep the AMD NPU off unless UNSLOTH_STUDIO_ENABLE_NPU=1
danielhanchen Oct 15, 2026
cf76fa6
Merge branch 'main' of https://github.com/unslothai/unsloth into r13797
danielhanchen Oct 15, 2026
0730f3b
Merge branch 'main' of https://github.com/unslothai/unsloth into r13797
danielhanchen Oct 15, 2026
a74b8f4
Restore tests/test_triton_fp_fusion.py to main's formatting
danielhanchen Oct 15, 2026
2d2de23
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 15, 2026
e6c658f
Merge branch 'main' of https://github.com/unslothai/unsloth into r13797
danielhanchen Oct 15, 2026
9be1e28
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 15, 2026
3c2c514
Merge main into studio-npu-ux
oobabooga Oct 16, 2026
adbd37a
Refuse an NPU switch on every route but chat
oobabooga Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions studio/backend/core/inference/npu_backend.py
335 changes: 288 additions & 47 deletions studio/backend/routes/inference.py

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions studio/backend/tests/test_mcp_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -683,14 +683,15 @@ def test_every_local_exit_without_a_tool_loop_refuses_the_image():
"_refuse_unused_mcp_image(_mcp_image, _catalog_names(_sf_tools_to_use) if _sf_use_tools else None)",
):
assert guard in statements
# NPU, both speech models, audio input and the GGUF passthrough return before either loop.
# The NPU switch check, NPU, both speech models, audio input and the GGUF passthrough
# return before either loop.
calls = [
node
for node in ast.walk(ast.parse(src))
if isinstance(node, ast.Call)
and getattr(node.func, "id", None) == "_refuse_unused_mcp_image"
]
assert len(calls) == 7
assert len(calls) == 8
# The forced image approval parks the GGUF stream, so the slot must be tracked for reclaim.
slot = [
ast.unparse(node.value)
Expand Down
17 changes: 17 additions & 0 deletions studio/backend/tests/test_npu_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,23 @@ def test_unsupported_npu_is_refused_before_any_download(tmp_path, monkeypatch):
assert installer.installs == 0


@pytest.mark.parametrize("call", ["enable", "catalog"])
def test_an_npu_left_off_is_refused_with_the_flag_named(tmp_path, monkeypatch, call):
# catalog: a runtime enabled before the flag went off must not start again.
installer = _Installer(_binary(tmp_path))
monkeypatch.setattr(nb, "_installer_module", lambda: installer)
monkeypatch.setattr(
nb,
"detect_amd_npu",
lambda: {"present": True, "enabled": False, "supported": False, "family": "XDNA2"},
)
backend = nb.LemonadeNpuBackend(root = tmp_path / "lemonade")
with pytest.raises(nb.NpuError, match = "UNSLOTH_STUDIO_ENABLE_NPU=1"):
getattr(backend, call)()
assert installer.installs == 0
assert backend.status()["supported"] is False


def test_catalog_keeps_flm_chat_models_only(npu):
npu.enable()
ids = [model.id for model in npu.catalog()]
Expand Down
240 changes: 240 additions & 0 deletions studio/backend/tests/test_npu_chat_route.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,38 @@ def _chunk(
}


# The end of a reply whose reasoning filled the loaded context, captured on Strix Halo: FastFlowLM
# says "stop" with the KV cache full.
_FULL_USAGE = {
"prompt_tokens": 45,
"completion_tokens": 8147,
"total_tokens": 8192,
"active_kv_tokens": 8192,
"max_kv_token_capacity": 8192,
"kv_token_occupancy_rate_percentage": 100.0,
}


def _script(prompt: str) -> str:
if "FULL" in prompt:
return (
_chunk({"reasoning_content": " Wait, no!"})
+ _chunk({"content": None}, "stop", _FULL_USAGE)
+ "data: [DONE]\n\n"
)
if "WSTOOL" in prompt:
call = {
"index": 0,
"id": "call_1",
"type": "function",
"function": {"name": "get_weather", "arguments": '{"city":"Paris"}'},
}
return (
_chunk({"content": "\n\n\n"})
+ _chunk({"tool_calls": [call]})
+ _chunk({}, "tool_calls", _USAGE)
+ "data: [DONE]\n\n"
)
if "FAULT" in prompt:
return (
_chunk({"content": "par"})
Expand Down Expand Up @@ -826,6 +857,161 @@ async def _unload_gpu(_backend):
assert teardowns == []


def test_an_npu_load_drains_active_inference_before_unloading(monkeypatch):
# An auto-switch passes no on_reload_confirmed, so without the drain it cut a reply short.
import routes.inference as routes
from core.inference import npu_backend as nb
from models.inference import LoadRequest

order: list[str] = []
model = SimpleNamespace(id = "qwen3-0.6b-FLM", model_path = "lemonade:qwen3-0.6b-FLM")

class _Npu:
def resident(self):
return None

def loadable_model(self, model_id):
return model

def load(self, model_id, ctx):
order.append("load")

async def _drain(**kwargs):
order.append("drain")

async def _unload_gpu(_backend):
order.append("unload")

monkeypatch.setattr(nb, "get_npu_backend", lambda: _Npu())
monkeypatch.setattr(routes, "get_llama_cpp_backend", lambda: SimpleNamespace(is_active = True))
monkeypatch.setattr(routes, "_peek_inference_backend", lambda: None)
monkeypatch.setattr(routes, "_wait_for_model_switch_idle", _drain)
monkeypatch.setattr(routes, "_unload_llama_before_standard_load", _unload_gpu)
monkeypatch.setattr(routes, "release_chat_gpu_claim", lambda: True)
with pytest.raises(HTTPException):
# The stub never becomes resident, so the load ends in a 409 after the steps above.
asyncio.run(
routes._load_npu_model(
LoadRequest(model_path = model.model_path),
current_request_counted = True,
on_reload_confirmed = None,
load_cancel_event = None,
)
)
assert order == ["drain", "unload", "load"]


@pytest.mark.parametrize("blocker", ["sidecar_swap", "shutdown"])
def test_an_npu_load_refuses_before_unloading_during_a_swap_or_shutdown(monkeypatch, blocker):
# The NPU branch skipped the rechecks the other load paths make, so it unloaded the resident
# model under a transformers install or after shutdown had torn everything down.
import routes.inference as routes
from core.inference import npu_backend as nb
from models.inference import LoadRequest
from utils import transformers_version

order: list[str] = []
model = SimpleNamespace(id = "qwen3-0.6b-FLM", model_path = "lemonade:qwen3-0.6b-FLM")

class _Npu:
def resident(self):
return None

def loadable_model(self, model_id):
return model

def load(self, model_id, ctx):
order.append("load")

async def _drain(**kwargs):
order.append("drain")

async def _unload_gpu(_backend):
order.append("unload")

monkeypatch.setattr(nb, "get_npu_backend", lambda: _Npu())
monkeypatch.setattr(routes, "get_llama_cpp_backend", lambda: SimpleNamespace(is_active = True))
monkeypatch.setattr(routes, "_peek_inference_backend", lambda: None)
monkeypatch.setattr(routes, "_wait_for_model_switch_idle", _drain)
monkeypatch.setattr(routes, "_unload_llama_before_standard_load", _unload_gpu)
monkeypatch.setattr(routes, "release_chat_gpu_claim", lambda: True)
if blocker == "sidecar_swap":
monkeypatch.setattr(transformers_version, "sidecar_swap_in_progress", lambda: True)
else:
monkeypatch.setattr(routes, "_loads_shutting_down", True)
with pytest.raises(HTTPException) as excinfo:
asyncio.run(
routes._load_npu_model(
LoadRequest(model_path = model.model_path),
current_request_counted = True,
on_reload_confirmed = None,
load_cancel_event = None,
)
)
assert excinfo.value.status_code == 409
assert "unload" not in order and "load" not in order


def test_a_stop_during_an_npu_load_cancels_it(monkeypatch):
# The cancel event was read only before and after the blocking load, so a Stop waited it out.
import routes.inference as routes
from core.inference import npu_backend as nb
from models.inference import LoadRequest

model = SimpleNamespace(id = "qwen3-0.6b-FLM", model_path = "lemonade:qwen3-0.6b-FLM")
loading = threading.Event()
stopped = threading.Event()
stop = threading.Event()

class _Npu:
def resident(self):
return None

def loadable_model(self, model_id):
return model

def load(self, model_id, ctx):
loading.set()
if not stopped.wait(10):
raise AssertionError("the load was never cancelled")
raise nb.NpuLoadCancelled("cancelled")

def cancel_load(self, model_id):
# The first ask lands before the load is registered, as when Stop beats npu.load.
asks.append(model_id)
if len(asks) == 1:
return False
stopped.set()
return True

asks: list[str] = []

def _stop_once_loading():
loading.wait(10)
stop.set()

async def _nothing(*_args, **_kwargs):
return None

monkeypatch.setattr(nb, "get_npu_backend", lambda: _Npu())
monkeypatch.setattr(routes, "get_llama_cpp_backend", lambda: SimpleNamespace(is_active = False))
monkeypatch.setattr(routes, "_peek_inference_backend", lambda: None)
monkeypatch.setattr(routes, "_unload_llama_before_standard_load", _nothing)
monkeypatch.setattr(routes, "release_chat_gpu_claim", lambda: True)
threading.Thread(target = _stop_once_loading, daemon = True).start()
with pytest.raises(HTTPException) as excinfo:
asyncio.run(
routes._load_npu_model(
LoadRequest(model_path = model.model_path),
current_request_counted = True,
on_reload_confirmed = None,
load_cancel_event = stop,
)
)
assert excinfo.value.status_code == 409
assert stopped.is_set() and len(asks) == 2


def test_a_reply_cut_short_is_not_reported_complete(flm):
flm()
status, body = _call(stream = False, messages = [{"role": "user", "content": "CUT"}])
Expand Down Expand Up @@ -1278,3 +1464,57 @@ def test_a_tool_loop_turn_dropped_after_a_tool_call_is_cut_short():
plain = _TurnFinish()
plain.see(_chunk({}, "stop").strip())
assert plain.finished is True


def test_a_reply_that_fills_the_context_says_so(flm):
flm(reasoning = True)
full = [{"role": "user", "content": "FULL"}]
status, lines = _call(stream = True, messages = full)
assert status == 200
chunks = _data(lines)
assert [c["_toolEvent"] for c in chunks if "_toolEvent" in c] == [
{"type": "context_window_exceeded"}
]
finishes = [
c["choices"][0]["finish_reason"] for c in chunks if c["choices"][0]["finish_reason"]
]
assert finishes == ["length"]
_, body = _call(stream = False, messages = full)
assert body["choices"][0]["finish_reason"] == "length"


@pytest.mark.parametrize(
("usage", "full"),
[
(_FULL_USAGE, True),
# Thinking off: FastFlowLM decodes an empty think block, four tokens counted as one.
({**_FULL_USAGE, "completion_tokens": 8144, "total_tokens": 8189}, True),
({"total_tokens": 8192, "max_kv_token_capacity": 8192}, True),
(_FLM_USAGE, False),
({"total_tokens": 8192}, False),
(None, False),
],
)
def test_a_full_kv_cache_reads_the_kv_position(usage, full):
import routes.inference as ri
assert ri._npu_kv_cache_full(usage) is full


def test_a_reply_with_room_left_keeps_its_stop(flm):
flm()
status, lines = _call(stream = True, messages = [{"role": "user", "content": "TIMED"}])
assert status == 200
chunks = _data(lines)
assert not any("_toolEvent" in c for c in chunks)
assert chunks[-1]["choices"][0]["finish_reason"] == "stop"


def test_whitespace_beside_a_tool_call_is_no_content(flm):
flm(tools = True)
tools = [
{"type": "function", "function": {"name": "get_weather", "parameters": {"type": "object"}}}
]
_, body = _call(stream = False, tools = tools, messages = [{"role": "user", "content": "WSTOOL"}])
message = body["choices"][0]["message"]
assert message["content"] is None
assert message["tool_calls"][0]["function"]["name"] == "get_weather"
26 changes: 25 additions & 1 deletion studio/backend/tests/test_npu_detect.py
Loading