Files
ComfyUI-Lora-Manager/tests/services/test_civitai_image_parser.py
Will Miao 34791c2ad7 fix(recipe): use resources type field to identify checkpoint instead of modelVersionIds[0]
When importing a CivitAI image as a recipe, modelVersionIds[0] was blindly used as the checkpoint version ID. This array mixes checkpoints and LoRAs without ordering guarantees, causing LoRAs to be saved as the recipe checkpoint.

Fix by:
1. Removing the modelVersionIds[0] fallback in _download_remote_media
2. Parsing resources entries with type:"model" as the checkpoint
3. Adding model type validation in populate_checkpoint_from_civitai

Also add 2 tests for the new behavior and fix 3 tests whose mocks lacked the required model.type field.
2026-05-28 15:46:38 +08:00

411 lines
13 KiB
Python

import pytest
from py.recipes.parsers.civitai_image import CivitaiApiMetadataParser
@pytest.mark.asyncio
async def test_parse_metadata_creates_loras_from_hashes(monkeypatch):
async def fake_metadata_provider():
return None
monkeypatch.setattr(
"py.recipes.parsers.civitai_image.get_default_metadata_provider",
fake_metadata_provider,
)
parser = CivitaiApiMetadataParser()
metadata = {
"Size": "1536x2688",
"seed": 3766932689,
"Model": "indexed_v1",
"steps": 30,
"hashes": {
"model": "692186a14a",
"LORA:Jedst1": "fb4063c470",
"LORA:HassaKu_style": "3ce00b926b",
"LORA:DetailedEyes_V3": "2c1c3f889f",
"LORA:jiaocha_illustriousXL": "35d3e6f8b0",
"LORA:绪儿 厚涂构图光影质感增强V3": "d9b5900a59",
},
"prompt": "test",
"Version": "ComfyUI",
"sampler": "er_sde_ays_30",
"cfgScale": 5,
"clipSkip": 2,
"resources": [
{
"hash": "692186a14a",
"name": "indexed_v1",
"type": "model",
}
],
"Model hash": "692186a14a",
"negativePrompt": "bad",
"username": "LumaRift",
"baseModel": "Illustrious",
}
result = await parser.parse_metadata(metadata)
assert result["base_model"] == "Illustrious"
assert len(result["loras"]) == 5
assert all(lora["weight"] == 1.0 for lora in result["loras"])
assert {lora["name"] for lora in result["loras"]} == {
"Jedst1",
"HassaKu_style",
"DetailedEyes_V3",
"jiaocha_illustriousXL",
"绪儿 厚涂构图光影质感增强V3",
}
@pytest.mark.asyncio
async def test_parse_metadata_handles_nested_meta_and_lowercase_hashes(monkeypatch):
async def fake_metadata_provider():
return None
monkeypatch.setattr(
"py.recipes.parsers.civitai_image.get_default_metadata_provider",
fake_metadata_provider,
)
parser = CivitaiApiMetadataParser()
metadata = {
"id": 106706587,
"meta": {
"prompt": "An enigmatic silhouette",
"hashes": {
"model": "ee75fd24a4",
"lora:mj": "de49e1e98c",
"LORA:Another_Earth_2": "dc11b64a8b",
},
"resources": [
{
"hash": "ee75fd24a4",
"name": "stoiqoNewrealityFLUXSD35_f1DAlphaTwo",
"type": "model",
}
],
},
}
assert parser.is_metadata_matching(metadata)
result = await parser.parse_metadata(metadata)
assert result["gen_params"]["prompt"] == "An enigmatic silhouette"
assert {l["name"] for l in result["loras"]} == {"mj", "Another_Earth_2"}
assert {l["hash"] for l in result["loras"]} == {"de49e1e98c", "dc11b64a8b"}
@pytest.mark.asyncio
async def test_parse_metadata_populates_checkpoint_and_rewrites_thumbnails(monkeypatch):
checkpoint_info = {
"id": 222,
"modelId": 111,
"model": {"name": "Checkpoint Example", "type": "checkpoint"},
"name": "Checkpoint Version",
"images": [{"url": "https://image.civitai.com/checkpoints/original=true"}],
"baseModel": "Illustrious",
"downloadUrl": "https://civitai.com/checkpoint/download",
"files": [
{
"type": "Model",
"primary": True,
"sizeKB": 1024,
"name": "Checkpoint Example.safetensors",
"hashes": {"SHA256": "FFAA0011"},
}
],
}
lora_info = {
"id": 444,
"modelId": 333,
"model": {"name": "Example Lora Model", "type": "lora"},
"name": "Example Lora Version",
"images": [{"url": "https://image.civitai.com/loras/original=true"}],
"baseModel": "Illustrious",
"downloadUrl": "https://civitai.com/lora/download",
"files": [
{
"type": "Model",
"primary": True,
"sizeKB": 512,
"hashes": {"SHA256": "abc123"},
}
],
}
async def fake_metadata_provider():
class Provider:
async def get_model_version_info(self, version_id):
if version_id == "222":
return checkpoint_info, None
if version_id == "444":
return lora_info, None
return None, "Model not found"
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.civitai_image.get_default_metadata_provider",
fake_metadata_provider,
)
parser = CivitaiApiMetadataParser()
metadata = {
"prompt": "test prompt",
"negativePrompt": "test negative prompt",
"civitaiResources": [
{
"type": "checkpoint",
"modelId": 111,
"modelVersionId": 222,
"modelName": "Checkpoint Example",
"modelVersionName": "Checkpoint Version",
},
{
"type": "lora",
"modelId": 333,
"modelVersionId": 444,
"modelName": "Example Lora",
"modelVersionName": "Lora Version",
"weight": 0.7,
},
],
}
result = await parser.parse_metadata(metadata)
assert result["model"] is not None
assert result["model"]["name"] == "Checkpoint Example"
assert result["model"]["type"] == "checkpoint"
assert (
result["model"]["thumbnailUrl"]
== "https://image.civitai.com/checkpoints/width=450,optimized=true"
)
assert result["model"]["modelId"] == 111
assert result["model"]["size"] == 1024 * 1024
assert result["model"]["hash"] == "ffaa0011"
assert result["model"]["file_name"] == "Checkpoint Example"
assert result["loras"]
assert result["loras"][0]["name"] == "Example Lora Model"
assert (
result["loras"][0]["thumbnailUrl"]
== "https://image.civitai.com/loras/width=450,optimized=true"
)
assert result["loras"][0]["hash"] == "abc123"
@pytest.mark.asyncio
async def test_parse_metadata_handles_modelVersionIds(monkeypatch):
"""Test that modelVersionIds from Civitai image API are properly processed."""
lora_info_1 = {
"id": 2398829,
"modelId": 123456,
"model": {"name": "Dance LoRA 1", "type": "lora"},
"name": "Version 1.0",
"images": [{"url": "https://image.civitai.com/lora1/original=true"}],
"baseModel": "SDXL",
"downloadUrl": "https://civitai.com/lora1/download",
"files": [
{
"type": "Model",
"primary": True,
"sizeKB": 10240,
"name": "dance_lora_1.safetensors",
"hashes": {"SHA256": "aabbccdd0011"},
}
],
}
lora_info_2 = {
"id": 2398838,
"modelId": 123457,
"model": {"name": "Style LoRA 2", "type": "lora"},
"name": "Version 2.0",
"images": [{"url": "https://image.civitai.com/lora2/original=true"}],
"baseModel": "SDXL",
"downloadUrl": "https://civitai.com/lora2/download",
"files": [
{
"type": "Model",
"primary": True,
"sizeKB": 20480,
"name": "style_lora_2.safetensors",
"hashes": {"SHA256": "aabbccdd0022"},
}
],
}
async def fake_metadata_provider():
class Provider:
async def get_model_version_info(self, version_id):
if version_id == "2398829":
return lora_info_1, None
if version_id == "2398838":
return lora_info_2, None
return None, "Model not found"
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.civitai_image.get_default_metadata_provider",
fake_metadata_provider,
)
parser = CivitaiApiMetadataParser()
# This simulates the metadata from Civitai image API where modelVersionIds
# is at the root level and meta only contains basic prompt info
metadata = {
"id": 109882763,
"meta": {
"id": 109882763,
"meta": {"prompt": "A woman does the hip bump dance."},
},
"modelVersionIds": [2398829, 2398838],
}
assert parser.is_metadata_matching(metadata)
result = await parser.parse_metadata(metadata)
# Verify both LoRAs were created from modelVersionIds
assert len(result["loras"]) == 2
# Check first LoRA
lora1 = result["loras"][0]
assert lora1["id"] == 2398829
assert lora1["name"] == "Dance LoRA 1"
assert lora1["type"] == "lora"
assert lora1["hash"] == "aabbccdd0011"
assert lora1["baseModel"] == "SDXL"
assert (
lora1["thumbnailUrl"]
== "https://image.civitai.com/lora1/width=450,optimized=true"
)
# Check second LoRA
lora2 = result["loras"][1]
assert lora2["id"] == 2398838
assert lora2["name"] == "Style LoRA 2"
assert lora2["type"] == "lora"
assert lora2["hash"] == "aabbccdd0022"
assert lora2["baseModel"] == "SDXL"
@pytest.mark.asyncio
async def test_parse_metadata_extracts_checkpoint_from_resources_model_type(monkeypatch):
"""resources entries with type:"model" should be captured as the checkpoint,
not skipped (which was the old buggy behavior), and not mixed into loras."""
captured_hashes = []
async def fake_metadata_provider():
class Provider:
async def get_model_by_hash(self, model_hash):
captured_hashes.append(model_hash)
if model_hash == "a1b2c3d4e5":
return ({
"id": 999,
"modelId": 888,
"name": "v1.0",
"model": {"name": "Real Checkpoint", "type": "Checkpoint"},
"baseModel": "SDXL 1.0",
"images": [{"url": "https://image.civitai.com/cp/original=true"}],
"files": [{"type": "Model", "primary": True, "sizeKB": 1024, "name": "cp.safetensors"}]
}, None)
return None, "Model not found"
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.civitai_image.get_default_metadata_provider",
fake_metadata_provider,
)
parser = CivitaiApiMetadataParser()
metadata = {
"prompt": "test",
"resources": [
{"hash": "a1b2c3d4e5", "name": "Real Checkpoint", "type": "model"},
{"hash": "f6g7h8i9j0", "name": "Some LoRA", "type": "lora", "weight": 0.8},
],
"Model hash": "a1b2c3d4e5",
}
result = await parser.parse_metadata(metadata)
# The type:"model" resource should be in result["model"], not in result["loras"]
assert result["model"] is not None, "checkpoint model should be extracted"
assert result["model"]["name"] == "Real Checkpoint"
assert result["model"]["hash"] == "a1b2c3d4e5"
assert result["model"]["type"] == "model"
# The LoRA resource should be in result["loras"]
assert len(result["loras"]) == 1
assert result["loras"][0]["name"] == "Some LoRA"
# The checkpoint hash should have triggered a lookup
assert "a1b2c3d4e5" in captured_hashes
@pytest.mark.asyncio
async def test_parse_metadata_resources_model_type_does_not_duplicate_checkpoint_in_loras(monkeypatch):
"""When a resources entry has type:"model", it should NOT also appear in loras.
Regression test for the bug where the checkpoint model appeared in both places."""
async def fake_metadata_provider():
class Provider:
async def get_model_by_hash(self, model_hash):
if model_hash == "cp123hash":
return ({
"id": 100,
"modelId": 200,
"name": "v2",
"model": {"name": "My Checkpoint", "type": "Checkpoint"},
"baseModel": "SDXL",
"files": [{"type": "Model", "primary": True, "sizeKB": 1024, "name": "cp.safetensors"}]
}, None)
if model_hash == "lora1hash":
return ({
"id": 300,
"modelId": 400,
"name": "v1",
"model": {"name": "Style LoRA", "type": "LORA"},
"baseModel": "SDXL",
"files": [{"type": "Model", "primary": True, "sizeKB": 512, "name": "style.safetensors"}]
}, None)
return None, "Model not found"
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.civitai_image.get_default_metadata_provider",
fake_metadata_provider,
)
parser = CivitaiApiMetadataParser()
metadata = {
"resources": [
{"hash": "cp123hash", "name": "My Checkpoint", "type": "model"},
{"hash": "lora1hash", "name": "Style LoRA", "type": "lora", "weight": 0.5},
],
}
result = await parser.parse_metadata(metadata)
# Checkpoint must NOT appear in loras
lora_names = {l["name"] for l in result["loras"]}
assert "My Checkpoint" not in lora_names
assert "Style LoRA" in lora_names
# Checkpoint must be in result["model"]
assert result["model"] is not None
assert result["model"]["name"] == "My Checkpoint"