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
+5
View File
@@ -149,6 +149,7 @@ class BaseModelRoutes(ABC):
settings_service=self._settings,
server_i18n=self._server_i18n,
logger=logger,
page_context_provider=self._get_page_context_provider(),
)
listing = ModelListingHandler(
service=service,
@@ -250,6 +251,10 @@ class BaseModelRoutes(ABC):
"""Get expected model types string for error messages - to be overridden by subclasses."""
return "any model type"
def _get_page_context_provider(self):
"""Optional hook returning extra template context for the page view."""
return None
def _find_model_file(self, files):
"""Find the appropriate model file from the files list - can be overridden by subclasses."""
return next((file for file in files if file.get("type") in MODEL_WEIGHT_FILE_TYPES and file.get("primary") is True), None)
@@ -56,11 +56,38 @@ class DownloadRoutingHandler:
)
if model_type.lower() in VALID_OTHER_CIVITAI_TYPES:
from ...services.settings_manager import get_settings_manager
settings = get_settings_manager()
if not settings.is_other_models_enabled():
# Opt-in feature is off: never auto-route, the UI falls back to
# manual folder selection and the download manager rejects it.
return web.json_response(
{
"success": True,
"root_kind": "other",
"sub_type": None,
"disabled": True,
"reason": "other_models_disabled",
}
)
sub_type = resolve_other_download_sub_type(
model_type,
file_types=(str(t) for t in file_types),
selected_file_type=selected_file_type,
)
if sub_type and not settings.is_other_sub_type_enabled(sub_type):
return web.json_response(
{
"success": True,
"root_kind": "other",
"sub_type": None,
"disabled": True,
"reason": "other_sub_type_disabled",
"requested_sub_type": sub_type,
}
)
return web.json_response(
{
"success": True,
+26 -5
View File
@@ -663,6 +663,17 @@ class HealthCheckHandler:
"recipe": ServiceRegistry.get_recipe_scanner,
}
def _active_scanner_getters(
self,
) -> Mapping[str, Callable[[], Awaitable[Any]]]:
"""Drop the opt-in other scanner while Other Models is disabled."""
getters = self._scanner_getters
if "other" not in getters:
return getters
if get_settings_manager().is_other_models_enabled():
return getters
return {name: getter for name, getter in getters.items() if name != "other"}
async def health_check(self, request: web.Request) -> web.Response:
return web.json_response({"status": "ok"})
@@ -674,7 +685,7 @@ class HealthCheckHandler:
page accepts the update and only reloads once all scanners are done.
"""
pending: list[str] = []
for name, getter in self._scanner_getters.items():
for name, getter in self._active_scanner_getters().items():
try:
scanner = await getter()
except Exception:
@@ -764,6 +775,14 @@ class DoctorHandler:
)
self._app_version_getter = app_version_getter
def _active_scanner_factories(
self,
) -> Sequence[tuple[str, str, Callable[[], Awaitable[Any]]]]:
"""Drop the opt-in other scanner while Other Models is disabled."""
if self._settings.is_other_models_enabled():
return self._scanner_factories
return tuple(entry for entry in self._scanner_factories if entry[0] != "other")
async def get_doctor_diagnostics(self, request: web.Request) -> web.Response:
try:
client_version = (request.query.get("clientVersion") or "").strip()
@@ -811,7 +830,7 @@ class DoctorHandler:
repaired: list[dict[str, Any]] = []
failures: list[dict[str, str]] = []
for model_type, label, factory in self._scanner_factories:
for model_type, label, factory in self._active_scanner_factories():
try:
scanner = await factory()
await scanner.get_cached_data(force_refresh=True, rebuild_cache=True)
@@ -843,7 +862,7 @@ class DoctorHandler:
renamed: list[dict[str, Any]] = []
try:
for model_type, label, factory in self._scanner_factories:
for model_type, label, factory in self._active_scanner_factories():
try:
scanner = await factory()
hash_index = getattr(scanner, "_hash_index", None)
@@ -1075,7 +1094,7 @@ class DoctorHandler:
overall_status = "ok"
summary = "All model caches look healthy."
for model_type, label, factory in self._scanner_factories:
for model_type, label, factory in self._active_scanner_factories():
try:
scanner = await factory()
persisted = None
@@ -1160,7 +1179,7 @@ class DoctorHandler:
total_conflict_groups = 0
total_conflict_files = 0
for model_type, label, factory in self._scanner_factories:
for model_type, label, factory in self._active_scanner_factories():
# Duplicate filename detection targets LoRAs which use basename-only
# syntax (<lora:name:strength>). Checkpoints/embeddings reference
# models via relative paths with extensions, so conflicts there would
@@ -2797,6 +2816,8 @@ class ModelLibraryHandler:
# Acquire the other scanner lazily so adapters without it only
# fail when the payload actually contains other-type models.
# While the opt-in feature is off the scanner still exists (its
# cache is empty), so other types simply report inLibrary=False.
needs_other_scanner = any(
isinstance(model, dict)
and str(model.get("type", "")).lower() in other_type_aliases
+12
View File
@@ -90,6 +90,7 @@ class ModelPageView:
settings_service: SettingsManager,
server_i18n,
logger: logging.Logger,
page_context_provider: Callable[[web.Request], Dict[str, Any]] | None = None,
) -> None:
self._template_env = template_env
self._template_name = template_name
@@ -97,6 +98,7 @@ class ModelPageView:
self._settings = settings_service
self._server_i18n = server_i18n
self._logger = logger
self._page_context_provider = page_context_provider
def _load_supporters(self) -> dict[str, Any]:
"""Load supporters data from JSON file."""
@@ -210,6 +212,16 @@ class ModelPageView:
self._logger.error("Error loading cache data: %s", cache_error)
template_context["is_initializing"] = True
if self._page_context_provider is not None:
try:
extra_context = self._page_context_provider(request)
if isinstance(extra_context, dict):
template_context.update(extra_context)
except Exception as context_error: # pragma: no cover - logging path
self._logger.error(
"Error building page context: %s", context_error
)
rendered = self._template_env.get_template(self._template_name).render(
**template_context
)
+26 -3
View File
@@ -7,7 +7,11 @@ from .model_route_registrar import ModelRouteRegistrar
from ..config import config
from ..services.other_model_service import OtherModelService
from ..services.service_registry import ServiceRegistry
from ..utils.constants import OTHER_MODEL_FOLDER_SUBTYPES, VALID_OTHER_CIVITAI_TYPES
from ..utils.constants import (
CIVITAI_TYPE_TO_OTHER_SUB_TYPE,
OTHER_MODEL_FOLDER_SUBTYPES,
VALID_OTHER_CIVITAI_TYPES,
)
logger = logging.getLogger(__name__)
@@ -50,9 +54,28 @@ class OtherRoutes(BaseModelRoutes):
"""Validate CivitAI model type for other models.
Accepts retired CivitAI types (CLIP, CLIPVision) as well grandfathered
models on CivitAI still carry them.
models on CivitAI still carry them. Types whose sub_type is currently
disabled (or every type while the opt-in feature is off) are rejected.
"""
return model_type.lower() in VALID_OTHER_CIVITAI_TYPES
normalized = (model_type or "").strip().lower()
if normalized not in VALID_OTHER_CIVITAI_TYPES:
return False
if not self._settings.is_other_models_enabled():
return False
sub_type = CIVITAI_TYPE_TO_OTHER_SUB_TYPE.get(normalized)
if sub_type is None:
# CivitAI "Other" has no sub_type of its own; it is only usable
# while at least one sub_type is enabled.
return bool(self._settings.get_enabled_other_sub_types())
return self._settings.is_other_sub_type_enabled(sub_type)
def _get_page_context_provider(self):
"""Expose the opt-in feature state to the Other Models page template."""
return self._page_context_for_other
def _page_context_for_other(self, request: web.Request) -> Dict[str, Any]:
return {"other_disabled": not self._settings.is_other_models_enabled()}
def _get_expected_model_types(self) -> str:
"""Get expected model types string for error messages"""