mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -03:00
d572292142
A ModelScope or Hugging Face download landed as a bare filename, hash and
source link; the model card stayed empty until the user ran "Enrich
Metadata with AI" by hand. But everything that makes a CivitAI download
useful — the display name, the description, the tags, the trigger words,
the example images, the preview — is already published by those sites'
public APIs, so asking for it at download time is deterministic work, not
model work.
Add `py/services/model_sources/hydration.py`, called by
`_save_source_metadata()` once the sidecar exists and the file is in the
scanner cache. It fetches the model card plus the site's card extras and
hands them to the same `PostProcessor` the AI skill uses, with an empty
`llm_output`, so the two paths cannot drift apart. What lands:
* `model_name` from the site's own display name (ModelScope's `Name`), so
the card stops showing the local filename — written only while the value
still equals the file stem, since once a user renames a model that
choice is theirs to keep
* `civitai.name` from the matched version's label (`showName`), which the
card renders as the version chip
* `civitai.description` / `modelDescription` from the author summary plus
the README as HTML
* `civitai.images` / `preview_url` from the per-file example images
* `civitai.trainedWords` from the per-file trigger words
* `base_model`, `tags` and `usage_tips` as before
Provenance stays honest: the pass records
`metadata_source = "source:<platform>"` rather than the skill's
`agent:enrich_hf_metadata`, and — because no provider ran — it no longer
stamps `llm_enriched_at`; that stamp is now conditional on the LLM
actually answering, which is what the field means. The five hand-rolled
`civitai` dict merges in the post-processor collapse into one
`_merge_civitai()` helper.
Two guards keep it safe. Only a model whose stored
`source_platform`/`source_url` match the repository being downloaded is
updated, so a local file that merely shares a name never receives another
model's card; and a file already on disk is topped up too, which
back-fills models downloaded before this existed. READMEs and detail
payloads describe the repository rather than the file, so a short-lived
process-wide `ModelSourceCache` (300 s, 32 entries) keeps a batch over one
repository to two HTTP requests. Every failure is logged and swallowed:
hydration can never fail a download.
Fix the hash policy while here. `_save_source_metadata()` went straight to
`MetadataManager.create_default_metadata()`, bypassing the per-type
factory on the owning scanner, so a checkpoint paid a full SHA256 inside
the download request — `CheckpointScanner`/`OtherScanner` deliberately
record `hash_status="pending"` with an empty `sha256` for their multi-GB
files. Metadata is now created through `scanner._create_default_metadata()`.
Hydration copes with the empty hash: `_matching_versions()` falls back to
the repository basename, which is exactly what the download just wrote.
Report both post-transfer stages, which advance no byte counter and so
read as a stall: the bar sat at 100% showing `0 B/s` for the seconds spent
hashing and fetching. `_report_phase()` broadcasts
`{"status": "metadata", "stage": "indexing" | "source", "platform": ...}`,
and `LoadingManager` names the stage in the status line (keeping the batch
position), retitles the item line, replaces the dead speed figure and runs
a sheen over the bar. `stage`/`platform` are machine-readable; the wording
is localised in the frontend.
Finally, `modelscope.ai` is its own catalogue rather than an alias of
`modelscope.cn` — `referall13/EM1` exists only on `.ai` and
`jj3550945163/Krea-2-LORA` only on `.cn` — so its URLs were rejected with
"Invalid model URL format". Register it as `ModelScopeIntlSource`
(`platform="modelscope-ai"`, `msai:` group prefix, its own default
download directory) and derive every URL either deployment builds from a
per-class `base_url`. `modelscope.com` stays an alias of `.cn`, which is
what it redirects to. The frontend source table, the link dialog hints and
the docs mirror the split.
Verified against the live APIs: both reported `.ai` repositories list
their files, read their READMEs and yield name / version / base model /
trigger words / example images. Backend 3092 passed; frontend 1259 JS +
91 Vue passed. The nine locales carry the new progress copy in the next
commit.
1151 lines
39 KiB
Python
1151 lines
39 KiB
Python
"""Tests for the external model-source handlers.
|
|
|
|
Covers linking (``set_hf_url``), file listing and downloads across the
|
|
registered platforms (Hugging Face / ModelScope).
|
|
|
|
Regression coverage for issue #1094: linking a model to HuggingFace must not
|
|
clear its CivitAI provenance or metadata, so both "View on CivitAI" and
|
|
"View on Hugging Face" can coexist.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from py.routes.handlers import model_source_handlers
|
|
from py.routes.handlers.model_source_handlers import ModelSourceHandler
|
|
from py.services.model_sources import ModelSourceError, SourceRef
|
|
from py.services.service_registry import ServiceRegistry
|
|
from py.utils.models import LoraMetadata
|
|
from py.utils.metadata_manager import MetadataManager
|
|
|
|
|
|
def _json_payload(response) -> dict[str, Any]:
|
|
assert response.text is not None
|
|
return json.loads(response.text)
|
|
|
|
|
|
class FakeRequest:
|
|
def __init__(self, *, json_data=None, query=None):
|
|
self._json_data = json_data or {}
|
|
self.query = query or {}
|
|
|
|
async def json(self):
|
|
return self._json_data
|
|
|
|
|
|
def _sidecar_path(model_path) -> str:
|
|
return f"{os.path.splitext(str(model_path))[0]}.metadata.json"
|
|
|
|
|
|
@pytest.fixture
|
|
def source_env(tmp_path, monkeypatch):
|
|
"""Point HF linking at *tmp_path* and stub the scanner cache write."""
|
|
monkeypatch.setattr(model_source_handlers, "_find_matching_root", lambda _dir: str(tmp_path))
|
|
cache_write = AsyncMock()
|
|
monkeypatch.setattr(model_source_handlers, "_add_to_scanner_cache", cache_write)
|
|
return {"root": tmp_path, "cache_write": cache_write}
|
|
|
|
|
|
async def _write_model(model_path, payload: dict[str, Any]) -> None:
|
|
model_path.write_bytes(b"x" * 32)
|
|
await MetadataManager.save_metadata(str(model_path), payload)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_hf_url_keeps_civitai_metadata_and_provenance(tmp_path, source_env):
|
|
model_path = tmp_path / "civitai_model.safetensors"
|
|
await _write_model(
|
|
model_path,
|
|
{
|
|
"file_name": "civitai_model",
|
|
"model_name": "CivitAI Model",
|
|
"file_path": str(model_path),
|
|
"size": 32,
|
|
"modified": 1.0,
|
|
"sha256": "a" * 64,
|
|
"base_model": "SDXL 1.0",
|
|
"preview_url": "",
|
|
"from_civitai": True,
|
|
"civitai": {"id": 111, "modelId": 222, "name": "v1", "trainedWords": []},
|
|
},
|
|
)
|
|
|
|
response = await ModelSourceHandler().set_hf_url(
|
|
FakeRequest(
|
|
json_data={
|
|
"file_path": str(model_path),
|
|
"hf_url": "https://huggingface.co/user/repo",
|
|
}
|
|
)
|
|
)
|
|
|
|
assert response.status == 200
|
|
assert _json_payload(response)["success"] is True
|
|
|
|
saved = json.loads(open(_sidecar_path(model_path), encoding="utf-8").read())
|
|
assert saved["hf_url"] == "https://huggingface.co/user/repo"
|
|
# Linking HF must not erase the model's CivitAI provenance or data.
|
|
assert saved["from_civitai"] is True
|
|
assert saved["civitai"]["modelId"] == 222
|
|
assert saved["civitai"]["id"] == 111
|
|
|
|
source_env["cache_write"].assert_awaited_once()
|
|
cached_metadata = source_env["cache_write"].await_args.args[1]
|
|
assert cached_metadata["hf_url"] == "https://huggingface.co/user/repo"
|
|
assert cached_metadata["from_civitai"] is True
|
|
assert cached_metadata["civitai"]["modelId"] == 222
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_hf_url_does_not_force_from_civitai_false(tmp_path, source_env):
|
|
"""A model without CivitAI data keeps its existing provenance flag."""
|
|
model_path = tmp_path / "hf_only.safetensors"
|
|
await _write_model(
|
|
model_path,
|
|
{
|
|
"file_name": "hf_only",
|
|
"model_name": "HF Only",
|
|
"file_path": str(model_path),
|
|
"size": 32,
|
|
"modified": 1.0,
|
|
"sha256": "b" * 64,
|
|
"base_model": "Unknown",
|
|
"preview_url": "",
|
|
"from_civitai": True,
|
|
},
|
|
)
|
|
|
|
response = await ModelSourceHandler().set_hf_url(
|
|
FakeRequest(
|
|
json_data={
|
|
"file_path": str(model_path),
|
|
"hf_url": "https://huggingface.co/user/repo",
|
|
}
|
|
)
|
|
)
|
|
|
|
assert response.status == 200
|
|
saved = json.loads(open(_sidecar_path(model_path), encoding="utf-8").read())
|
|
assert saved["hf_url"] == "https://huggingface.co/user/repo"
|
|
assert saved["from_civitai"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_hf_url_rejects_non_repo_url(tmp_path, source_env):
|
|
model_path = tmp_path / "model.safetensors"
|
|
await _write_model(
|
|
model_path,
|
|
{
|
|
"file_name": "model",
|
|
"model_name": "model",
|
|
"file_path": str(model_path),
|
|
"size": 32,
|
|
"modified": 1.0,
|
|
"sha256": "c" * 64,
|
|
"base_model": "Unknown",
|
|
"preview_url": "",
|
|
},
|
|
)
|
|
|
|
response = await ModelSourceHandler().set_hf_url(
|
|
FakeRequest(json_data={"file_path": str(model_path), "hf_url": "https://example.com/x"})
|
|
)
|
|
|
|
assert response.status == 400
|
|
payload = _json_payload(response)
|
|
assert payload["success"] is False
|
|
source_env["cache_write"].assert_not_awaited()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Multi-source linking (ModelScope / TensorArt)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def _write_plain_model(model_path, sha: str = "d" * 64) -> None:
|
|
await _write_model(
|
|
model_path,
|
|
{
|
|
"file_name": "model",
|
|
"model_name": "model",
|
|
"file_path": str(model_path),
|
|
"size": 32,
|
|
"modified": 1.0,
|
|
"sha256": sha,
|
|
"base_model": "Unknown",
|
|
"preview_url": "",
|
|
},
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_hf_url_accepts_modelscope_and_stores_source_fields(tmp_path, source_env):
|
|
model_path = tmp_path / "ms_model.safetensors"
|
|
await _write_plain_model(model_path)
|
|
|
|
response = await ModelSourceHandler().set_hf_url(
|
|
FakeRequest(
|
|
json_data={
|
|
"file_path": str(model_path),
|
|
"source_url": "https://modelscope.cn/models/jj3550945163/Krea-2-LORA",
|
|
}
|
|
)
|
|
)
|
|
|
|
assert response.status == 200
|
|
payload = _json_payload(response)
|
|
assert payload["source_platform"] == "modelscope"
|
|
assert payload["source_url"] == "https://modelscope.cn/models/jj3550945163/Krea-2-LORA"
|
|
|
|
saved = json.loads(open(_sidecar_path(model_path), encoding="utf-8").read())
|
|
assert saved["source_platform"] == "modelscope"
|
|
assert saved["source_url"] == "https://modelscope.cn/models/jj3550945163/Krea-2-LORA"
|
|
# No stale Hugging Face alias for a ModelScope model.
|
|
assert saved.get("hf_url", "") == ""
|
|
|
|
cached_metadata = source_env["cache_write"].await_args.args[1]
|
|
assert cached_metadata["source_platform"] == "modelscope"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_hf_url_accepts_tensorart_url(tmp_path, source_env):
|
|
model_path = tmp_path / "ta_model.safetensors"
|
|
await _write_plain_model(model_path, sha="e" * 64)
|
|
|
|
response = await ModelSourceHandler().set_hf_url(
|
|
FakeRequest(
|
|
json_data={
|
|
"file_path": str(model_path),
|
|
"source_url": (
|
|
"https://tensor.art/models/827823520299086029/"
|
|
"Vivid-Impressions-Storybook-Sstyle-V1.0"
|
|
),
|
|
}
|
|
)
|
|
)
|
|
|
|
assert response.status == 200
|
|
payload = _json_payload(response)
|
|
assert payload["source_platform"] == "tensorart"
|
|
# The canonical page URL is stored, without the slug.
|
|
assert payload["source_url"] == "https://tensor.art/models/827823520299086029"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_hf_url_canonicalises_modelscope_subpage(tmp_path, source_env):
|
|
model_path = tmp_path / "ms_sub.safetensors"
|
|
await _write_plain_model(model_path, sha="f" * 64)
|
|
|
|
response = await ModelSourceHandler().set_hf_url(
|
|
FakeRequest(
|
|
json_data={
|
|
"file_path": str(model_path),
|
|
"source_url": "https://modelscope.cn/models/user/repo/summary",
|
|
}
|
|
)
|
|
)
|
|
|
|
assert response.status == 200
|
|
assert _json_payload(response)["source_url"] == "https://modelscope.cn/models/user/repo"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_hf_url_is_idempotent_for_modelscope(tmp_path, source_env):
|
|
model_path = tmp_path / "ms_twice.safetensors"
|
|
await _write_plain_model(model_path, sha="1" * 64)
|
|
|
|
request = FakeRequest(
|
|
json_data={
|
|
"file_path": str(model_path),
|
|
"source_url": "https://modelscope.cn/models/user/repo",
|
|
}
|
|
)
|
|
await ModelSourceHandler().set_hf_url(request)
|
|
await ModelSourceHandler().set_hf_url(request)
|
|
|
|
# The second call short-circuits without rewriting the cache entry.
|
|
assert source_env["cache_write"].await_count == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_hf_url_switching_source_clears_hf_alias(tmp_path, source_env):
|
|
model_path = tmp_path / "switch.safetensors"
|
|
await _write_plain_model(model_path, sha="2" * 64)
|
|
|
|
await ModelSourceHandler().set_hf_url(
|
|
FakeRequest(
|
|
json_data={
|
|
"file_path": str(model_path),
|
|
"source_url": "https://huggingface.co/user/repo",
|
|
}
|
|
)
|
|
)
|
|
await ModelSourceHandler().set_hf_url(
|
|
FakeRequest(
|
|
json_data={
|
|
"file_path": str(model_path),
|
|
"source_url": "https://modelscope.cn/models/user/repo",
|
|
}
|
|
)
|
|
)
|
|
|
|
saved = json.loads(open(_sidecar_path(model_path), encoding="utf-8").read())
|
|
assert saved["source_platform"] == "modelscope"
|
|
assert saved.get("hf_url", "") == ""
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_model_sources_lists_capabilities():
|
|
response = await ModelSourceHandler().get_model_sources(FakeRequest())
|
|
sources = _json_payload(response)
|
|
|
|
by_platform = {s["platform"]: s for s in sources}
|
|
assert set(by_platform) == {
|
|
"huggingface",
|
|
"modelscope",
|
|
"modelscope-ai",
|
|
"tensorart",
|
|
}
|
|
assert by_platform["huggingface"]["supports_enrichment"] is True
|
|
assert by_platform["modelscope"]["supports_enrichment"] is True
|
|
# TensorArt is link-only: no accessible model card for the backend.
|
|
assert by_platform["tensorart"]["supports_enrichment"] is False
|
|
assert by_platform["modelscope"]["supports_download"] is True
|
|
assert by_platform["modelscope"]["default_revision"] == "master"
|
|
assert by_platform["tensorart"]["supports_download"] is False
|
|
# The international deployment is advertised with its own example URL, so
|
|
# the Link dialog names the host a user actually has open.
|
|
assert by_platform["modelscope-ai"]["supports_download"] is True
|
|
assert by_platform["modelscope-ai"]["example_url"].startswith(
|
|
"https://www.modelscope.ai/"
|
|
)
|
|
assert all(s["example_url"] for s in sources)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# File listing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_model_source_files_returns_provider_result(monkeypatch):
|
|
captured: dict = {}
|
|
|
|
async def fake_list_files(self, source_id, revision=""):
|
|
captured["source_id"] = source_id
|
|
captured["revision"] = revision
|
|
return [{"filename": "a.safetensors", "size": 10}]
|
|
|
|
monkeypatch.setattr(
|
|
"py.services.model_sources.modelscope.ModelScopeSource.list_files",
|
|
fake_list_files,
|
|
)
|
|
|
|
response = await ModelSourceHandler().list_model_source_files(
|
|
FakeRequest(
|
|
query={
|
|
"platform": "modelscope",
|
|
"repo": "jj3550945163/Krea-2-LORA",
|
|
"revision": "v1",
|
|
}
|
|
)
|
|
)
|
|
|
|
assert response.status == 200
|
|
assert _json_payload(response) == [{"filename": "a.safetensors", "size": 10}]
|
|
assert captured == {"source_id": "jj3550945163/Krea-2-LORA", "revision": "v1"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_model_source_files_rejects_link_only_platform():
|
|
response = await ModelSourceHandler().list_model_source_files(
|
|
FakeRequest(query={"platform": "tensorart", "repo": "u/r"})
|
|
)
|
|
|
|
assert response.status == 400
|
|
assert "does not support downloads" in _json_payload(response)["error"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_model_source_files_rejects_unsafe_repo():
|
|
for repo in ("noslash", "../etc/passwd", "u/.."):
|
|
response = await ModelSourceHandler().list_model_source_files(
|
|
FakeRequest(query={"platform": "modelscope", "repo": repo})
|
|
)
|
|
assert response.status == 400, repo
|
|
assert "repo" in _json_payload(response)["error"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_model_source_files_maps_missing_repo_to_404(monkeypatch):
|
|
async def fake_list_files(self, source_id, revision=""):
|
|
raise ModelSourceError(f"Repository '{source_id}' not found", status=404)
|
|
|
|
monkeypatch.setattr(
|
|
"py.services.model_sources.modelscope.ModelScopeSource.list_files",
|
|
fake_list_files,
|
|
)
|
|
|
|
response = await ModelSourceHandler().list_model_source_files(
|
|
FakeRequest(query={"platform": "modelscope", "repo": "u/r"})
|
|
)
|
|
|
|
assert response.status == 404
|
|
assert "not found" in _json_payload(response)["error"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_model_source_files_maps_transport_failure_to_502(monkeypatch):
|
|
async def fake_list_files(self, source_id, revision=""):
|
|
raise ModelSourceError("upstream exploded", status=502)
|
|
|
|
monkeypatch.setattr(
|
|
"py.services.model_sources.modelscope.ModelScopeSource.list_files",
|
|
fake_list_files,
|
|
)
|
|
|
|
response = await ModelSourceHandler().list_model_source_files(
|
|
FakeRequest(query={"platform": "modelscope", "repo": "u/r"})
|
|
)
|
|
|
|
assert response.status == 502
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Downloads
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _stub_download_backend(monkeypatch) -> dict:
|
|
"""Replace the downloader/settings plumbing with a recording stub."""
|
|
captured: dict = {}
|
|
|
|
async def fake_download_file(**kwargs):
|
|
captured.update(kwargs)
|
|
return True, kwargs["save_path"]
|
|
|
|
class _Downloader:
|
|
download_file = staticmethod(fake_download_file)
|
|
|
|
async def fake_get_downloader():
|
|
return _Downloader()
|
|
|
|
class _Settings:
|
|
def get(self, key, default=None):
|
|
return default
|
|
|
|
monkeypatch.setattr(model_source_handlers, "get_downloader", fake_get_downloader)
|
|
monkeypatch.setattr(
|
|
model_source_handlers, "get_settings_manager", lambda: _Settings()
|
|
)
|
|
return captured
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_model_source_modelscope_uses_resolve_url(tmp_path, monkeypatch):
|
|
captured = _stub_download_backend(monkeypatch)
|
|
saved = AsyncMock()
|
|
monkeypatch.setattr(model_source_handlers, "_save_source_metadata", saved)
|
|
|
|
response = await ModelSourceHandler().download_model_source(
|
|
FakeRequest(
|
|
json_data={
|
|
"platform": "modelscope",
|
|
"repo": "jj3550945163/Krea-2-LORA",
|
|
"filename": "Krea-2-LORA_c1-st1000.safetensors",
|
|
"model_root": str(tmp_path),
|
|
}
|
|
)
|
|
)
|
|
|
|
assert response.status == 200
|
|
assert captured["url"] == (
|
|
"https://modelscope.cn/models/jj3550945163/Krea-2-LORA/resolve/master/"
|
|
"Krea-2-LORA_c1-st1000.safetensors"
|
|
)
|
|
assert captured["save_path"] == str(tmp_path / "Krea-2-LORA_c1-st1000.safetensors")
|
|
|
|
ref = saved.await_args.args[1]
|
|
assert ref.platform == "modelscope"
|
|
assert ref.source_id == "jj3550945163/Krea-2-LORA"
|
|
assert ref.url == "https://modelscope.cn/models/jj3550945163/Krea-2-LORA"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_model_source_modelscope_default_paths(tmp_path, monkeypatch):
|
|
captured = _stub_download_backend(monkeypatch)
|
|
saved = AsyncMock()
|
|
monkeypatch.setattr(model_source_handlers, "_save_source_metadata", saved)
|
|
|
|
response = await ModelSourceHandler().download_model_source(
|
|
FakeRequest(
|
|
json_data={
|
|
"platform": "modelscope",
|
|
"repo": "owner/name",
|
|
"filename": "nested/model.safetensors",
|
|
"model_root": str(tmp_path),
|
|
"use_default_paths": True,
|
|
}
|
|
)
|
|
)
|
|
|
|
assert response.status == 200
|
|
# The site gets its own sub-directory, mirroring `huggingface/<owner>/<repo>`.
|
|
assert captured["save_path"] == str(
|
|
tmp_path / "modelscope" / "owner" / "name" / "model.safetensors"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_model_source_modelscope_intl_uses_its_own_host(
|
|
tmp_path, monkeypatch
|
|
):
|
|
"""`.ai` is a separate catalogue, so the download must not go to `.cn`."""
|
|
captured = _stub_download_backend(monkeypatch)
|
|
saved = AsyncMock()
|
|
monkeypatch.setattr(model_source_handlers, "_save_source_metadata", saved)
|
|
|
|
response = await ModelSourceHandler().download_model_source(
|
|
FakeRequest(
|
|
json_data={
|
|
"platform": "modelscope-ai",
|
|
"repo": "referall13/EM1",
|
|
"filename": "EM1_c1-st1000.safetensors",
|
|
"model_root": str(tmp_path),
|
|
"use_default_paths": True,
|
|
}
|
|
)
|
|
)
|
|
|
|
assert response.status == 200
|
|
assert captured["url"] == (
|
|
"https://www.modelscope.ai/models/referall13/EM1/resolve/master/"
|
|
"EM1_c1-st1000.safetensors"
|
|
)
|
|
# Its own default directory, so the same owner/name on both deployments
|
|
# cannot overwrite each other.
|
|
assert captured["save_path"] == str(
|
|
tmp_path / "modelscope-ai" / "referall13" / "EM1" / "EM1_c1-st1000.safetensors"
|
|
)
|
|
|
|
ref = saved.await_args.args[1]
|
|
assert ref.platform == "modelscope-ai"
|
|
assert ref.source_id == "referall13/EM1"
|
|
assert ref.url == "https://www.modelscope.ai/models/referall13/EM1"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_model_source_defaults_to_huggingface(tmp_path, monkeypatch):
|
|
"""The legacy /api/lm/download-hf-model payload has no `platform` key."""
|
|
captured = _stub_download_backend(monkeypatch)
|
|
monkeypatch.setattr(model_source_handlers, "_save_source_metadata", AsyncMock())
|
|
|
|
response = await ModelSourceHandler().download_model_source(
|
|
FakeRequest(
|
|
json_data={
|
|
"repo": "user/repo",
|
|
"filename": "f.safetensors",
|
|
"revision": "main",
|
|
"model_root": str(tmp_path),
|
|
}
|
|
)
|
|
)
|
|
|
|
assert response.status == 200
|
|
assert captured["url"] == (
|
|
"https://huggingface.co/user/repo/resolve/main/f.safetensors"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_model_source_rejects_link_only_platform(tmp_path):
|
|
response = await ModelSourceHandler().download_model_source(
|
|
FakeRequest(
|
|
json_data={
|
|
"platform": "tensorart",
|
|
"repo": "u/r",
|
|
"filename": "f.safetensors",
|
|
"model_root": str(tmp_path),
|
|
}
|
|
)
|
|
)
|
|
|
|
assert response.status == 400
|
|
assert "does not support downloads" in _json_payload(response)["error"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_model_source_rejects_unsafe_input(tmp_path, monkeypatch):
|
|
_stub_download_backend(monkeypatch)
|
|
|
|
cases = [
|
|
({"repo": "noslash", "filename": "f.safetensors"}, "repo format"),
|
|
({"repo": "u/r", "filename": "../../etc/passwd"}, "Invalid filename"),
|
|
(
|
|
{"repo": "u/r", "filename": "f.safetensors", "relative_path": "/abs"},
|
|
"relative_path must not be absolute",
|
|
),
|
|
(
|
|
{"repo": "u/r", "filename": "f.safetensors", "relative_path": "../up"},
|
|
"Invalid relative_path",
|
|
),
|
|
]
|
|
for extra, expected in cases:
|
|
response = await ModelSourceHandler().download_model_source(
|
|
FakeRequest(
|
|
json_data={
|
|
"platform": "modelscope",
|
|
"model_root": str(tmp_path),
|
|
**extra,
|
|
}
|
|
)
|
|
)
|
|
assert response.status == 400, extra
|
|
assert expected in _json_payload(response)["error"], extra
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_model_source_skips_existing_file(tmp_path, monkeypatch):
|
|
captured = _stub_download_backend(monkeypatch)
|
|
monkeypatch.setattr(model_source_handlers, "_save_source_metadata", AsyncMock())
|
|
(tmp_path / "f.safetensors").write_bytes(b"already here")
|
|
|
|
response = await ModelSourceHandler().download_model_source(
|
|
FakeRequest(
|
|
json_data={
|
|
"platform": "modelscope",
|
|
"repo": "u/r",
|
|
"filename": "f.safetensors",
|
|
"model_root": str(tmp_path),
|
|
}
|
|
)
|
|
)
|
|
|
|
assert response.status == 200
|
|
assert "already exists" in _json_payload(response)["message"]
|
|
assert captured == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("platform", "url", "expect_hf_alias"),
|
|
[
|
|
("modelscope", "https://modelscope.cn/models/u/r", False),
|
|
("huggingface", "https://huggingface.co/u/r", True),
|
|
],
|
|
)
|
|
async def test_save_source_metadata_writes_platform_fields(
|
|
tmp_path, monkeypatch, platform, url, expect_hf_alias
|
|
):
|
|
"""A download's sidecar must record its own platform (and no stale HF alias)."""
|
|
model_path = tmp_path / "downloaded.safetensors"
|
|
model_path.write_bytes(b"x" * 32)
|
|
|
|
metadata = LoraMetadata(
|
|
file_name="downloaded",
|
|
model_name="Downloaded",
|
|
file_path=str(model_path),
|
|
size=32,
|
|
modified=1.0,
|
|
sha256="a" * 64,
|
|
base_model="SDXL 1.0",
|
|
preview_url="",
|
|
)
|
|
scanner = SimpleNamespace(
|
|
# A real scanner owns metadata creation (see the lazy-hash test below).
|
|
_create_default_metadata=AsyncMock(return_value=metadata),
|
|
add_model_to_cache=AsyncMock(),
|
|
)
|
|
monkeypatch.setattr(
|
|
ServiceRegistry, "get_lora_scanner", AsyncMock(return_value=scanner)
|
|
)
|
|
monkeypatch.setattr(
|
|
model_source_handlers, "_infer_model_type", lambda _root: (LoraMetadata, "get_lora_scanner")
|
|
)
|
|
hydrate = AsyncMock()
|
|
monkeypatch.setattr(model_source_handlers, "hydrate_from_source", hydrate)
|
|
|
|
ref = SourceRef(platform=platform, source_id="u/r", url=url)
|
|
await model_source_handlers._save_source_metadata(str(model_path), ref, str(tmp_path))
|
|
|
|
saved = json.loads(open(_sidecar_path(model_path), encoding="utf-8").read())
|
|
assert saved["source_platform"] == platform
|
|
assert saved["source_url"] == url
|
|
assert bool(saved.get("hf_url", "")) is expect_hf_alias
|
|
|
|
assert scanner._create_default_metadata.await_args.args == (str(model_path),)
|
|
|
|
cached = scanner.add_model_to_cache.await_args.args[0]
|
|
assert cached["source_platform"] == platform
|
|
assert cached["source_url"] == url
|
|
|
|
# The site's own API is consulted last, so the scanner-cache refresh it
|
|
# performs lands on the entry created above.
|
|
assert hydrate.await_args.args == (str(model_path),)
|
|
assert hydrate.await_args.kwargs["ref"] == ref
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_checkpoint_download_defers_the_hash(tmp_path, monkeypatch):
|
|
"""A multi-GB checkpoint must not be hashed inside the download request.
|
|
|
|
``CheckpointScanner`` records ``hash_status="pending"`` and lets the hash be
|
|
computed on demand; going through the generic
|
|
``MetadataManager.create_default_metadata`` would read the whole file before
|
|
the download response could return, which is exactly the pause this code
|
|
path is supposed to avoid.
|
|
"""
|
|
from py.services.checkpoint_scanner import CheckpointScanner
|
|
from py.utils.models import CheckpointMetadata
|
|
|
|
model_path = tmp_path / "big_checkpoint.safetensors"
|
|
model_path.write_bytes(b"stub")
|
|
|
|
real_scanner = CheckpointScanner()
|
|
scanner = SimpleNamespace(
|
|
_create_default_metadata=real_scanner._create_default_metadata,
|
|
add_model_to_cache=AsyncMock(),
|
|
)
|
|
monkeypatch.setattr(
|
|
ServiceRegistry, "get_checkpoint_scanner", AsyncMock(return_value=scanner)
|
|
)
|
|
monkeypatch.setattr(
|
|
model_source_handlers,
|
|
"_infer_model_type",
|
|
lambda _root: (CheckpointMetadata, "get_checkpoint_scanner"),
|
|
)
|
|
generic = AsyncMock(
|
|
# Stands in for the eager helper: if the handler reaches for it, the
|
|
# sidecar ends up hashed and the assertions below say so plainly.
|
|
return_value=LoraMetadata(
|
|
file_name="big_checkpoint",
|
|
model_name="big_checkpoint",
|
|
file_path=str(model_path),
|
|
size=4,
|
|
modified=1.0,
|
|
sha256="d" * 64,
|
|
base_model="Unknown",
|
|
preview_url="",
|
|
)
|
|
)
|
|
monkeypatch.setattr(
|
|
model_source_handlers.MetadataManager, "create_default_metadata", generic
|
|
)
|
|
monkeypatch.setattr(model_source_handlers, "hydrate_from_source", AsyncMock())
|
|
|
|
ref = SourceRef(
|
|
platform="huggingface",
|
|
source_id="u/r",
|
|
url="https://huggingface.co/u/r",
|
|
)
|
|
await model_source_handlers._save_source_metadata(str(model_path), ref, str(tmp_path))
|
|
|
|
saved = json.loads(open(_sidecar_path(model_path), encoding="utf-8").read())
|
|
assert saved["sha256"] == ""
|
|
assert saved["hash_status"] == "pending"
|
|
assert saved["from_civitai"] is False
|
|
# The download link is still recorded on top of the deferred hash.
|
|
assert saved["source_platform"] == "huggingface"
|
|
assert saved["source_url"] == "https://huggingface.co/u/r"
|
|
|
|
# The scanner cache must carry the pending state too, or the cache fill
|
|
# would compute the hash after all.
|
|
cached = scanner.add_model_to_cache.await_args.args[0]
|
|
assert cached["hash_status"] == "pending"
|
|
assert cached["sha256"] == ""
|
|
|
|
generic.assert_not_awaited()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Post-transfer phase reporting
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _stub_hydration_pipeline(tmp_path, monkeypatch):
|
|
"""Wire `_save_source_metadata`'s collaborators and record call order."""
|
|
model_path = tmp_path / "downloaded.safetensors"
|
|
model_path.write_bytes(b"x" * 32)
|
|
|
|
metadata = LoraMetadata(
|
|
file_name="downloaded",
|
|
model_name="Downloaded",
|
|
file_path=str(model_path),
|
|
size=32,
|
|
modified=1.0,
|
|
sha256="a" * 64,
|
|
base_model="SDXL 1.0",
|
|
preview_url="",
|
|
)
|
|
monkeypatch.setattr(
|
|
model_source_handlers.MetadataManager,
|
|
"create_default_metadata",
|
|
AsyncMock(return_value=metadata),
|
|
)
|
|
monkeypatch.setattr(
|
|
ServiceRegistry,
|
|
"get_lora_scanner",
|
|
AsyncMock(return_value=SimpleNamespace(add_model_to_cache=AsyncMock())),
|
|
)
|
|
monkeypatch.setattr(
|
|
model_source_handlers,
|
|
"_infer_model_type",
|
|
lambda _root: (LoraMetadata, "get_lora_scanner"),
|
|
)
|
|
|
|
events: list = []
|
|
|
|
async def fake_broadcast(download_id, data):
|
|
events.append(("broadcast", data["stage"], data, download_id))
|
|
|
|
async def fake_hydrate(*_args, **_kwargs):
|
|
events.append(("hydrate", None, None, None))
|
|
|
|
monkeypatch.setattr(
|
|
model_source_handlers.ws_manager, "broadcast_download_progress", fake_broadcast
|
|
)
|
|
monkeypatch.setattr(model_source_handlers, "hydrate_from_source", fake_hydrate)
|
|
return model_path, events
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_save_source_metadata_reports_post_transfer_stages(tmp_path, monkeypatch):
|
|
"""The byte counter stops before indexing and the site fetch, so the UI has
|
|
to be told what is still running — otherwise the bar looks stuck."""
|
|
model_path, events = _stub_hydration_pipeline(tmp_path, monkeypatch)
|
|
|
|
ref = SourceRef(
|
|
platform="modelscope", source_id="u/r", url="https://modelscope.cn/models/u/r"
|
|
)
|
|
await model_source_handlers._save_source_metadata(
|
|
str(model_path), ref, str(tmp_path), download_id="dl-1"
|
|
)
|
|
|
|
# Each stage is announced *before* its work starts, so the label is never
|
|
# describing something that already finished.
|
|
assert [event[:2] for event in events] == [
|
|
("broadcast", "indexing"),
|
|
("broadcast", "source"),
|
|
("hydrate", None),
|
|
]
|
|
for kind, stage, data, download_id in events:
|
|
if kind != "broadcast":
|
|
continue
|
|
assert download_id == "dl-1"
|
|
assert data["status"] == "metadata"
|
|
assert data["progress"] == 100
|
|
assert data["platform"] == "modelscope"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_save_source_metadata_is_silent_without_a_watcher(tmp_path, monkeypatch):
|
|
"""No `download_id` means no UI is watching; nothing should be broadcast."""
|
|
model_path, events = _stub_hydration_pipeline(tmp_path, monkeypatch)
|
|
|
|
ref = SourceRef(
|
|
platform="modelscope", source_id="u/r", url="https://modelscope.cn/models/u/r"
|
|
)
|
|
await model_source_handlers._save_source_metadata(str(model_path), ref, str(tmp_path))
|
|
|
|
assert events == [("hydrate", None, None, None)]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_report_phase_never_breaks_a_download(monkeypatch):
|
|
"""Progress reporting is cosmetic; a dead socket must not fail the file."""
|
|
monkeypatch.setattr(
|
|
model_source_handlers.ws_manager,
|
|
"broadcast_download_progress",
|
|
AsyncMock(side_effect=RuntimeError("socket gone")),
|
|
)
|
|
|
|
await model_source_handlers._report_phase("dl-1", "source", "modelscope")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_passes_its_watch_id_into_metadata_work(tmp_path, monkeypatch):
|
|
"""The stages are only visible if the handler hands its id down."""
|
|
_stub_download_backend(monkeypatch)
|
|
saved = AsyncMock()
|
|
monkeypatch.setattr(model_source_handlers, "_save_source_metadata", saved)
|
|
|
|
await ModelSourceHandler().download_model_source(
|
|
FakeRequest(
|
|
json_data={
|
|
"platform": "modelscope",
|
|
"repo": "owner/name",
|
|
"filename": "model.safetensors",
|
|
"model_root": str(tmp_path),
|
|
"download_id": "dl-42",
|
|
}
|
|
)
|
|
)
|
|
|
|
assert saved.await_args.kwargs["download_id"] == "dl-42"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skipped_download_still_reports_the_site_stage(tmp_path, monkeypatch):
|
|
"""An already-present file is hydrated too, so it needs the same signal."""
|
|
_stub_download_backend(monkeypatch)
|
|
hydrate = AsyncMock()
|
|
monkeypatch.setattr(model_source_handlers, "hydrate_from_source", hydrate)
|
|
broadcast = AsyncMock()
|
|
monkeypatch.setattr(
|
|
model_source_handlers.ws_manager, "broadcast_download_progress", broadcast
|
|
)
|
|
|
|
existing = tmp_path / "model.safetensors"
|
|
existing.write_bytes(b"x" * 32)
|
|
|
|
await ModelSourceHandler().download_model_source(
|
|
FakeRequest(
|
|
json_data={
|
|
"platform": "modelscope",
|
|
"repo": "owner/name",
|
|
"filename": "model.safetensors",
|
|
"model_root": str(tmp_path),
|
|
"download_id": "dl-7",
|
|
}
|
|
)
|
|
)
|
|
|
|
assert broadcast.await_args.args[1]["stage"] == "source"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_save_source_metadata_survives_a_hydration_failure(tmp_path, monkeypatch):
|
|
"""Metadata hydration must never be able to fail a completed download."""
|
|
model_path = tmp_path / "downloaded.safetensors"
|
|
model_path.write_bytes(b"x" * 32)
|
|
|
|
metadata = LoraMetadata(
|
|
file_name="downloaded",
|
|
model_name="Downloaded",
|
|
file_path=str(model_path),
|
|
size=32,
|
|
modified=1.0,
|
|
sha256="a" * 64,
|
|
base_model="SDXL 1.0",
|
|
preview_url="",
|
|
)
|
|
monkeypatch.setattr(
|
|
model_source_handlers.MetadataManager,
|
|
"create_default_metadata",
|
|
AsyncMock(return_value=metadata),
|
|
)
|
|
monkeypatch.setattr(
|
|
ServiceRegistry,
|
|
"get_lora_scanner",
|
|
AsyncMock(return_value=SimpleNamespace(add_model_to_cache=AsyncMock())),
|
|
)
|
|
monkeypatch.setattr(
|
|
model_source_handlers, "_infer_model_type", lambda _root: (LoraMetadata, "get_lora_scanner")
|
|
)
|
|
monkeypatch.setattr(
|
|
model_source_handlers,
|
|
"hydrate_from_source",
|
|
AsyncMock(side_effect=RuntimeError("site down")),
|
|
)
|
|
|
|
ref = SourceRef(
|
|
platform="modelscope", source_id="u/r", url="https://modelscope.cn/models/u/r"
|
|
)
|
|
await model_source_handlers._save_source_metadata(str(model_path), ref, str(tmp_path))
|
|
|
|
saved = json.loads(open(_sidecar_path(model_path), encoding="utf-8").read())
|
|
assert saved["source_platform"] == "modelscope"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_downloading_an_existing_file_still_hydrates(tmp_path, monkeypatch):
|
|
"""A pre-existing file may still be missing the site's metadata."""
|
|
_stub_download_backend(monkeypatch)
|
|
saved = AsyncMock()
|
|
monkeypatch.setattr(model_source_handlers, "_save_source_metadata", saved)
|
|
hydrate = AsyncMock()
|
|
monkeypatch.setattr(model_source_handlers, "hydrate_from_source", hydrate)
|
|
|
|
existing = tmp_path / "model.safetensors"
|
|
existing.write_bytes(b"x" * 32)
|
|
|
|
response = await ModelSourceHandler().download_model_source(
|
|
FakeRequest(
|
|
json_data={
|
|
"platform": "modelscope",
|
|
"repo": "owner/name",
|
|
"filename": "model.safetensors",
|
|
"model_root": str(tmp_path),
|
|
}
|
|
)
|
|
)
|
|
|
|
assert response.status == 200
|
|
saved.assert_not_awaited()
|
|
assert hydrate.await_args.args == (str(existing),)
|
|
assert hydrate.await_args.kwargs["ref"].source_id == "owner/name"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Download-time metadata hydration (end to end)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _modelscope_card_payload() -> dict:
|
|
"""A trimmed ModelScope model-detail response for the hydration test."""
|
|
|
|
return {
|
|
"Code": 200,
|
|
"Data": {
|
|
"Name": "Krea-2-LORA",
|
|
"ChineseName": "krea脸模",
|
|
"AigcType": "LoRA",
|
|
"Description": "权重0.5-1.2。配合《风格滤镜》lora一起使用。",
|
|
"BaseModel": ["krea/Krea-2-Turbo"],
|
|
"OfficialTags": [{"Tag": "photography"}, {"Tag": "woman"}],
|
|
"ModelInfos": {
|
|
"safetensor": {
|
|
"files": [
|
|
{
|
|
"name": "Krea-2-LORA_c1-st1000.safetensors",
|
|
"sha256": "a" * 64,
|
|
}
|
|
]
|
|
}
|
|
},
|
|
"MuseInfo": {
|
|
"versions": [
|
|
{
|
|
"stats": {"fileList": ["Krea-2-LORA_c1-st1000.safetensors"]},
|
|
"modelVersion": {
|
|
"showName": "c1-st1000",
|
|
"triggerWords": '["kreaface","kreamodel"]',
|
|
},
|
|
"coverImages": [
|
|
{"url": "https://resources.modelscope.cn/cover-images/b.png"},
|
|
{"url": "https://resources.modelscope.cn/cover-images/c.png"},
|
|
],
|
|
}
|
|
]
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_hydrates_the_card_from_the_site(tmp_path, monkeypatch):
|
|
"""A ModelScope download must land with a populated model card.
|
|
|
|
Only the network, the scanner and the file transfer are faked, so this
|
|
exercises the real handler, the real `ModelScopeSource` and the real
|
|
post-processor together. Breaking the wiring between them fails here even
|
|
when each half still passes its own unit tests.
|
|
"""
|
|
model_path = tmp_path / "Krea-2-LORA_c1-st1000.safetensors"
|
|
|
|
async def fake_download_file(**kwargs):
|
|
with open(kwargs["save_path"], "wb") as handle:
|
|
handle.write(b"stub")
|
|
return True, kwargs["save_path"]
|
|
|
|
class _Downloader:
|
|
download_file = staticmethod(fake_download_file)
|
|
|
|
class _Settings:
|
|
def get(self, key, default=None):
|
|
return default
|
|
|
|
async def fake_get_downloader():
|
|
return _Downloader()
|
|
|
|
monkeypatch.setattr(model_source_handlers, "get_downloader", fake_get_downloader)
|
|
monkeypatch.setattr(
|
|
model_source_handlers, "get_settings_manager", lambda: _Settings()
|
|
)
|
|
monkeypatch.setattr(
|
|
model_source_handlers,
|
|
"_infer_model_type",
|
|
lambda _root: (LoraMetadata, "get_lora_scanner"),
|
|
)
|
|
|
|
scanner = SimpleNamespace(
|
|
get_cached_data=AsyncMock(
|
|
return_value=SimpleNamespace(raw_data=[{"file_path": str(model_path)}])
|
|
),
|
|
add_model_to_cache=AsyncMock(),
|
|
update_single_model_cache=AsyncMock(),
|
|
)
|
|
monkeypatch.setattr(
|
|
ServiceRegistry, "get_lora_scanner", AsyncMock(return_value=scanner)
|
|
)
|
|
|
|
async def fake_fetch_text(url, **_kwargs):
|
|
return "# Krea-2-LORA\n\n权重0.5-1.2。"
|
|
|
|
async def fake_fetch_json(url, **_kwargs):
|
|
return 200, _modelscope_card_payload()
|
|
|
|
monkeypatch.setattr(
|
|
"py.services.model_sources.modelscope.fetch_text", fake_fetch_text
|
|
)
|
|
monkeypatch.setattr(
|
|
"py.services.model_sources.modelscope.fetch_json", fake_fetch_json
|
|
)
|
|
monkeypatch.setattr(
|
|
"py.metadata_ops.list_base_models", AsyncMock(return_value=["Krea 2"])
|
|
)
|
|
monkeypatch.setattr(
|
|
"py.metadata_ops.download_preview",
|
|
AsyncMock(return_value=str(tmp_path / "preview.webp")),
|
|
)
|
|
|
|
response = await ModelSourceHandler().download_model_source(
|
|
FakeRequest(
|
|
json_data={
|
|
"platform": "modelscope",
|
|
"repo": "jj3550945163/Krea-2-LORA",
|
|
"filename": "Krea-2-LORA_c1-st1000.safetensors",
|
|
"model_root": str(tmp_path),
|
|
}
|
|
)
|
|
)
|
|
|
|
assert response.status == 200
|
|
saved = json.loads(open(_sidecar_path(model_path), encoding="utf-8").read())
|
|
|
|
# The download's own provenance is unchanged.
|
|
assert saved["source_platform"] == "modelscope"
|
|
assert saved["source_url"] == "https://modelscope.cn/models/jj3550945163/Krea-2-LORA"
|
|
assert saved["from_civitai"] is False
|
|
|
|
# The site's published metadata, with no LLM involved.
|
|
assert saved["model_name"] == "Krea-2-LORA"
|
|
assert saved["base_model"] == "Krea 2"
|
|
assert saved["tags"] == ["photography", "woman"]
|
|
assert saved["civitai"]["name"] == "c1-st1000"
|
|
assert saved["civitai"]["trainedWords"] == ["kreaface", "kreamodel"]
|
|
assert saved["civitai"]["description"] == "权重0.5-1.2。配合《风格滤镜》lora一起使用。"
|
|
assert [img["url"] for img in saved["civitai"]["images"]] == [
|
|
"https://resources.modelscope.cn/cover-images/b.png",
|
|
"https://resources.modelscope.cn/cover-images/c.png",
|
|
]
|
|
assert saved["preview_url"] == str(tmp_path / "preview.webp")
|
|
assert saved["usage_tips"] == (
|
|
'{"strength_min": 0.5, "strength_max": 1.2, "strength_range": "0.5-1.2"}'
|
|
)
|
|
assert saved["metadata_source"] == "source:modelscope"
|
|
# No provider answered, so claiming an AI enrichment would be a lie.
|
|
assert "llm_enriched_at" not in saved
|
|
|
|
# The enriched card reaches the scanner cache, not just the file.
|
|
assert scanner.update_single_model_cache.await_count == 1
|
|
cached = scanner.update_single_model_cache.await_args.args[2]
|
|
assert cached["model_name"] == "Krea-2-LORA"
|