e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
443 lines
18 KiB
Python
443 lines
18 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 routes/training_vram.py -- the VRAM-aware decision to keep or unload a
|
|
resident chat model when a training run starts.
|
|
"""
|
|
|
|
import importlib.util
|
|
import sys
|
|
import types
|
|
import unittest
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from utils.hardware import DeviceType
|
|
import utils.hardware.hardware as _hw_module
|
|
|
|
# Load training_vram.py standalone so importing it doesn't pull the heavy
|
|
# routes/__init__.py; its lazy backend imports still resolve via sys.modules stubs.
|
|
_BACKEND_ROOT = Path(__file__).resolve().parent.parent
|
|
_spec = importlib.util.spec_from_file_location(
|
|
"training_vram_under_test", _BACKEND_ROOT / "routes" / "training_vram.py"
|
|
)
|
|
tv = importlib.util.module_from_spec(_spec)
|
|
_spec.loader.exec_module(tv)
|
|
|
|
|
|
class _GpuCacheResetMixin:
|
|
"""Reset module-level GPU caches between tests to prevent state leaks."""
|
|
|
|
def tearDown(self):
|
|
_hw_module._physical_gpu_count = None
|
|
_hw_module._visible_gpu_count = None
|
|
|
|
|
|
def _fake_inference_backend(
|
|
*,
|
|
active = None,
|
|
loading = None,
|
|
alive = False,
|
|
):
|
|
inf = SimpleNamespace(
|
|
active_model_name = active,
|
|
loading_models = set(loading or []),
|
|
models = {},
|
|
)
|
|
inf._ensure_subprocess_alive = lambda: alive
|
|
inf._shutdown_subprocess = MagicMock()
|
|
# Bind the MagicMock so assertions can reach it.
|
|
inf._shutdown_subprocess_mock = inf._shutdown_subprocess
|
|
return inf
|
|
|
|
|
|
def _fake_llama_backend(
|
|
*,
|
|
active = False,
|
|
identifier = "model.gguf",
|
|
gpu_offload = None,
|
|
loaded = None,
|
|
):
|
|
# A healthy active server is loaded; pass loaded=False for a mid-start one.
|
|
is_loaded = active if loaded is None else loaded
|
|
llama = SimpleNamespace(
|
|
is_active = active,
|
|
is_loaded = is_loaded,
|
|
model_identifier = identifier,
|
|
_gpu_offload_active = gpu_offload,
|
|
)
|
|
llama.unload_model = MagicMock()
|
|
return llama
|
|
|
|
|
|
def _patch_backends(inf, llama):
|
|
"""Stub core.inference + routes.inference modules so the lazy imports inside
|
|
training_vram resolve to fakes (avoids importing torch-heavy backends)."""
|
|
core_inf = types.ModuleType("core.inference")
|
|
core_inf.get_inference_backend = lambda: inf
|
|
routes_inf = types.ModuleType("routes.inference")
|
|
routes_inf.get_llama_cpp_backend = lambda: llama
|
|
return patch.dict(sys.modules, {"core.inference": core_inf, "routes.inference": routes_inf})
|
|
|
|
|
|
# ── summarize_resident_chat ──────────────────────────────────────────────────
|
|
|
|
|
|
class TestSummarizeResidentChat(_GpuCacheResetMixin, unittest.TestCase):
|
|
def test_nothing_resident(self):
|
|
with _patch_backends(_fake_inference_backend(), _fake_llama_backend(active = False)):
|
|
self.assertEqual(
|
|
tv.summarize_resident_chat(),
|
|
{"hf": None, "gguf": None, "loading": False, "any": False},
|
|
)
|
|
|
|
def test_hf_resident_via_active_model(self):
|
|
with _patch_backends(
|
|
_fake_inference_backend(active = "unsloth/Qwen3-4B"), _fake_llama_backend(active = False)
|
|
):
|
|
out = tv.summarize_resident_chat()
|
|
self.assertEqual(out["hf"], "unsloth/Qwen3-4B")
|
|
self.assertFalse(out["loading"])
|
|
self.assertTrue(out["any"])
|
|
|
|
def test_hf_resident_while_still_loading(self):
|
|
# Mid-load: no active model yet but VRAM is held -> flag in-flight.
|
|
with _patch_backends(
|
|
_fake_inference_backend(active = None, loading = ["unsloth/Qwen3-4B"]),
|
|
_fake_llama_backend(active = False),
|
|
):
|
|
out = tv.summarize_resident_chat()
|
|
self.assertEqual(out["hf"], "unsloth/Qwen3-4B")
|
|
self.assertTrue(out["loading"])
|
|
self.assertTrue(out["any"])
|
|
|
|
def test_replacement_hf_load_is_in_flight(self):
|
|
# Swap: new model loading while old still active -> unsafe to keep.
|
|
with _patch_backends(
|
|
_fake_inference_backend(active = "unsloth/old", loading = ["unsloth/new"]),
|
|
_fake_llama_backend(active = False),
|
|
):
|
|
out = tv.summarize_resident_chat()
|
|
self.assertEqual(out["hf"], "unsloth/old")
|
|
self.assertTrue(out["loading"])
|
|
|
|
def test_cpu_only_gguf_is_not_a_vram_resident(self):
|
|
# A llama-server confirmed to run entirely on CPU holds no VRAM.
|
|
with _patch_backends(
|
|
_fake_inference_backend(),
|
|
_fake_llama_backend(active = True, identifier = "cpu.gguf", gpu_offload = False),
|
|
):
|
|
out = tv.summarize_resident_chat()
|
|
self.assertIsNone(out["gguf"])
|
|
self.assertFalse(out["any"])
|
|
|
|
def test_mid_start_gguf_is_in_flight(self):
|
|
# Active but not yet healthy -- still allocating, so unsafe to size.
|
|
with _patch_backends(
|
|
_fake_inference_backend(),
|
|
_fake_llama_backend(active = True, loaded = False, identifier = "starting.gguf"),
|
|
):
|
|
out = tv.summarize_resident_chat()
|
|
self.assertEqual(out["gguf"], "starting.gguf")
|
|
self.assertTrue(out["loading"])
|
|
|
|
def test_bare_alive_subprocess_without_model_is_not_resident(self):
|
|
# Bare-alive subprocess (no model, only CUDA context) must NOT count.
|
|
with _patch_backends(
|
|
_fake_inference_backend(active = None, alive = True), _fake_llama_backend(active = False)
|
|
):
|
|
out = tv.summarize_resident_chat()
|
|
self.assertIsNone(out["hf"])
|
|
self.assertFalse(out["any"])
|
|
|
|
def test_gguf_resident(self):
|
|
with _patch_backends(
|
|
_fake_inference_backend(), _fake_llama_backend(active = True, identifier = "gemma.gguf")
|
|
):
|
|
out = tv.summarize_resident_chat()
|
|
self.assertEqual(out["gguf"], "gemma.gguf")
|
|
self.assertFalse(out["loading"]) # healthy/loaded -> safe to size
|
|
self.assertTrue(out["any"])
|
|
|
|
def test_one_backend_raising_does_not_break_the_other(self):
|
|
bad_inf = SimpleNamespace() # missing attributes -> AttributeError
|
|
with _patch_backends(bad_inf, _fake_llama_backend(active = True)):
|
|
out = tv.summarize_resident_chat()
|
|
self.assertIsNone(out["hf"])
|
|
self.assertTrue(out["any"]) # GGUF still detected
|
|
|
|
|
|
# ── can_keep_during_training (auto mode) ─────────────────────────────────────
|
|
|
|
|
|
_BASE_KW = dict(
|
|
model_name = "unsloth/Qwen3-4B",
|
|
hf_token = None,
|
|
training_type = "LoRA/QLoRA",
|
|
load_in_4bit = True,
|
|
batch_size = 2,
|
|
max_seq_length = 2048,
|
|
lora_rank = 16,
|
|
target_modules = None,
|
|
gradient_checkpointing = "unsloth",
|
|
optimizer = "adamw_8bit",
|
|
gpu_ids = None,
|
|
)
|
|
|
|
|
|
class TestCanKeepAuto(_GpuCacheResetMixin, unittest.TestCase):
|
|
def _run(
|
|
self,
|
|
auto_return,
|
|
*,
|
|
device = DeviceType.CUDA,
|
|
**overrides,
|
|
):
|
|
kw = {**_BASE_KW, **overrides}
|
|
with (
|
|
patch("utils.hardware.get_device", return_value = device),
|
|
patch("utils.hardware.auto_select_gpu_ids", return_value = auto_return) as auto_mock,
|
|
):
|
|
keep, info = tv.can_keep_chat_during_training(**kw)
|
|
return keep, info, auto_mock
|
|
|
|
def test_keep_when_abundant(self):
|
|
# required 10 -> threshold 10*1.15+4 = 15.5; usable 30 >> 15.5.
|
|
meta = {"selection_mode": "auto", "required_gb": 10.0, "usable_gb": 30.0}
|
|
keep, info, _ = self._run(([1], meta))
|
|
self.assertTrue(keep)
|
|
self.assertEqual(info["mode"], "auto")
|
|
|
|
def test_unload_when_within_margin(self):
|
|
# required 10 -> threshold 15.5; usable 15.0 fits raw but not the margin.
|
|
meta = {"selection_mode": "auto", "required_gb": 10.0, "usable_gb": 15.0}
|
|
keep, _, _ = self._run(([0], meta))
|
|
self.assertFalse(keep)
|
|
|
|
def test_unload_on_fallback_all(self):
|
|
meta = {"selection_mode": "fallback_all", "required_gb": 10.0, "usable_gb": 100.0}
|
|
keep, _, _ = self._run(([0, 1], meta))
|
|
self.assertFalse(keep)
|
|
|
|
def test_unload_when_estimate_unavailable(self):
|
|
meta = {"selection_mode": "auto", "required_gb": None, "usable_gb": None}
|
|
keep, _, _ = self._run((None, meta))
|
|
self.assertFalse(keep)
|
|
|
|
def test_unload_on_non_cuda(self):
|
|
keep, info, auto_mock = self._run(([0], {}), device = DeviceType.CPU)
|
|
self.assertFalse(keep)
|
|
self.assertEqual(info["mode"], "non_cuda")
|
|
auto_mock.assert_not_called()
|
|
|
|
def test_full_finetuning_forces_16bit_in_estimate(self):
|
|
meta = {"selection_mode": "auto", "required_gb": 10.0, "usable_gb": 30.0}
|
|
_keep, _info, auto_mock = self._run(
|
|
([0], meta), training_type = "Full Finetuning", load_in_4bit = True
|
|
)
|
|
self.assertFalse(auto_mock.call_args.kwargs["load_in_4bit"])
|
|
|
|
def test_hf_token_forwarded(self):
|
|
meta = {"selection_mode": "auto", "required_gb": 10.0, "usable_gb": 30.0}
|
|
_keep, _info, auto_mock = self._run(([0], meta), hf_token = "hf_secret")
|
|
self.assertEqual(auto_mock.call_args.kwargs["hf_token"], "hf_secret")
|
|
|
|
def test_probe_exception_defaults_to_unload(self):
|
|
kw = {**_BASE_KW}
|
|
with (
|
|
patch("utils.hardware.get_device", return_value = DeviceType.CUDA),
|
|
patch("utils.hardware.auto_select_gpu_ids", side_effect = RuntimeError("boom")),
|
|
):
|
|
keep, info = tv.can_keep_chat_during_training(**kw)
|
|
self.assertFalse(keep)
|
|
self.assertEqual(info["reason"], "probe_error")
|
|
|
|
|
|
# ── can_keep_during_training (explicit GPU mode) ─────────────────────────────
|
|
|
|
|
|
class TestCanKeepExplicit(_GpuCacheResetMixin, unittest.TestCase):
|
|
def _run(
|
|
self,
|
|
*,
|
|
required,
|
|
devices,
|
|
resolved,
|
|
gpu_ids,
|
|
est_meta = None,
|
|
resolve_side_effect = None,
|
|
):
|
|
kw = {**_BASE_KW, "gpu_ids": gpu_ids}
|
|
resolve_kwargs = (
|
|
{"side_effect": resolve_side_effect}
|
|
if resolve_side_effect
|
|
else {"return_value": resolved}
|
|
)
|
|
with (
|
|
patch("utils.hardware.get_device", return_value = DeviceType.CUDA),
|
|
patch(
|
|
"utils.hardware.estimate_required_model_memory_gb",
|
|
return_value = (required, est_meta or {}),
|
|
),
|
|
patch(
|
|
"utils.hardware.get_visible_gpu_utilization",
|
|
return_value = {"devices": devices},
|
|
),
|
|
patch("utils.hardware.resolve_requested_gpu_ids", **resolve_kwargs),
|
|
patch("utils.hardware.auto_select_gpu_ids") as auto_mock,
|
|
):
|
|
keep, info = tv.can_keep_chat_during_training(**kw)
|
|
return keep, info, auto_mock
|
|
|
|
def test_keep_when_chosen_gpu_has_room(self):
|
|
devices = [{"index": 0, "vram_total_gb": 80.0, "vram_used_gb": 20.0}]
|
|
keep, info, auto_mock = self._run(required = 30.0, devices = devices, resolved = [0], gpu_ids = [0])
|
|
# free 60 >= 30*1.15+4 = 38.5
|
|
self.assertTrue(keep)
|
|
self.assertEqual(info["mode"], "explicit")
|
|
auto_mock.assert_not_called() # explicit mode never calls the selector
|
|
|
|
def test_unload_when_chosen_gpu_too_tight(self):
|
|
devices = [{"index": 0, "vram_total_gb": 24.0, "vram_used_gb": 20.0}]
|
|
keep, _, _ = self._run(required = 10.0, devices = devices, resolved = [0], gpu_ids = [0])
|
|
# free 4 < 10*1.15+4 = 15.5
|
|
self.assertFalse(keep)
|
|
|
|
def test_multi_gpu_overhead_applied(self):
|
|
# frees [20, 10]; without overhead usable=30, with overhead=20+10*0.85=28.5.
|
|
# required 22 -> threshold 22*1.15+4 = 29.3. 28.5 < 29.3 -> unload, proving
|
|
# the 0.85 overhead was applied (raw 30 would have kept).
|
|
devices = [
|
|
{"index": 0, "vram_total_gb": 24.0, "vram_used_gb": 4.0},
|
|
{"index": 1, "vram_total_gb": 24.0, "vram_used_gb": 14.0},
|
|
]
|
|
keep, info, _ = self._run(required = 22.0, devices = devices, resolved = [0, 1], gpu_ids = [0, 1])
|
|
self.assertFalse(keep)
|
|
self.assertAlmostEqual(info["usable_gb"], 28.5, places = 3)
|
|
|
|
def test_requested_gpu_missing_from_devices_counts_as_zero(self):
|
|
devices = [{"index": 0, "vram_total_gb": 80.0, "vram_used_gb": 5.0}]
|
|
# resolved [3] is absent -> free 0 -> unload.
|
|
keep, _, _ = self._run(required = 5.0, devices = devices, resolved = [3], gpu_ids = [3])
|
|
self.assertFalse(keep)
|
|
|
|
def test_unload_when_estimate_none(self):
|
|
with (
|
|
patch("utils.hardware.get_device", return_value = DeviceType.CUDA),
|
|
patch("utils.hardware.estimate_required_model_memory_gb", return_value = (None, {})),
|
|
patch("utils.hardware.resolve_requested_gpu_ids", return_value = [0]),
|
|
):
|
|
keep, info = tv.can_keep_chat_during_training(**{**_BASE_KW, "gpu_ids": [0]})
|
|
self.assertFalse(keep)
|
|
self.assertEqual(info["reason"], "estimate_unavailable")
|
|
|
|
def test_per_gpu_floor_blocks_uneven_explicit_split(self):
|
|
# free [45, 10]: aggregate 53.5 >= 50 passes, but GPU1's 10 < per-GPU
|
|
# floor 25 -> unload (the tight GPU would OOM).
|
|
devices = [
|
|
{"index": 0, "vram_total_gb": 80.0, "vram_used_gb": 35.0}, # 45 free
|
|
{"index": 1, "vram_total_gb": 80.0, "vram_used_gb": 70.0}, # 10 free
|
|
]
|
|
keep, info, _ = self._run(
|
|
required = 40.0,
|
|
devices = devices,
|
|
resolved = [0, 1],
|
|
gpu_ids = [0, 1],
|
|
est_meta = {"vram_breakdown": {"min_per_gpu_2": 25.0}},
|
|
)
|
|
self.assertFalse(keep)
|
|
self.assertAlmostEqual(info["min_free_gb"], 10.0, places = 3)
|
|
|
|
def test_per_gpu_floor_passes_when_even(self):
|
|
# Same aggregate, but both GPUs clear the 25 GB per-GPU floor -> keep.
|
|
devices = [
|
|
{"index": 0, "vram_total_gb": 80.0, "vram_used_gb": 45.0}, # 35 free
|
|
{"index": 1, "vram_total_gb": 80.0, "vram_used_gb": 50.0}, # 30 free
|
|
]
|
|
keep, _, _ = self._run(
|
|
required = 40.0,
|
|
devices = devices,
|
|
resolved = [0, 1],
|
|
gpu_ids = [0, 1],
|
|
est_meta = {"vram_breakdown": {"min_per_gpu_2": 25.0}},
|
|
)
|
|
self.assertTrue(keep)
|
|
|
|
def test_invalid_gpu_ids_keeps_chat_instead_of_unloading(self):
|
|
# resolve raising -> request will 400 before training, so leave chat alone.
|
|
keep, info, _ = self._run(
|
|
required = 5.0,
|
|
devices = [],
|
|
resolved = None,
|
|
gpu_ids = [99],
|
|
resolve_side_effect = ValueError("Invalid gpu_ids [99]"),
|
|
)
|
|
self.assertTrue(keep)
|
|
self.assertEqual(info["reason"], "invalid_gpu_ids")
|
|
|
|
|
|
# ── free_chat_models_for_training ────────────────────────────────────────────
|
|
|
|
|
|
class TestFreeChatModels(_GpuCacheResetMixin, unittest.TestCase):
|
|
def test_unloads_both_backends(self):
|
|
inf = _fake_inference_backend(active = "unsloth/Qwen3-4B")
|
|
llama = _fake_llama_backend(active = True, identifier = "gemma.gguf")
|
|
with _patch_backends(inf, llama):
|
|
freed = tv.free_chat_models_for_training(reason = "test")
|
|
inf._shutdown_subprocess.assert_called_once()
|
|
llama.unload_model.assert_called_once()
|
|
self.assertIn("hf:unsloth/Qwen3-4B", freed)
|
|
self.assertIn("gguf:gemma.gguf", freed)
|
|
# State cleared so a later resident check is accurate.
|
|
self.assertIsNone(inf.active_model_name)
|
|
self.assertEqual(inf.models, {})
|
|
self.assertEqual(inf.loading_models, set())
|
|
|
|
def test_unloads_gguf_only(self):
|
|
inf = _fake_inference_backend() # nothing resident
|
|
llama = _fake_llama_backend(active = True, identifier = "gemma.gguf")
|
|
with _patch_backends(inf, llama):
|
|
freed = tv.free_chat_models_for_training(reason = "test")
|
|
inf._shutdown_subprocess.assert_not_called()
|
|
llama.unload_model.assert_called_once()
|
|
self.assertEqual(freed, ["gguf:gemma.gguf"])
|
|
|
|
def test_leaves_cpu_only_gguf_alone(self):
|
|
# Killing a CPU-only llama-server cannot reclaim VRAM, so don't.
|
|
inf = _fake_inference_backend()
|
|
llama = _fake_llama_backend(active = True, identifier = "cpu.gguf", gpu_offload = False)
|
|
with _patch_backends(inf, llama):
|
|
freed = tv.free_chat_models_for_training(reason = "test")
|
|
llama.unload_model.assert_not_called()
|
|
self.assertEqual(freed, [])
|
|
|
|
def test_unloads_inflight_hf_load(self):
|
|
inf = _fake_inference_backend(active = None, loading = ["unsloth/Qwen3-4B"])
|
|
llama = _fake_llama_backend(active = False)
|
|
with _patch_backends(inf, llama):
|
|
freed = tv.free_chat_models_for_training(reason = "test")
|
|
inf._shutdown_subprocess.assert_called_once()
|
|
self.assertEqual(freed, ["hf:unsloth/Qwen3-4B"])
|
|
|
|
def test_nothing_to_free(self):
|
|
inf = _fake_inference_backend()
|
|
llama = _fake_llama_backend(active = False)
|
|
with _patch_backends(inf, llama):
|
|
freed = tv.free_chat_models_for_training(reason = "test")
|
|
self.assertEqual(freed, [])
|
|
|
|
def test_hf_failure_still_unloads_gguf(self):
|
|
bad_inf = SimpleNamespace() # AttributeError on access
|
|
llama = _fake_llama_backend(active = True, identifier = "gemma.gguf")
|
|
with _patch_backends(bad_inf, llama):
|
|
freed = tv.free_chat_models_for_training(reason = "test")
|
|
llama.unload_model.assert_called_once()
|
|
self.assertEqual(freed, ["gguf:gemma.gguf"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|