feat(backend): gate Other Models behind opt-in management toggles

Other Models management is now opt-in: enable_other_models (default false)
plus the enabled_other_sub_types allow-list replace the unreleased additive
enabled_other_folders key.

- config._get_enabled_other_folder_keys() is the single scan gate; a new
  refresh_other_roots() rebuilds roots and preview roots on toggle.
- ModelScanner gains a _should_keep_cached_entry() hydration hook and
  on_library_changed(reconcile=...) so switching a sub_type off drops its
  entries (and hash/autov3 rows) at load time and switching it on rescans.
- OtherScanner filters location-derived entries accordingly.
- Other routes reject every other type while off (or a disabled sub_type) and
  expose an "other_disabled" page flag; download routing returns a disabled
  marker instead of guessing; the download manager refuses other-type
  downloads and default-path routing for switched-off sub_types.
- Doctor / init-status / refresh-all skip the other scanner while off; the
  scanner stays registered so staged pending-deletes still merge.
- Tests updated with explicit opt-in fixtures plus new gating coverage.
This commit is contained in:
Will Miao
2026-09-13 07:59:28 +08:00
parent f88fe2665c
commit 28fbb86dce
19 changed files with 583 additions and 84 deletions
@@ -43,6 +43,14 @@ def isolate_settings(monkeypatch, tmp_path):
"text_encoder": str(tmp_path / "text_encoders"),
"clip_vision": str(tmp_path / "clip_vision"),
},
"enable_other_models": True,
"enabled_other_sub_types": [
"vae",
"upscaler",
"text_encoder",
"clip_vision",
"controlnet",
],
"download_path_templates": {
"lora": "{base_model}/{first_tag}",
"checkpoint": "{base_model}/{first_tag}",
@@ -231,6 +239,40 @@ async def test_download_rejects_unknown_model_type(
assert result["error"].startswith("Model type")
@pytest.mark.asyncio
async def test_download_rejects_other_when_feature_disabled(
monkeypatch, scanners, metadata_provider, tmp_path
):
"""The opt-in feature is off: no other-type download is accepted."""
metadata_provider.payload = _other_payload("VAE")
get_settings_manager().settings["enable_other_models"] = False
manager = DownloadManager()
result = await manager.download_from_civitai(
model_version_id=99, save_dir=str(tmp_path)
)
assert result["success"] is False
assert "disabled" in result["error"].lower()
@pytest.mark.asyncio
async def test_default_paths_reject_switched_off_sub_type(
monkeypatch, scanners, metadata_provider, tmp_path
):
"""A disabled sub_type refuses default-path routing (manual pick still works)."""
metadata_provider.payload = _other_payload("VAE")
get_settings_manager().settings["enabled_other_sub_types"] = ["upscaler"]
manager = DownloadManager()
result = await manager.download_from_civitai(
model_version_id=99, use_default_paths=True
)
assert result["success"] is False
assert "disabled" in result["error"].lower()
@pytest.mark.asyncio
async def test_early_gate_checks_other_scanner(
monkeypatch, scanners, metadata_provider, tmp_path
+45
View File
@@ -173,6 +173,51 @@ class TestOtherScannerRoots:
assert result["sub_type"] == "upscaler"
class TestOtherScannerHydrationFilter:
"""Persisted entries for roots that are no longer managed are dropped."""
def test_keeps_entries_under_enabled_roots(self, other_config):
scanner = _make_scanner()
assert (
scanner._should_keep_cached_entry(
{"file_path": f"{other_config['vae']}/model.safetensors"}
)
is True
)
def test_drops_entries_under_disabled_root(self, other_config, monkeypatch):
scanner = _make_scanner()
# Only vae stays managed; the upscaler root disappeared from the map.
monkeypatch.setattr(
config_module.config,
"other_root_subtypes",
{other_config["vae"]: "vae"},
)
assert (
scanner._should_keep_cached_entry(
{"file_path": f"{other_config['upscaler']}/model.safetensors"}
)
is False
)
assert (
scanner._should_keep_cached_entry(
{"file_path": f"{other_config['vae']}/model.safetensors"}
)
is True
)
def test_drops_everything_when_feature_off(self, monkeypatch):
monkeypatch.setattr(config_module.config, "other_root_subtypes", {})
scanner = _make_scanner()
assert (
scanner._should_keep_cached_entry(
{"file_path": "/models/vae/model.safetensors"}
)
is False
)
class TestOtherScannerLazyHash:
"""Lazy hashing: pending by default, singleflight on-demand calculation."""
+1 -1
View File
@@ -2513,7 +2513,7 @@ async def test_on_library_changed_bumps_cache_version(tmp_path: Path, monkeypatc
scanner = DummyScanner(str(tmp_path))
assert scanner.cache_version == 0
async def _noop_initialize() -> None:
async def _noop_initialize(reconcile: bool = False) -> None:
pass
monkeypatch.setattr(scanner, "initialize_in_background", _noop_initialize)
+29
View File
@@ -1222,7 +1222,33 @@ def test_default_other_roots_stay_empty_without_other_folders(manager):
assert manager.get("default_other_roots") == {}
def test_other_models_disabled_by_default(manager):
assert manager.is_other_models_enabled() is False
assert manager.get_enabled_other_sub_types() == []
assert manager.is_other_sub_type_enabled("vae") is False
def test_auto_set_default_other_roots_skipped_when_feature_off(manager):
manager.settings["enable_other_models"] = False
manager.settings["default_other_roots"] = {}
manager.settings["folder_paths"] = {"vae": ["/vae"]}
manager._auto_set_default_roots()
assert manager.get("default_other_roots") == {}
def test_set_enabled_other_sub_types_normalizes(manager):
manager.settings["enable_other_models"] = True
manager.set("enabled_other_sub_types", ["controlnet", "vae", "nope", "vae", 42])
assert manager.get("enabled_other_sub_types") == ["vae", "controlnet"]
assert manager.is_other_sub_type_enabled("vae") is True
assert manager.is_other_sub_type_enabled("upscaler") is False
def test_auto_set_default_other_roots(manager):
manager.settings["enable_other_models"] = True
manager.settings["default_other_roots"] = {}
manager.settings["folder_paths"] = {
"vae": ["/vae"],
@@ -1243,6 +1269,7 @@ def test_auto_set_default_other_roots(manager):
def test_auto_set_default_other_roots_text_encoder_dual_key_union(manager):
"""text_encoder candidates merge text_encoders and the legacy clip key."""
manager.settings["enable_other_models"] = True
manager.settings["default_other_roots"] = {}
manager.settings["folder_paths"] = {
"clip": ["/legacy-clip"],
@@ -1261,6 +1288,7 @@ def test_auto_set_default_other_roots_text_encoder_dual_key_union(manager):
def test_auto_set_default_other_roots_repairs_stale(manager):
manager.settings["enable_other_models"] = True
manager.settings["default_other_roots"] = {"vae": "/stale-vae"}
manager.settings["folder_paths"] = {"vae": ["/vae"]}
@@ -1270,6 +1298,7 @@ def test_auto_set_default_other_roots_repairs_stale(manager):
def test_auto_set_default_other_roots_uses_extra_folder_paths(manager):
manager.settings["enable_other_models"] = True
manager.settings["default_other_roots"] = {}
manager.settings["folder_paths"] = {"vae": []}
manager.settings["extra_folder_paths"] = {"vae": ["/extra-vae"]}