* Resolve the transformers tier by probing AutoConfig instead of guessing When the only signal is a 5.x tokenizer class, get_transformers_tier guessed the lowest 5.x sidecar (530). That misroutes models whose built-in config parser needs a higher tier: dense NemotronH ships a 5.x tokenizer but its '-' (MLP) layer only transformers 5.10 can parse, so 5.3/5.5 raise KeyError '-'. The config.json transformers_version field records the saving version, not the minimum to load, so it cannot drive routing either. Replace the weak tokenizer->530 guesses (local and remote) with a probe: parse config.json with the built-in parser (trust_remote_code=False) in each sidecar, escalating 530->550->510, and pick the first that succeeds. This generalizes to any architecture without hardcoded lists. Strong signals stay fast paths (no subprocess); the probe runs only when the tier is otherwise ambiguous and is cached by (model, commit sha). It never executes repo code, never downloads weights, never raises, and falls back to the legacy 530 guess on a transient/auth/offline failure or when no sidecar is available. UNSLOTH_DISABLE_TIER_PROBE restores the old behavior. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Address review: tier probe fallbacks and cross-platform robustness Codex: - Never escalate to 510 on uncertainty. When every sidecar was probed and none parsed with the built-in parser, the model is a remote-code / custom model_type that loads via its own code; keep the legacy 530 route instead of jumping to 510 (which would change the behavior of models that worked on the 5.3 stack). - Only cache the 530 fallback when the result is conclusive (every tier actually probed). If a sidecar was missing/uninstallable the environment is incomplete, so return 530 uncached and retry on the next call. - Do not pin the tier cache under an unknown revision: _resolve_commit_sha no longer memoizes a None sha (a transient Hub failure is retried), and _probe_tier only caches a tier when the commit sha is known. Gemini: - Wrap Path.exists() in the sha resolver in try/except OSError (a remote repo id can raise WinError 123 on Windows). - Probe script writes the error to sys.stderr.buffer as UTF-8 bytes so a non-ASCII message cannot itself raise UnicodeEncodeError under cp1252. - subprocess.run decodes stderr with errors="replace" to avoid UnicodeDecodeError on non-UTF-8 consoles. Tests: 72 passed (added partial-sidecar uncached, sha-unresolved not cached, all-failed stays 530 + cached, sha resolver retries None / handles OSError). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Address review round 2: authenticate tier checks, stop memoizing local sigs Codex: - Thread hf_token through _check_config_needs_510/550 and _check_tokenizer_config_needs_v5 (and the underlying raw fetches). Previously a gated/private model whose only 5.x signal is tokenizer_config.json never reached the authenticated probe: the unauthenticated raw fetch failed and cached False, so the model fell through to the default 4.x tier. The per-check caches are now keyed by (model, token) so an unauthenticated miss cannot poison a later authed read, mirroring _load_config_json. - _resolve_commit_sha no longer memoizes a local directory signature. A local signature is mutable (size/mtime of config/tokenizer), so a reused/overwritten checkpoint path would otherwise keep selecting the previous tier; it is now recomputed every call. Only the immutable remote commit sha is memoized. Tests: 75 passed (added token-cache isolation + auth header, local signature not memoized, token threaded into all checks/probe). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Address review round 3: reach activation with the token, drop SHA tier cache Codex round 3: - Thread hf_token into the activation path that actually selects a sidecar. The token-aware tier checks added last round were unreachable: activate_transformers_for_subprocess called get_transformers_tier without a token, and the inference/training/export workers passed only the model name even though they hold a request-scoped hf_token. activate_transformers_for_subprocess now takes hf_token and the three workers forward config["hf_token"], so a gated/private model whose only 5.x signal is an authenticated config/tokenizer is routed to the right sidecar instead of falling to default 4.x. - Stop importing huggingface_hub during tier detection. _probe_tier no longer resolves a commit sha, so it never pulls huggingface_hub into the worker before the sidecar venv is prepended to sys.path (activation only prepends, never purges), which would otherwise pin the default-env hub over the sidecar's pinned huggingface_hub==1.8.0. - The tier cache is now keyed by model_name for the process lifetime (a model's required tier is a property of its architecture; cleared on restart). This drops the mutable-SHA memo that masked remote revision changes and the mutable local-signature memo, removing _resolve_commit_sha / _local_dir_signature / _probe_sha_cache entirely. - Do not cache a probe success that depended on a skipped lower tier: if a lower sidecar was unavailable, the lowest valid tier may change once it installs, so the result is returned uncached and re-probed next call. Tests: 73 passed (probe imports no hub; success uncached when a lower tier is skipped; activation forwards the token). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Trim comments to be more succinct * Re-probe overwritten local checkpoints and authenticate the probe child The AutoConfig tier probe cached its result under the bare model_name, so a local checkpoint overwritten in place (same path, new config.json) kept serving the stale sidecar. Fold a cheap config.json signature (size + mtime) into the cache key for local paths; remote ids stay name-keyed so no huggingface_hub import lands before the sidecar is activated. The probe relies on the implicit HF_TOKEN env, so an inherited HF_HUB_DISABLE_IMPLICIT_TOKEN=1 left it unauthenticated and a gated repo 401ed into the 530 fail-safe. Clear that flag in the child env when a token is set. * Keep tier probes off the log-only path and probe new 5.x archs default-first - get_transformers_tier gains probe=True/False. needs_transformers_5 (a coarse 4-vs-5 boolean used only for a spawn log and a vision-check branch) now passes probe=False, so a parent/log-only caller never spawns sidecar probes. The real activation path keeps probe=True and resolves the exact tier in the worker. - A config.json saved by transformers 5.x but matched by no fast path is now probed default-first: _probe_tier gains include_default + floor, prepending the ambient 4.57.x tier to the escalation. A model that still parses on the default is left on it (no mis-route onto a sidecar); only a config the default parser cannot read escalates to the lowest 5.x tier that parses. The transformers_version field is a cheap 'worth probing' hint only, read from the already-fetched config (no extra network); ordinary 4.x configs never probe. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Separate probe cache by mode and keep version-field 5.x visible to needs_transformers_5 - _probe_tier cache was keyed only by config.json signature, so a default-first probe that returned 'default' could be handed back to a later tokenizer/known-5.x caller (floor=530), leaving a model with a 5.x-only tokenizer on transformers 4.x. Key the cache by probe mode (floor + include_default); the legacy 530 mode keeps the bare key. - The version-field 5.x detection is a cheap config read, not a probe, so run it even when probe=False: a standard-tokenizer model whose only signal is transformers_version >= 5 now classifies as 5.x via needs_transformers_5 (returns '530' without spawning a probe), so the vision-routing fallback uses the 5.x subprocess instead of failing the default parser and marking it non-vision. The real activation path still probes default-first and may resolve 'default'. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Don't treat local checkpoints as Hub ids, and fix stale activation test double - _load_config_json / _check_tokenizer_config_needs_v5: a local checkpoint dir whose config.json / tokenizer_config.json is not yet present was being fetched from the Hub as if the path were a repo id, and the 404 miss was cached. A later call after the file is written (in-progress checkpoint) then served the stale miss, so a TokenizersBackend checkpoint fell through to the default tier. Skip the Hub fetch for local dirs and do not cache the miss, so the file is read once it appears. - test_activate_transformers_version_or_warn_*: the worker now threads hf_token into _activate_transformers_version (model_name, hf_token); update the one-arg test doubles to the real two-arg signature so the silent-success path stays silent. * Tighten comments in the AutoConfig probe and tier-selection paths * Address review: canonical probe cache key and reuse _token_cache_key - _probe_cache_key resolves config.json to its absolute realpath before keying, so a relative path or a changed cwd can't collide with or miss a prior probe result. Remote ids still fall back to the name (stat raises, caught). - _cached_config_json reuses _token_cache_key instead of re-hashing the token inline, keeping the (model, token) key derivation in one place. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
526 lines
21 KiB
Python
526 lines
21 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Tests for utils.ssm_runtime: the inference-side auto-install of SSM/Mamba kernels.
|
|
|
|
Covers detection, wheel-first install, idempotency, the failure path, the inference
|
|
worker wiring, and a drift guard so the constants/detection stay in lockstep with the
|
|
training worker (the original source of this behaviour).
|
|
"""
|
|
|
|
import sys
|
|
import types
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
_BACKEND = Path(__file__).resolve().parent.parent
|
|
if str(_BACKEND) not in sys.path:
|
|
sys.path.insert(0, str(_BACKEND))
|
|
|
|
from utils import ssm_runtime # noqa: E402
|
|
|
|
|
|
class _Result:
|
|
def __init__(
|
|
self,
|
|
returncode = 0,
|
|
stdout = "",
|
|
):
|
|
self.returncode = returncode
|
|
self.stdout = stdout
|
|
|
|
|
|
# ── detection ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"name",
|
|
[
|
|
"unsloth/NVIDIA-Nemotron-3-Nano-4B",
|
|
"unsloth/Nemotron-3-Nano-30B-A3B",
|
|
"nvidia/Nemotron-H-8B",
|
|
"tiiuae/Falcon-H1-0.5B-Instruct",
|
|
"ibm-granite/granite-4.0-h-micro",
|
|
"ibm/granitemoehybrid-test",
|
|
],
|
|
)
|
|
def test_ssm_models_detected(name):
|
|
assert ssm_runtime.model_is_ssm(name) is True
|
|
# every SSM model also needs causal-conv1d
|
|
assert ssm_runtime.model_wants_causal_conv1d(name) is True
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"name",
|
|
[
|
|
"Qwen/Qwen3-Next-80B-A3B",
|
|
"unsloth/Qwen3.5-2B",
|
|
"LiquidAI/LFM2-1.2B",
|
|
],
|
|
)
|
|
def test_causal_conv1d_only_models(name):
|
|
# linear-attention hybrids need causal-conv1d but not mamba-ssm
|
|
assert ssm_runtime.model_wants_causal_conv1d(name) is True
|
|
assert ssm_runtime.model_is_ssm(name) is False
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"name",
|
|
[
|
|
"unsloth/Llama-3.2-1B-Instruct",
|
|
"unsloth/Qwen2.5-7B",
|
|
"unsloth/gemma-3-4b-it",
|
|
"",
|
|
None,
|
|
],
|
|
)
|
|
def test_non_ssm_models_not_detected(name):
|
|
assert ssm_runtime.model_is_ssm(name) is False
|
|
assert ssm_runtime.model_wants_causal_conv1d(name) is False
|
|
|
|
|
|
# ── ssm_probe_identifier: match a real model id, never an arbitrary name ───────
|
|
|
|
|
|
def test_probe_lora_uses_base_not_adapter_name():
|
|
# A plain-Llama LoRA whose adapter id contains an SSM substring is not SSM.
|
|
probe = ssm_runtime.ssm_probe_identifier("user/falcon-h1-lora", "meta-llama/Llama-3-8B")
|
|
assert probe == "meta-llama/Llama-3-8B"
|
|
assert ssm_runtime.model_is_ssm(probe) is False
|
|
|
|
|
|
def test_probe_lora_on_ssm_base_detected():
|
|
probe = ssm_runtime.ssm_probe_identifier("user/my-adapter", "nvidia/Nemotron-H-8B")
|
|
assert ssm_runtime.model_is_ssm(probe) is True
|
|
|
|
|
|
def test_probe_plain_hf_id_unchanged():
|
|
assert ssm_runtime.ssm_probe_identifier("nvidia/Nemotron-H-8B") == "nvidia/Nemotron-H-8B"
|
|
|
|
|
|
def test_probe_local_path_uses_basename(tmp_path):
|
|
# Parent folders are arbitrary: a Llama checkpoint under a falcon-h1 dir is not SSM.
|
|
d = tmp_path / "falcon-h1-experiment" / "llama-checkpoint"
|
|
d.mkdir(parents = True)
|
|
probe = ssm_runtime.ssm_probe_identifier(str(d))
|
|
assert probe == "llama-checkpoint"
|
|
assert ssm_runtime.model_is_ssm(probe) is False
|
|
|
|
|
|
def test_probe_local_ssm_checkpoint_basename_detected(tmp_path):
|
|
d = tmp_path / "runs" / "nemotron-h-finetune"
|
|
d.mkdir(parents = True)
|
|
assert ssm_runtime.model_is_ssm(ssm_runtime.ssm_probe_identifier(str(d))) is True
|
|
|
|
|
|
# ── ensure_ssm_runtime behaviour ─────────────────────────────────────────────
|
|
|
|
|
|
def test_noop_for_non_ssm_model(monkeypatch):
|
|
calls = []
|
|
monkeypatch.setattr(ssm_runtime, "_install_kernel", lambda **k: calls.append(k) or True)
|
|
ssm_runtime.ensure_ssm_runtime("unsloth/Llama-3.2-1B-Instruct", run = lambda *a, **k: _Result())
|
|
assert calls == [] # nothing installed for a plain transformer
|
|
|
|
|
|
def test_ssm_model_installs_causal_then_mamba(monkeypatch):
|
|
order = []
|
|
|
|
def fake_install(*, import_name, **_):
|
|
order.append(import_name)
|
|
return True
|
|
|
|
monkeypatch.setattr(ssm_runtime, "_install_kernel", fake_install)
|
|
ssm_runtime.ensure_ssm_runtime("unsloth/NVIDIA-Nemotron-3-Nano-4B")
|
|
assert order == ["causal_conv1d", "mamba_ssm"]
|
|
|
|
|
|
def test_causal_only_model_skips_mamba(monkeypatch):
|
|
order = []
|
|
monkeypatch.setattr(
|
|
ssm_runtime,
|
|
"_install_kernel",
|
|
lambda *, import_name, **_: order.append(import_name) or True,
|
|
)
|
|
ssm_runtime.ensure_ssm_runtime("Qwen/Qwen3-Next-80B-A3B")
|
|
assert order == ["causal_conv1d"]
|
|
|
|
|
|
def test_failure_raises_runtime_error(monkeypatch):
|
|
# A true SSM model whose mamba-ssm cannot install is fatal (cryptic mid-load import
|
|
# otherwise). "Nemotron-3-Nano-30B-A3B" matches the SSM substrings.
|
|
monkeypatch.setattr(ssm_runtime, "_install_kernel", lambda **k: False)
|
|
with pytest.raises(RuntimeError):
|
|
ssm_runtime.ensure_ssm_runtime("unsloth/Nemotron-3-Nano-30B-A3B")
|
|
|
|
|
|
def test_causal_only_install_failure_is_not_fatal(monkeypatch):
|
|
# Qwen3-Next/LFM2 want causal-conv1d but fall back to torch; a failed install must
|
|
# not block the load (best-effort, mirrors training).
|
|
monkeypatch.setattr(ssm_runtime, "_install_kernel", lambda **k: False)
|
|
ssm_runtime.ensure_ssm_runtime("Qwen/Qwen3-Next-80B-A3B") # no raise
|
|
|
|
|
|
def test_ssm_causal_failure_nonfatal_when_mamba_ok(monkeypatch):
|
|
# causal-conv1d is best-effort even for a true SSM model; only mamba-ssm is fatal.
|
|
monkeypatch.setattr(
|
|
ssm_runtime, "_install_kernel", lambda *, import_name, **_: import_name == "mamba_ssm"
|
|
)
|
|
ssm_runtime.ensure_ssm_runtime("unsloth/NVIDIA-Nemotron-3-Nano-4B") # no raise
|
|
|
|
|
|
def test_install_kernel_idempotent_when_present(monkeypatch):
|
|
monkeypatch.setattr(ssm_runtime, "_is_importable", lambda name: True)
|
|
called = []
|
|
monkeypatch.setattr(ssm_runtime, "url_exists", lambda u: called.append("url") or True)
|
|
ok = ssm_runtime._install_kernel(
|
|
import_name = "mamba_ssm",
|
|
display_name = "mamba-ssm",
|
|
pypi_name = "mamba-ssm",
|
|
package_version = "2.3.1",
|
|
release_tag = "v2.3.1",
|
|
release_base_url = "x",
|
|
status_cb = None,
|
|
run = lambda *a, **k: _Result(),
|
|
)
|
|
assert ok is True
|
|
assert called == [] # short-circuits before touching the network
|
|
|
|
|
|
def test_install_kernel_uses_prebuilt_wheel(monkeypatch):
|
|
# not importable before install, importable after the wheel lands
|
|
states = iter([False, True])
|
|
monkeypatch.setattr(ssm_runtime, "_is_importable", lambda name: next(states))
|
|
monkeypatch.setattr(ssm_runtime, "probe_torch_wheel_env", lambda timeout = 30: {"x": "y"})
|
|
seen = {}
|
|
monkeypatch.setattr(
|
|
ssm_runtime,
|
|
"direct_wheel_url",
|
|
lambda **k: seen.update(k) or "https://example/mamba_ssm-2.3.1-cp313.whl",
|
|
)
|
|
monkeypatch.setattr(ssm_runtime, "url_exists", lambda u: True)
|
|
installed = {}
|
|
|
|
def fake_install_wheel(url, **k):
|
|
installed["url"] = url
|
|
return [("uv", _Result(returncode = 0))]
|
|
|
|
monkeypatch.setattr(ssm_runtime, "install_wheel", fake_install_wheel)
|
|
ran = []
|
|
ok = ssm_runtime._install_kernel(
|
|
import_name = "mamba_ssm",
|
|
display_name = "mamba-ssm",
|
|
pypi_name = "mamba-ssm",
|
|
package_version = "2.3.1",
|
|
release_tag = "v2.3.1",
|
|
release_base_url = "https://github.com/state-spaces/mamba/releases/download",
|
|
status_cb = None,
|
|
run = lambda *a, **k: ran.append(a) or _Result(),
|
|
)
|
|
assert ok is True
|
|
assert installed["url"].endswith(".whl")
|
|
assert seen["filename_prefix"] == "mamba_ssm"
|
|
assert ran == [] # wheel succeeded; no PyPI source build
|
|
|
|
|
|
def test_install_kernel_falls_back_to_source(monkeypatch):
|
|
# no wheel -> source build -> importable after install
|
|
states = iter([False, True]) # before install, after install
|
|
monkeypatch.setattr(ssm_runtime, "_is_importable", lambda name: next(states))
|
|
monkeypatch.setattr(ssm_runtime, "probe_torch_wheel_env", lambda timeout = 30: {})
|
|
monkeypatch.setattr(ssm_runtime, "direct_wheel_url", lambda **k: None)
|
|
pip_cmds = []
|
|
ok = ssm_runtime._install_kernel(
|
|
import_name = "causal_conv1d",
|
|
display_name = "causal-conv1d",
|
|
pypi_name = "causal-conv1d",
|
|
package_version = "1.6.1",
|
|
release_tag = "v1.6.1.post4",
|
|
release_base_url = "x",
|
|
status_cb = None,
|
|
run = lambda cmd, **k: pip_cmds.append(cmd) or _Result(returncode = 0),
|
|
)
|
|
assert ok is True
|
|
assert any("causal-conv1d==1.6.1" in c for c in pip_cmds[0])
|
|
|
|
|
|
# ── import-cache invalidation (so a just-installed kernel is importable) ───────
|
|
|
|
|
|
def test_is_importable_invalidates_caches(monkeypatch):
|
|
calls = []
|
|
monkeypatch.setattr(ssm_runtime.importlib, "invalidate_caches", lambda: calls.append(1))
|
|
assert ssm_runtime._is_importable("sys") is True
|
|
assert calls # caches invalidated before attempting the import
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"exc",
|
|
[
|
|
ImportError("no module"),
|
|
OSError("undefined symbol: cuLaunchKernel"),
|
|
RuntimeError("CUDA error: ABI mismatch"),
|
|
],
|
|
)
|
|
def test_is_importable_treats_broken_kernel_as_not_importable(monkeypatch, exc):
|
|
# ABI-incompatible kernels raise OSError/RuntimeError, not ImportError; all must read as
|
|
# not-importable. _is_importable calls bare __import__(), so patching ssm_runtime.__import__
|
|
# (resolved via module globals) leaves real `import` statements untouched.
|
|
def _raise(name):
|
|
raise exc
|
|
|
|
monkeypatch.setattr(ssm_runtime, "__import__", _raise, raising = False)
|
|
monkeypatch.setattr(ssm_runtime.importlib, "invalidate_caches", lambda: None)
|
|
assert ssm_runtime._is_importable("causal_conv1d") is False
|
|
|
|
|
|
def test_causal_conv1d_skipped_on_windows(monkeypatch):
|
|
# No prebuilt Windows wheel: a causal-conv1d-only model must NOT enter the source build
|
|
# (which can hang a chat load for minutes); it falls back to torch.
|
|
monkeypatch.setattr(ssm_runtime.sys, "platform", "win32")
|
|
installed = []
|
|
monkeypatch.setattr(
|
|
ssm_runtime,
|
|
"_install_kernel",
|
|
lambda *, import_name, **_: installed.append(import_name) or True,
|
|
)
|
|
ssm_runtime.ensure_ssm_runtime("Qwen/Qwen3-Next-80B-A3B")
|
|
assert installed == [] # never attempted to build causal-conv1d
|
|
|
|
|
|
def test_ssm_model_on_windows_still_installs_mamba(monkeypatch):
|
|
# A true SSM hybrid still needs mamba-ssm on Windows; only causal-conv1d is skipped.
|
|
monkeypatch.setattr(ssm_runtime.sys, "platform", "win32")
|
|
installed = []
|
|
monkeypatch.setattr(
|
|
ssm_runtime,
|
|
"_install_kernel",
|
|
lambda *, import_name, **_: installed.append(import_name) or True,
|
|
)
|
|
ssm_runtime.ensure_ssm_runtime("unsloth/NVIDIA-Nemotron-3-Nano-4B")
|
|
assert installed == ["mamba_ssm"] # causal-conv1d skipped, mamba-ssm still attempted
|
|
|
|
|
|
def test_wheel_installed_but_not_importable_falls_back_to_source(monkeypatch):
|
|
# top: not importable; after wheel: still not importable (ABI mismatch) -> source build;
|
|
# after source build: importable.
|
|
states = iter([False, False, True])
|
|
monkeypatch.setattr(ssm_runtime, "_is_importable", lambda name: next(states))
|
|
monkeypatch.setattr(ssm_runtime, "probe_torch_wheel_env", lambda timeout = 30: {})
|
|
monkeypatch.setattr(ssm_runtime, "direct_wheel_url", lambda **k: "https://x/w.whl")
|
|
monkeypatch.setattr(ssm_runtime, "url_exists", lambda u: True)
|
|
monkeypatch.setattr(
|
|
ssm_runtime, "install_wheel", lambda url, **k: [("uv", _Result(returncode = 0))]
|
|
)
|
|
pip_cmds = []
|
|
ok = ssm_runtime._install_kernel(
|
|
import_name = "mamba_ssm",
|
|
display_name = "mamba-ssm",
|
|
pypi_name = "mamba-ssm",
|
|
package_version = "2.3.1",
|
|
release_tag = "v2.3.1",
|
|
release_base_url = "x",
|
|
status_cb = None,
|
|
run = lambda cmd, **k: pip_cmds.append(cmd) or _Result(returncode = 0),
|
|
)
|
|
assert ok is True
|
|
assert pip_cmds, "a non-importable wheel must fall back to a source build"
|
|
|
|
|
|
def test_hip_source_build_requires_hipcc(monkeypatch):
|
|
# ROCm env (hip_version set) with no wheel and no hipcc must fail clearly, not build.
|
|
monkeypatch.setattr(ssm_runtime, "_is_importable", lambda name: False)
|
|
monkeypatch.setattr(
|
|
ssm_runtime, "probe_torch_wheel_env", lambda timeout = 30: {"hip_version": "6.2"}
|
|
)
|
|
monkeypatch.setattr(ssm_runtime, "direct_wheel_url", lambda **k: None)
|
|
monkeypatch.setattr(ssm_runtime.shutil, "which", lambda name: None) # no uv, no hipcc
|
|
ran = []
|
|
ok = ssm_runtime._install_kernel(
|
|
import_name = "causal_conv1d",
|
|
display_name = "causal-conv1d",
|
|
pypi_name = "causal-conv1d",
|
|
package_version = "1.6.1",
|
|
release_tag = "v1.6.1.post4",
|
|
release_base_url = "x",
|
|
status_cb = None,
|
|
run = lambda cmd, **k: ran.append(cmd) or _Result(returncode = 0),
|
|
)
|
|
assert ok is False
|
|
assert ran == [] # bailed before invoking pip
|
|
|
|
|
|
def test_source_build_reinstalls_to_replace_broken_wheel(monkeypatch):
|
|
# Reached only when not importable (possibly a broken wheel at the pinned version);
|
|
# the source build must reinstall so it replaces it instead of no-opping.
|
|
states = iter([False, True])
|
|
monkeypatch.setattr(ssm_runtime, "_is_importable", lambda name: next(states))
|
|
monkeypatch.setattr(ssm_runtime, "probe_torch_wheel_env", lambda timeout = 30: {})
|
|
monkeypatch.setattr(ssm_runtime, "direct_wheel_url", lambda **k: None)
|
|
cmds = []
|
|
ssm_runtime._install_kernel(
|
|
import_name = "causal_conv1d",
|
|
display_name = "causal-conv1d",
|
|
pypi_name = "causal-conv1d",
|
|
package_version = "1.6.1",
|
|
release_tag = "v1.6.1.post4",
|
|
release_base_url = "x",
|
|
status_cb = None,
|
|
run = lambda cmd, **k: cmds.append(cmd) or _Result(returncode = 0),
|
|
)
|
|
assert "--reinstall" in cmds[0] or "--force-reinstall" in cmds[0]
|
|
|
|
|
|
def test_hip_uv_source_build_uses_no_cache(monkeypatch):
|
|
# ROCm uv source build must skip the cache to avoid reusing stale partial HIP builds.
|
|
states = iter([False, True])
|
|
monkeypatch.setattr(ssm_runtime, "_is_importable", lambda name: next(states))
|
|
monkeypatch.setattr(
|
|
ssm_runtime, "probe_torch_wheel_env", lambda timeout = 30: {"hip_version": "6.2"}
|
|
)
|
|
monkeypatch.setattr(ssm_runtime, "direct_wheel_url", lambda **k: None)
|
|
monkeypatch.setattr(ssm_runtime.shutil, "which", lambda name: "/usr/bin/" + name) # uv + hipcc
|
|
monkeypatch.setattr(ssm_runtime, "_hipcc_gcc_install_dir", lambda: None)
|
|
cmds = []
|
|
ssm_runtime._install_kernel(
|
|
import_name = "causal_conv1d",
|
|
display_name = "causal-conv1d",
|
|
pypi_name = "causal-conv1d",
|
|
package_version = "1.6.1",
|
|
release_tag = "v1.6.1.post4",
|
|
release_base_url = "x",
|
|
status_cb = None,
|
|
run = lambda cmd, **k: cmds.append(cmd) or _Result(returncode = 0),
|
|
)
|
|
assert cmds[0][0] == "uv"
|
|
assert "--no-cache" in cmds[0] and "--reinstall" in cmds[0]
|
|
|
|
|
|
# ── inference worker wiring ───────────────────────────────────────────────────
|
|
|
|
|
|
def test_inference_worker_calls_ensure_ssm_runtime():
|
|
src = (_BACKEND / "core" / "inference" / "worker.py").read_text()
|
|
assert "from utils.ssm_runtime import ensure_ssm_runtime" in src
|
|
assert "ensure_ssm_runtime(" in src
|
|
|
|
|
|
def test_inference_worker_skips_ssm_on_mlx_and_checks_lora_base():
|
|
src = (_BACKEND / "core" / "inference" / "worker.py").read_text()
|
|
# MLX (Apple Silicon) must not try to build CUDA/ROCm SSM kernels.
|
|
assert 'getattr(backend, "device", None) != "mlx"' in src
|
|
# A LoRA load must also check its base model, not just the adapter id.
|
|
assert "mc.base_model" in src
|
|
|
|
|
|
def test_inference_worker_resolves_remote_lora_base_pre_import():
|
|
# A remote LoRA's base (from the Hub adapter_config.json) must be resolved before the
|
|
# transformers import so its SSM kernels are pre-installed, not too late in _handle_load.
|
|
src = (_BACKEND / "core" / "inference" / "worker.py").read_text()
|
|
assert "_remote_lora_base" in src
|
|
|
|
|
|
def test_inference_worker_tiers_on_base_and_gates_lora_base_only():
|
|
src = (_BACKEND / "core" / "inference" / "worker.py").read_text()
|
|
# Tier activation runs on the resolved base, not the raw adapter id (remote-LoRA fix).
|
|
assert "_activate_transformers_version(_base" in src
|
|
# The gate only adds a genuine LoRA base, never a full fine-tune's recorded (unloaded) base.
|
|
assert "_gate_targets" in src and "_lora_base" in src
|
|
|
|
|
|
def test_inference_worker_probes_base_for_ssm_kernels():
|
|
# Both the pre-import path and _handle_load must derive SSM targets from a real model id
|
|
# via ssm_probe_identifier, not the raw adapter id / local checkpoint path.
|
|
src = (_BACKEND / "core" / "inference" / "worker.py").read_text()
|
|
assert src.count("ssm_probe_identifier(") >= 2
|
|
|
|
|
|
def test_pre_import_gate_is_transformers_free():
|
|
# The pre-import gate must not import transformers: security_load_subdirs pulls
|
|
# model_config -> transformers, which would snapshot SSM backend availability before the
|
|
# kernels install. With load_subdirs=() the malware + consent scans stay transformers-free.
|
|
import sys as _sys
|
|
from unittest.mock import patch
|
|
import utils.security.file_security as fs
|
|
import utils.security.consent as consent
|
|
|
|
for m in list(_sys.modules):
|
|
if m == "transformers" or m.startswith("transformers.") or m == "utils.models.model_config":
|
|
_sys.modules.pop(m, None)
|
|
|
|
with patch.object(fs, "_fetch_security_status", return_value = None):
|
|
fs.evaluate_file_security("nvidia/Nemotron-H-8B", load_subdirs = ())
|
|
with patch.object(
|
|
consent, "_load_remote_code_configs", return_value = [{"model_type": "nemotron_h"}]
|
|
):
|
|
from utils.security import evaluate_remote_code_consent_for_targets
|
|
evaluate_remote_code_consent_for_targets(["nvidia/Nemotron-H-8B"], trust_remote_code = True)
|
|
|
|
assert "transformers" not in _sys.modules
|
|
assert "utils.models.model_config" not in _sys.modules
|
|
|
|
|
|
def test_pre_import_gate_skips_subdir_computation():
|
|
# The worker's pre-import preflight must call the gate with compute_subdirs=False so it
|
|
# never imports model_config/transformers before the SSM kernels are installed.
|
|
src = (_BACKEND / "core" / "inference" / "worker.py").read_text()
|
|
assert "compute_subdirs = False" in src
|
|
|
|
|
|
def _call_linenos(tree, func_name, call_name):
|
|
import ast
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.FunctionDef) and node.name == func_name:
|
|
return [
|
|
c.lineno
|
|
for c in ast.walk(node)
|
|
if isinstance(c, ast.Call)
|
|
and isinstance(c.func, ast.Name)
|
|
and c.func.id == call_name
|
|
]
|
|
return []
|
|
|
|
|
|
def test_security_gates_run_before_ssm_install():
|
|
# The SSM install is name-based and can source-build native packages, so a malware /
|
|
# blocked-code model must be refused first -- in both the pre-import path and _handle_load.
|
|
import ast
|
|
tree = ast.parse((_BACKEND / "core" / "inference" / "worker.py").read_text())
|
|
for fn in ("run_inference_process", "_handle_load"):
|
|
gates = _call_linenos(tree, fn, "_run_security_gates")
|
|
ssm = _call_linenos(tree, fn, "_ensure_ssm_kernels")
|
|
assert gates, f"{fn} must call _run_security_gates"
|
|
assert ssm, f"{fn} must call _ensure_ssm_kernels"
|
|
assert min(gates) < min(ssm), f"{fn} must gate before installing SSM kernels"
|
|
|
|
|
|
# ── drift guard vs the training worker (single source of truth) ───────────────
|
|
|
|
|
|
def test_constants_match_training_worker():
|
|
try:
|
|
from core.training import worker as tw
|
|
except Exception as exc: # pragma: no cover - only when training deps absent
|
|
pytest.skip(f"training worker not importable here: {exc}")
|
|
|
|
assert set(ssm_runtime.SSM_MODEL_SUBSTRINGS) == set(tw._SSM_MODEL_SUBSTRINGS)
|
|
assert ssm_runtime.MAMBA_SSM_PACKAGE_VERSION == tw._MAMBA_SSM_PACKAGE_VERSION
|
|
assert ssm_runtime.MAMBA_SSM_RELEASE_TAG == tw._MAMBA_SSM_RELEASE_TAG
|
|
assert ssm_runtime.CAUSAL_CONV1D_PACKAGE_VERSION == tw._CAUSAL_CONV1D_PACKAGE_VERSION
|
|
assert ssm_runtime.CAUSAL_CONV1D_RELEASE_TAG == tw._CAUSAL_CONV1D_RELEASE_TAG
|
|
|
|
# detection must agree with the training worker across SSM + non-SSM names
|
|
for name in (
|
|
"unsloth/NVIDIA-Nemotron-3-Nano-4B",
|
|
"nvidia/Nemotron-H-8B",
|
|
"tiiuae/Falcon-H1-0.5B",
|
|
"ibm-granite/granite-4.0-h-micro",
|
|
"Qwen/Qwen3-Next-80B",
|
|
"LiquidAI/LFM2-1.2B",
|
|
"unsloth/Llama-3.2-1B-Instruct",
|
|
"unsloth/Qwen2.5-7B",
|
|
):
|
|
assert ssm_runtime.model_wants_causal_conv1d(name) == tw._model_wants_causal_conv1d(
|
|
name
|
|
), name
|