Files
Will Miao 0b6348be5c fix(recipes): resolve CivitAI-id models from the local library before lookup
Importing an image whose metadata references a model by CivitAI id
(ComfyUI URN names, A1111 "Civitai resources", recipe checkpoint blocks)
always queried the metadata provider, even when the model was already in
the library. That made large imports network-bound and prone to vendor
rate limiting for no benefit.

Parsers now resolve the id against the scanner's version index first and
fill the entry from the local cache (model name, base model, hash,
preview, size). The provider is only consulted on a miss, matching the
existing hash-based local-first path.

- RecipeScanner: get_local_lora_by_version_id / get_local_checkpoint_by_version_id
- RecipeMetadataParser: populate_checkpoint_from_local + resolver helpers
- comfy / automatic / recipe_format parsers use the local lookup first
2026-10-29 09:05:50 +08:00

664 lines
24 KiB
Python

import pytest
from py.recipes.parsers.automatic import AutomaticMetadataParser
class LocalRecipeScanner:
class LoraScanner:
@staticmethod
def has_hash(model_hash):
return False
def __init__(self, models, models_by_version=None, checkpoints_by_version=None):
self.models = models
self.models_by_version = models_by_version or {}
self.checkpoints_by_version = checkpoints_by_version or {}
self.queries = []
self.hash_queries = []
self.version_queries = []
self.checkpoint_version_queries = []
self._lora_scanner = self.LoraScanner()
async def get_local_lora(self, name, base_model=None):
self.queries.append(name)
return self.models.get(name)
async def get_local_lora_by_hash(self, hash_value):
self.hash_queries.append(hash_value)
return next((model for model in self.models.values() if model.get("sha256") == hash_value), None)
async def get_local_lora_by_version_id(self, version_id):
self.version_queries.append(version_id)
return self.models_by_version.get(int(version_id))
async def get_local_checkpoint_by_version_id(self, version_id):
self.checkpoint_version_queries.append(version_id)
return self.checkpoints_by_version.get(int(version_id))
def local_lora(file_name="local_only"):
return {
"file_path": f"/models/loras/styles/{file_name}.safetensors",
"file_name": file_name,
"model_name": "Local Only",
"sha256": "a" * 64,
"size": 123456,
"base_model": "Flux.1 D",
"preview_url": f"/models/loras/styles/{file_name}.preview.png",
"civitai": None,
}
@pytest.mark.asyncio
async def test_parse_metadata_extracts_checkpoint_from_civitai_resources(monkeypatch):
checkpoint_info = {
"id": 2442439,
"modelId": 123456,
"model": {"name": "Z Image", "type": "checkpoint"},
"name": "Turbo",
"images": [{"url": "https://image.civitai.com/checkpoints/original=true"}],
"baseModel": "sdxl",
"downloadUrl": "https://civitai.com/api/download/checkpoint",
"files": [
{
"type": "Model",
"primary": True,
"sizeKB": 2048,
"name": "Z_Image_Turbo.safetensors",
"hashes": {"SHA256": "ABC123FF"},
}
],
}
async def fake_metadata_provider():
class Provider:
async def get_model_version_info(self, version_id):
assert version_id == "2442439"
return checkpoint_info, None
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.automatic.get_default_metadata_provider",
fake_metadata_provider,
)
parser = AutomaticMetadataParser()
metadata_text = (
"Negative space, fog, BLACK blue color GRADIENT BACKGROUND, a vintage car in the middle, "
"FOG, and a silhouetted figure near the car, in the style of the Blade Runner movie "
"Negative prompt: Steps: 23, Sampler: Undefined, CFG scale: 3.5, Seed: 1760020955, "
"Size: 832x1216, Clip skip: 2, Created Date: 2025-11-28T09:18:43.5269343Z, "
'Civitai resources: [{"type":"checkpoint","modelVersionId":2442439,"modelName":"Z Image","modelVersionName":"Turbo"}], '
"Civitai metadata: {}"
)
result = await parser.parse_metadata(metadata_text)
checkpoint = result.get("checkpoint")
assert checkpoint is not None
assert checkpoint["name"] == "Z Image"
assert checkpoint["version"] == "Turbo"
assert checkpoint["type"] == "checkpoint"
assert checkpoint["modelId"] == 123456
assert checkpoint["hash"] == "abc123ff"
assert checkpoint["file_name"] == "Z_Image_Turbo"
assert checkpoint["thumbnailUrl"].endswith("width=450,optimized=true")
assert result["model"] == checkpoint
assert result["base_model"] == "sdxl"
assert result["loras"] == []
@pytest.mark.asyncio
async def test_parse_metadata_merges_lora_hashes_over_empty_hashes_json(monkeypatch):
"""When Hashes JSON has empty lora hashes but Lora hashes text field has
real ones, the real hashes should be used and those LoRAs resolved
correctly; entries with empty hashes in both sources should be skipped."""
lora_version_info = {
"id": 947620,
"modelId": 98765,
"model": {"name": "cfg_scale_boost", "type": "LORA"},
"name": "v1",
"images": [{"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/original=true"}],
"baseModel": "illustrious",
"downloadUrl": "https://civitai.com/api/download/models/947620",
"files": [
{
"type": "Model",
"primary": True,
"sizeKB": 1024,
"name": "cfg_scale_boost.safetensors",
"hashes": {"SHA256": "4605b2de07"},
}
],
}
async def fake_metadata_provider():
class Provider:
async def get_model_by_hash(self, model_hash):
assert model_hash == "4605b2de07"
return lora_version_info, None
async def get_model_version_info(self, version_id):
raise AssertionError("get_model_version_info should not be called")
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.automatic.get_default_metadata_provider",
fake_metadata_provider,
)
parser = AutomaticMetadataParser()
metadata_text = (
"a cyberpunk portrait <lora:cfg_scale_boost:0.6>\n"
"Negative prompt: low quality\n"
"Steps: 20, Sampler: Euler a, CFG scale: 7, Seed: 123456, Size: 512x768, "
"Model hash: abc123, Model: test.safetensors, "
'Lora hashes: "cfg_scale_boost: 4605b2de07, EmptyLora: ", '
'Hashes: {"model": "abc123", "lora:cfg_scale_boost": "", "lora:EmptyLora": "", "lora:UnusedLora": ""}'
)
result = await parser.parse_metadata(metadata_text)
# cfg_scale_boost should be resolved (hash from Lora hashes overrode empty Hashes JSON)
loras = result.get("loras", [])
assert len(loras) == 1, f"Expected 1 LoRA, got {len(loras)}"
lora = loras[0]
assert lora["name"] == "cfg_scale_boost", f"Expected cfg_scale_boost, got {lora['name']}"
assert lora["hash"] == "4605b2de07", f"Expected hash 4605b2de07, got {lora['hash']}"
assert lora.get("isDeleted") in (None, False), f"LoRA should not be deleted"
assert lora["weight"] == 0.6, f"Expected weight 0.6, got {lora['weight']}"
# EmptyLora and UnusedLora should be skipped (no hash in either source)
lora_names = [l["name"] for l in loras]
assert "EmptyLora" not in lora_names, "EmptyLora should have been skipped"
assert "UnusedLora" not in lora_names, "UnusedLora should have been skipped"
@pytest.mark.asyncio
async def test_parse_metadata_lora_hashes_override_conflicting_hashes_json(monkeypatch):
"""When Hashes JSON carries a non-empty but stale hash and the Lora
hashes text field carries the real 12-char AutoV3 hash, the Lora hashes
value must win: CivitAI is queried with it and the entry is resolved
instead of being poisoned by the stale hash."""
lora_version_info = {
"id": 359072,
"modelId": 320224,
"model": {"name": "Daphne Blake Cosplay (Scooby Doo)", "type": "LORA"},
"name": "v1.0",
"images": [{"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/original=true"}],
"baseModel": "SD 1.5",
"downloadUrl": "https://civitai.com/api/download/models/359072",
"files": [
{
"type": "Model",
"primary": True,
"sizeKB": 1024,
"name": "Daphne Blake Cosplay_v1.safetensors",
"hashes": {"SHA256": "533317d3f7d269f9f504bdc432514774d3ada3738ebd80f3f1a37ff848e88276"},
}
],
}
queried_hashes = []
async def fake_metadata_provider():
class Provider:
async def get_model_by_hash(self, model_hash):
queried_hashes.append(model_hash)
if model_hash == "e67ebd5e315f":
return lora_version_info, None
return None, "Model not found"
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.automatic.get_default_metadata_provider",
fake_metadata_provider,
)
parser = AutomaticMetadataParser()
metadata_text = (
"woman, natural blonde hair, ice blue eyes, <lora:Daphne Blake Cosplay_v1:1> "
"daphne blake cosplay, upper body\n"
"Negative prompt: low quality\n"
"Steps: 20, Sampler: DPM++ 2M Karras, CFG scale: 7, Seed: 4140408634, "
"Size: 512x768, Model hash: 3c8530cb22, Model: cyberrealistic_v33, "
'Lora hashes: "Daphne Blake Cosplay_v1: e67ebd5e315f", '
'Hashes: {"lora:Daphne Blake Cosplay_v1": "a2a12bfa01"}'
)
result = await parser.parse_metadata(metadata_text)
assert "e67ebd5e315f" in queried_hashes, (
f"CivitAI must be queried with the Lora hashes value, got {queried_hashes}"
)
assert "a2a12bfa01" not in queried_hashes, (
"the stale Hashes JSON value must never be used for CivitAI lookup"
)
loras = result.get("loras", [])
assert len(loras) == 1
lora = loras[0]
assert lora["hash"] == "533317d3f7d269f9f504bdc432514774d3ada3738ebd80f3f1a37ff848e88276"
assert lora["id"] == 359072
assert lora["modelId"] == 320224
assert lora.get("isDeleted") in (None, False)
@pytest.mark.asyncio
async def test_parse_metadata_resolves_local_lora_with_empty_hash(monkeypatch):
async def fake_metadata_provider():
class Provider:
async def get_model_by_hash(self, model_hash):
raise AssertionError("Local and empty-hash LoRAs must not query Civitai")
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.automatic.get_default_metadata_provider",
fake_metadata_provider,
)
scanner = LocalRecipeScanner({"local_only": local_lora()})
metadata_text = (
"portrait <lora:local_only:0.65> <lora:missing:0.4>\n"
"Steps: 20, Sampler: Euler, CFG scale: 7, Seed: 1, "
'Hashes: {"lora:local_only": "", "lora:missing": ""}'
)
result = await AutomaticMetadataParser().parse_metadata(metadata_text, scanner)
assert len(result["loras"]) == 1
entry = result["loras"][0]
assert entry["name"] == "Local Only"
assert entry["file_name"] == "local_only"
assert entry["weight"] == 0.65
assert entry["hash"] == "a" * 64
assert entry["localPath"].endswith("local_only.safetensors")
assert entry["size"] == 123456
assert entry["baseModel"] == "Flux.1 D"
assert entry["existsLocally"] is True
assert entry["isDeleted"] is False
assert scanner.queries == ["local_only", "missing"]
@pytest.mark.asyncio
async def test_parse_metadata_resolves_prompt_lora_without_hashes(monkeypatch):
async def fake_metadata_provider():
return None
monkeypatch.setattr(
"py.recipes.parsers.automatic.get_default_metadata_provider",
fake_metadata_provider,
)
model = local_lora()
scanner = LocalRecipeScanner({"styles/local_only": model})
metadata_text = "portrait <lora:styles/local_only:0.7>\nSteps: 20, Seed: 1"
result = await AutomaticMetadataParser().parse_metadata(metadata_text, scanner)
assert len(result["loras"]) == 1
assert result["loras"][0]["weight"] == 0.7
assert result["loras"][0]["hash"] == "a" * 64
assert scanner.queries == ["styles/local_only"]
@pytest.mark.asyncio
async def test_parse_metadata_prefers_hash_over_colliding_local_name(monkeypatch):
remote_info = {
"id": 100,
"modelId": 200,
"model": {"name": "Hash Match", "type": "LORA"},
"name": "v1",
"files": [{"type": "Model", "primary": True, "name": "hash_match.safetensors", "hashes": {"SHA256": "b" * 64}}],
}
async def fake_metadata_provider():
class Provider:
async def get_model_by_hash(self, model_hash):
assert model_hash == "deadbeef00"
return remote_info, None
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.automatic.get_default_metadata_provider",
fake_metadata_provider,
)
scanner = LocalRecipeScanner({"local_only": local_lora()})
metadata_text = (
"portrait <lora:local_only:0.8>\n"
"Steps: 20, Seed: 1, "
'Hashes: {"lora:local_only": "deadbeef00"}'
)
result = await AutomaticMetadataParser().parse_metadata(metadata_text, scanner)
assert len(result["loras"]) == 1
assert result["loras"][0]["id"] == 100
assert result["loras"][0]["weight"] == 0.8
assert scanner.queries == []
assert scanner.hash_queries == ["deadbeef00"]
@pytest.mark.asyncio
async def test_parse_metadata_falls_back_to_name_when_hash_is_unresolved(monkeypatch):
async def fake_metadata_provider():
class Provider:
async def get_model_by_hash(self, model_hash):
return None, "Model not found"
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.automatic.get_default_metadata_provider",
fake_metadata_provider,
)
scanner = LocalRecipeScanner({"local_only": local_lora()})
metadata_text = (
"portrait <lora:local_only:0.8>\nSteps: 20, Seed: 1, "
'Hashes: {"lora:local_only": "deadbeef00"}'
)
result = await AutomaticMetadataParser().parse_metadata(metadata_text, scanner)
assert result["loras"][0]["hash"] == "a" * 64
assert scanner.queries == ["local_only"]
@pytest.mark.asyncio
async def test_parse_metadata_uses_prompt_weight_for_civitai_resource(monkeypatch):
remote_info = {
"id": 100,
"modelId": 200,
"model": {"name": "local_only", "type": "LORA"},
"name": "v1",
"files": [
{
"type": "Model",
"primary": True,
"name": "remote_file.safetensors",
"hashes": {"SHA256": "b" * 64},
}
],
}
async def fake_metadata_provider():
class Provider:
async def get_model_version_info(self, version_id):
assert version_id == 100
return remote_info, None
async def get_model_by_hash(self, model_hash):
raise AssertionError("The Civitai resource should not be fetched again by hash")
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.automatic.get_default_metadata_provider",
fake_metadata_provider,
)
scanner = LocalRecipeScanner({"local_only": local_lora()})
metadata_text = (
"portrait <lora:remote_file:0.35> <lora:local_only:0.6>\n"
"Steps: 20, Seed: 1, "
'Civitai resources: [{"type":"lora","modelVersionId":100,"modelName":"local_only"}]'
)
result = await AutomaticMetadataParser().parse_metadata(metadata_text, scanner)
assert len(result["loras"]) == 2
assert [entry["file_name"] for entry in result["loras"]] == ["remote_file", "local_only"]
assert [entry["weight"] for entry in result["loras"]] == [0.35, 0.6]
assert result["loras"][1]["existsLocally"] is True
assert scanner.queries == ["local_only"]
@pytest.mark.asyncio
async def test_parse_metadata_keeps_mixed_local_and_civitai_loras(monkeypatch):
remote_info = {
"id": 100,
"modelId": 200,
"model": {"name": "Remote LoRA", "type": "LORA"},
"name": "v1",
"files": [
{
"type": "Model",
"primary": True,
"name": "remote.safetensors",
"hashes": {"SHA256": "b" * 64},
}
],
}
async def fake_metadata_provider():
class Provider:
async def get_model_by_hash(self, model_hash):
assert model_hash == "bbbbbbbbbb"
return remote_info, None
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.automatic.get_default_metadata_provider",
fake_metadata_provider,
)
scanner = LocalRecipeScanner({"local_only": local_lora()})
metadata_text = (
"portrait <lora:local_only:0.6> <lora:remote:0.9>\n"
"Steps: 20, Seed: 1, "
'Hashes: {"lora:local_only": "", "lora:remote": "bbbbbbbbbb"}'
)
result = await AutomaticMetadataParser().parse_metadata(metadata_text, scanner)
assert [entry["name"] for entry in result["loras"]] == ["Local Only", "Remote LoRA"]
assert [entry["weight"] for entry in result["loras"]] == [0.6, 0.9]
assert result["loras"][0]["existsLocally"] is True
assert result["loras"][1]["existsLocally"] is False
@pytest.mark.asyncio
async def test_parse_metadata_extracts_checkpoint_from_model_hash(monkeypatch):
checkpoint_info = {
"id": 98765,
"modelId": 654321,
"model": {"name": "Flux Illustrious", "type": "checkpoint"},
"name": "v1",
"images": [{"url": "https://image.civitai.com/checkpoints/original=true"}],
"baseModel": "flux",
"downloadUrl": "https://civitai.com/api/download/checkpoint",
"files": [
{
"type": "Model",
"primary": True,
"sizeKB": 1024,
"name": "FluxIllustrious_v1.safetensors",
"hashes": {"SHA256": "C3688EE04C"},
}
],
}
async def fake_metadata_provider():
class Provider:
async def get_model_by_hash(self, model_hash):
assert model_hash == "c3688ee04c"
return checkpoint_info, None
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.automatic.get_default_metadata_provider",
fake_metadata_provider,
)
parser = AutomaticMetadataParser()
metadata_text = (
"A cyberpunk portrait with neon highlights.\n"
"Negative prompt: low quality\n"
"Steps: 20, Sampler: Euler a, CFG scale: 7, Seed: 123456, Size: 832x1216, "
"Model hash: c3688ee04c, Model: models/waiNSFWIllustrious_v110.safetensors"
)
result = await parser.parse_metadata(metadata_text)
checkpoint = result.get("checkpoint")
assert checkpoint is not None
assert checkpoint["hash"] == "c3688ee04c"
assert checkpoint["name"] == "Flux Illustrious"
assert checkpoint["version"] == "v1"
assert checkpoint["file_name"] == "FluxIllustrious_v1"
assert result["model"] == checkpoint
assert result["base_model"] == "flux"
assert result["loras"] == []
@pytest.mark.asyncio
async def test_parse_metadata_keeps_empty_placeholder_hash_lora_unresolved(monkeypatch):
"""A LoRA hash equal to the SHA256("") placeholder must never be resolved
against CivitAI or the local hash index, but the LoRA item itself must be
kept: matched by filename locally when present, otherwise kept as an
unresolved entry (no hash) instead of being dropped."""
queried_hashes = []
async def fake_metadata_provider():
class Provider:
async def get_model_by_hash(self, model_hash):
queried_hashes.append(model_hash)
return None, "Model not found"
async def get_model_version_info(self, version_id):
raise AssertionError("get_model_version_info should not be called")
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.automatic.get_default_metadata_provider",
fake_metadata_provider,
)
parser = AutomaticMetadataParser()
metadata_text = (
"photo of a DeLorean DMC12, <lora:dmc12bttf:1.2>, at night\n"
"Steps: 20, Sampler: Euler, CFG scale: 1, Seed: 2242760352, Size: 1280x720, "
"Model: flux1-dev, Model hash: 3f97fdc57a, "
'Lora hashes: "dmc12bttf: e3b0c44298fc"'
)
# Local file with the same name: the item is matched by filename.
scanner_with_local = LocalRecipeScanner({"dmc12bttf": local_lora("dmc12bttf")})
result = await parser.parse_metadata(metadata_text, recipe_scanner=scanner_with_local)
assert "e3b0c44298fc" not in queried_hashes
assert "e3b0c44298" not in queried_hashes
assert scanner_with_local.hash_queries == []
assert scanner_with_local.queries == ["dmc12bttf"]
assert len(result["loras"]) == 1
assert result["loras"][0]["file_name"] == "dmc12bttf"
assert result["loras"][0]["weight"] == 1.2
assert result["loras"][0]["existsLocally"] is True
assert result["loras"][0]["isDeleted"] is False
# No local file: the item is kept as unresolved (empty hash, flagged
# hashInvalid so the UI renders the unresolvable-hash badge).
scanner_without_local = LocalRecipeScanner({})
result = await parser.parse_metadata(metadata_text, recipe_scanner=scanner_without_local)
assert len(result["loras"]) == 1
lora = result["loras"][0]
assert lora["file_name"] == "dmc12bttf"
assert lora["weight"] == 1.2
assert lora["hash"] == ""
assert lora["hashInvalid"] is True
assert lora["existsLocally"] is False
assert lora["isDeleted"] is False
@pytest.mark.asyncio
async def test_parse_metadata_resolves_civitai_resources_from_local_library(monkeypatch):
"""Resources whose version id is in the library must not hit CivitAI."""
async def failing_metadata_provider():
class Provider:
async def get_model_version_info(self, version_id):
raise AssertionError(
f"Unexpected CivitAI lookup for local model {version_id}"
)
async def get_model_by_hash(self, model_hash):
raise AssertionError(
f"Unexpected CivitAI hash lookup for local model {model_hash}"
)
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.automatic.get_default_metadata_provider",
failing_metadata_provider,
)
checkpoint = {
"file_path": "/models/checkpoints/z_image_turbo.safetensors",
"file_name": "z_image_turbo",
"model_name": "Z Image",
"sha256": "b" * 64,
"size": 2048,
"base_model": "sdxl",
"preview_url": "/models/checkpoints/z_image_turbo.preview.png",
"civitai": {"id": 2442439, "modelId": 123456, "name": "Turbo"},
}
lora = {
"file_path": "/models/loras/styles/cfg_boost.safetensors",
"file_name": "cfg_boost",
"model_name": "CFG Boost",
"sha256": "c" * 64,
"size": 1024,
"base_model": "illustrious",
"preview_url": "/models/loras/styles/cfg_boost.preview.png",
"civitai": {"id": 947620, "modelId": 98765, "name": "v1"},
}
scanner = LocalRecipeScanner(
{},
models_by_version={947620: lora},
checkpoints_by_version={2442439: checkpoint},
)
metadata_text = (
"a vintage car at night\n"
"Negative prompt: fog\n"
"Steps: 23, Sampler: Euler, CFG scale: 3.5, Seed: 1760020955, "
"Size: 832x1216, "
'Civitai resources: ['
'{"type":"checkpoint","modelVersionId":2442439,"modelName":"Z Image","modelVersionName":"Turbo"},'
'{"type":"lora","modelVersionId":947620,"modelName":"cfg_scale_boost","weight":0.7}'
"]"
)
result = await AutomaticMetadataParser().parse_metadata(
metadata_text, recipe_scanner=scanner
)
assert scanner.checkpoint_version_queries == [2442439]
assert scanner.version_queries == [947620]
parsed_checkpoint = result["checkpoint"]
assert parsed_checkpoint["existsLocally"] is True
assert parsed_checkpoint["name"] == "Z Image"
assert parsed_checkpoint["file_name"] == "z_image_turbo"
assert parsed_checkpoint["hash"] == "b" * 64
assert parsed_checkpoint["localPath"] == "/models/checkpoints/z_image_turbo.safetensors"
assert len(result["loras"]) == 1
parsed_lora = result["loras"][0]
assert parsed_lora["existsLocally"] is True
assert parsed_lora["name"] == "CFG Boost"
assert parsed_lora["file_name"] == "cfg_boost"
assert parsed_lora["baseModel"] == "illustrious"
assert parsed_lora["weight"] == 0.7
assert result["base_model"] == "sdxl"