refactor(services): unify local model name matching with uniqueness and base-model guards (#1065)

Consolidate the duplicate name-matching logic into ModelScanner:
find_matching_models is now the single core, using each scanner's own
file_extensions for suffix stripping. get_model_info_by_name gains
require_unique/base_model kwargs while legacy route behavior is kept
byte-identical. reconnect_lora passes the recipe base model as a guard
and distinguishes ambiguous, base-model-mismatched, and missing LoRAs
in its error messages.
This commit is contained in:
Will Miao
2026-08-19 21:02:04 +08:00
parent 7fc3b7e5be
commit 8a16034135
5 changed files with 214 additions and 39 deletions
+29 -1
View File
@@ -57,9 +57,33 @@ class StubLoraScanner:
meta = self._hash_meta.get(hash_value.lower())
return meta.get("path") if meta else None
async def get_model_info_by_name(self, name: str):
async def get_model_info_by_name(
self,
name: str,
*,
require_unique: bool = False,
base_model: str | None = None,
):
if require_unique or base_model:
matches = ModelScanner.find_matching_models(
self._cache.raw_data,
name,
base_model=base_model,
extensions={".safetensors"},
)
if require_unique and len(matches) != 1:
return None
return matches[0] if matches else None
return self._models_by_name.get(name)
async def find_models_by_name(self, name: str, *, base_model: str | None = None):
return ModelScanner.find_matching_models(
self._cache.raw_data,
name,
base_model=base_model,
extensions={".safetensors"},
)
def register_model(self, name: str, info: Dict[str, Any]) -> None:
self._models_by_name[name] = info
hash_value = (info.get("sha256") or "").lower()
@@ -130,7 +154,11 @@ async def test_local_lora_lookup_requires_unambiguous_name_and_matching_base_mod
stub._hash_meta["b" * 64] = {"path": models[1]["file_path"]}
assert await scanner.get_local_lora("style") is None
assert await scanner.get_local_lora("style", "SDXL 1.0") is models[1]
assert await scanner.get_local_lora("sdxl/style.safetensors", "SDXL 1.0") is models[1]
# The lora scanner only indexes .safetensors, so a .pt name must not be
# stripped into a cross-extension match.
assert await scanner.get_local_lora("sdxl/style.pt", "SDXL 1.0") is None
assert await scanner.get_local_lora("sdxl/style.safetensors", "SD 1.5") is None
assert await scanner.get_local_lora("other/style.safetensors") is None
assert await scanner.get_local_lora_by_hash("b" * 64) is models[1]
+70
View File
@@ -17,6 +17,7 @@ from py.services.recipes.errors import (
RecipeValidationError,
)
from py.services.recipes.persistence_service import RecipePersistenceService
from py.services.model_scanner import ModelScanner
from py.recipes.parsers.civitai_image import CivitaiApiMetadataParser
from py.utils.exif_utils import ExifUtils
@@ -1156,3 +1157,72 @@ async def test_analyze_local_image_fingerprint_uses_sha256_normalized_hash(tmp_p
assert result.payload["loras"][0]["hash"] == sha256
assert result.payload["fingerprint"] == f"{sha256}:1.0"
@pytest.mark.asyncio
async def test_reconnect_lora_distinguishes_ambiguous_mismatched_and_missing(tmp_path):
service = RecipePersistenceService(
exif_utils=DummyExifUtils(),
card_preview_width=512,
logger=logging.getLogger("test"),
)
models = [
{
"file_name": "style.safetensors",
"folder": "sd15",
"file_path": "/models/loras/sd15/style.safetensors",
"base_model": "SD 1.5",
},
{
"file_name": "style.safetensors",
"folder": "sdxl",
"file_path": "/models/loras/sdxl/style.safetensors",
"base_model": "SDXL 1.0",
},
]
class DummyScanner:
def __init__(self, recipe_path):
self._recipe_path = recipe_path
async def get_recipe_json_path(self, recipe_id):
return str(self._recipe_path)
async def get_local_lora(self, name, base_model=None):
matches = ModelScanner.find_matching_models(models, name, base_model=base_model)
return matches[0] if len(matches) == 1 else None
async def find_local_loras_by_name(self, name, base_model=None):
return ModelScanner.find_matching_models(models, name, base_model=base_model)
def write_recipe(base_model):
recipe_path = tmp_path / "recipe.json"
recipe_path.write_text(
json.dumps({"id": "r1", "base_model": base_model, "loras": []})
)
return DummyScanner(recipe_path)
# Ambiguous bare name: two candidates survive (recipe base model unknown)
scanner = write_recipe("")
with pytest.raises(RecipeValidationError, match="include the folder path"):
await service.reconnect_lora(
recipe_scanner=scanner, recipe_id="r1", lora_index=0, target_name="style"
)
# Confident base-model mismatch: the only candidate belongs to another family
scanner = write_recipe("SD 1.5")
with pytest.raises(RecipeValidationError, match="different base model"):
await service.reconnect_lora(
recipe_scanner=scanner,
recipe_id="r1",
lora_index=0,
target_name="sdxl/style",
)
# No candidate at all
scanner = write_recipe("SDXL 1.0")
with pytest.raises(RecipeNotFoundError, match="not found"):
await service.reconnect_lora(
recipe_scanner=scanner, recipe_id="r1", lora_index=0, target_name="missing"
)