Files
ComfyUI-Lora-Manager/py/services/download_routing.py
T
Will Miao c372a688de feat(download): support tag-based rules and show matched rule in download modal
Download root rules now accept match_type "tag": the rule hits when any
of its free-text tags equals one of the model's CivitAI tags (trimmed,
case-insensitive), so e.g. all "character" models can be routed to a NAS
root regardless of base model. Rules stay ordered — first match wins
across mixed base_model/tag rules.

- settings normalize keeps tag entries as free text (case-insensitive
  dedupe, no base-model whitelist); resolve_download_root gained a tags
  parameter and a match_download_root_rule helper
- the download flow passes the version's model tags into rule resolution
- the routing endpoint accepts tags and reports the matched rule; the
  versions endpoint ships model-level tags with each version so the
  location step can preview tag matches
- the settings rule editor gained a per-rule match-type dropdown; tag
  rules edit a comma-separated list (committed on blur/Enter) instead of
  the base model checkbox panel
- the download confirmation modal shows "Routed by rule: ..." next to
  the preselected root
2026-10-09 22:32:25 +08:00

222 lines
7.3 KiB
Python

"""Shared download routing logic.
Decides whether a download initiated from the checkpoint library should be
routed to the unet/diffusion-model roots instead of the checkpoint roots.
Used by both the download manager (at download time) and the download
routing HTTP endpoint (when the user picks a location in the UI), so the
two can never disagree.
"""
from __future__ import annotations
import logging
from typing import Iterable, Mapping, Optional, Sequence
from ..utils.constants import (
CHECKPOINT_BASE_MODELS,
CIVITAI_FILE_TYPE_TO_OTHER_SUB_TYPE,
CIVITAI_TYPE_TO_OTHER_SUB_TYPE,
DIFFUSION_MODEL_BASE_MODELS,
)
logger = logging.getLogger(__name__)
# File types reported by the CivitAI API that indicate a raw diffusion
# model (loaded via UNETLoader in ComfyUI) rather than a full checkpoint.
DIFFUSION_FILE_TYPES = frozenset({"UNet", "Diffusion Model"})
# Allowed values for the "unknown_base_model_routing" setting / the
# unknown_base_model_default parameter below.
ROUTING_DIFFUSION_MODEL = "diffusion_model"
ROUTING_CHECKPOINT = "checkpoint"
# Download-time model type -> download_root_rules folder key. The rule set is
# keyed by folder_paths keys, so a checkpoint download that resolves to the
# unet roots consults the "unet" rules, not the "checkpoints" ones.
MODEL_TYPE_TO_ROOT_RULE_KEY = {
"lora": "loras",
"checkpoint": "checkpoints",
"unet": "unet",
"embedding": "embeddings",
}
# Download-time model type -> the setting holding its default download root.
MODEL_TYPE_TO_DEFAULT_ROOT_SETTING = {
"lora": "default_lora_root",
"checkpoint": "default_checkpoint_root",
"unet": "default_unet_root",
"embedding": "default_embedding_root",
}
# match_type values understood by download_root_rules.
RULE_MATCH_TYPE_BASE_MODEL = "base_model"
RULE_MATCH_TYPE_TAG = "tag"
def _normalize_tag_set(tags: Optional[Iterable[object]]) -> frozenset:
"""Case-folded, trimmed tag set for rule matching (never None)."""
if not tags:
return frozenset()
return frozenset(
tag.strip().casefold()
for tag in tags
if isinstance(tag, str) and tag.strip()
)
def match_download_root_rule(
rules: Optional[Sequence[Mapping[str, object]]],
base_model: str,
tags: Optional[Iterable[object]] = None,
) -> Optional[Mapping[str, object]]:
"""Return the first rule matching this download, else ``None``.
Rules are ordered; the first match wins. ``base_model`` rules hit when
their ``match`` list contains ``base_model`` (exact match); ``tag`` rules
hit when any ``match`` entry equals one of the model's tags (trimmed,
case-insensitive). A rule with an empty/invalid root never matches.
"""
if not rules:
return None
model_tags = _normalize_tag_set(tags)
for rule in rules:
if not isinstance(rule, Mapping):
continue
match = rule.get("match")
if not isinstance(match, Sequence) or isinstance(match, (str, bytes)):
continue
match_type = rule.get("match_type")
if match_type == RULE_MATCH_TYPE_BASE_MODEL:
if not base_model or base_model not in match:
continue
elif match_type == RULE_MATCH_TYPE_TAG:
if not model_tags or not any(
isinstance(entry, str) and entry.strip().casefold() in model_tags
for entry in match
):
continue
else:
continue
root = rule.get("root")
if isinstance(root, str) and root.strip():
return rule
return None
def resolve_download_root(
rules: Optional[Sequence[Mapping[str, object]]],
base_model: str,
default_root: str,
tags: Optional[Iterable[object]] = None,
) -> str:
"""Return the root from the first matching rule, else ``default_root``.
See :func:`match_download_root_rule` for the matching semantics.
"""
rule = match_download_root_rule(rules, base_model, tags)
if rule is not None:
root = rule["root"]
logger.info(
"Download root rule matched (%s), routing to %s",
rule.get("match_type"),
root,
)
return root
return default_root
def is_diffusion_model_download(
model_type: str,
file_types: Iterable[str] = (),
base_model: str = "",
unknown_base_model_default: str = ROUTING_DIFFUSION_MODEL,
) -> bool:
"""Return True when a download should be routed to the unet roots.
Only applies to downloads initiated from the checkpoint library.
Priority: (1) any file has type "UNet" or "Diffusion Model" (the more
direct signal from CivitAI), (2) baseModel is a known diffusion model,
(3) baseModel is a known full checkpoint -> not diffusion, (4) unknown
or empty baseModel -> the ``unknown_base_model_default`` setting, which
defaults to diffusion because the set of true checkpoint families is
closed while new DiT base models appear all the time.
"""
if model_type != "checkpoint":
return False
for file_type in file_types:
if file_type in DIFFUSION_FILE_TYPES:
logger.info(
"File type '%s' detected, routing checkpoint to unet folder",
file_type,
)
return True
if base_model in DIFFUSION_MODEL_BASE_MODELS:
logger.info(
"baseModel '%s' is a known diffusion model, routing to unet folder",
base_model,
)
return True
if base_model in CHECKPOINT_BASE_MODELS:
return False
is_diffusion = unknown_base_model_default != ROUTING_CHECKPOINT
logger.info(
"baseModel '%s' is unknown, routing to %s folder (unknown_base_model_routing)",
base_model,
"unet" if is_diffusion else "checkpoint",
)
return is_diffusion
def resolve_other_download_sub_type(
civitai_model_type: str,
file_types: Iterable[str] = (),
selected_file_type: Optional[str] = None,
) -> Optional[str]:
"""Resolve the "other"-page sub_type for a download.
Fixed priority (locked design, docs/plans/other-models-page.md §9.2):
1. Explicit user file pick — when the picked file's type maps, it wins
even when model.type maps to something else.
2. model.type via CIVITAI_TYPE_TO_OTHER_SUB_TYPE.
3. file.type fallback — only when model.type maps to nothing. Must NOT
override a mapped model.type: checkpoint models routinely bundle
VAE/Text Encoder component files.
4. Still undecidable -> None (caller must ask the user for a folder).
"""
if selected_file_type:
mapped = CIVITAI_FILE_TYPE_TO_OTHER_SUB_TYPE.get(selected_file_type)
if mapped:
logger.info(
"Explicit file pick type '%s' routes other download to '%s'",
selected_file_type,
mapped,
)
return mapped
normalized_model_type = (civitai_model_type or "").strip().lower()
mapped = CIVITAI_TYPE_TO_OTHER_SUB_TYPE.get(normalized_model_type)
if mapped:
return mapped
for file_type in file_types:
mapped = CIVITAI_FILE_TYPE_TO_OTHER_SUB_TYPE.get(file_type)
if mapped:
logger.info(
"model.type '%s' unmapped; file type '%s' routes other download to '%s'",
civitai_model_type,
file_type,
mapped,
)
return mapped
return None