fix(modelscope): identify a model file by hash before filename

A file was matched to its published version by comparing basenames against
each version's `stats.fileList`.  Renaming the weights — routine once a
model is filed away, and the reason the scanner records a sha256 at all —
made the match fail silently, so the file lost its example images and its
preview with no indication why.

The detail payload's `ModelInfos.safetensor.files[]` carries a real sha256
per published file, and the local hash is already on disk, so match on that
first: it is the one identifier a rename cannot invalidate.  Exact basename
and `showName` matching remain as fallbacks, and an unknown hash falls
through to them rather than giving up, so a re-encoded file still resolves.

Verified against the live repository: a renamed `c1-st1000` file with its
hash yields the c1-st1000 image, the same rename without a hash yields
nothing, and supplying c1-st2000's hash resolves to the c1-st2000 image even
when the filename claims otherwise.
This commit is contained in:
Will Miao
2026-09-14 21:27:09 +08:00
parent e9e9ee20c6
commit 0f160e157f
5 changed files with 255 additions and 26 deletions
+157
View File
@@ -7,9 +7,12 @@ each provider's model-card fetching and capability flags.
from __future__ import annotations
from unittest import mock
import pytest
from py.services.agent.agent_service import AgentService
from py.services.model_sources import (
ModelCardContext,
ModelSourceCache,
HuggingFaceSource,
ModelScopeSource,
@@ -381,6 +384,22 @@ def _modelscope_detail_payload() -> dict:
},
]
},
"ModelInfos": {
"safetensor": {
"files": [
{
"name": "Krea-2-LORA_c1-st8000.safetensors",
"sha256": "a" * 64,
"size": 234680568,
},
{
"name": "Krea-2-LORA_c1-st1000.safetensors",
"sha256": "b" * 64,
"size": 234680568,
},
]
}
},
},
}
@@ -799,3 +818,141 @@ class TestCachedModelCardFetch:
await source.fetch_model_card_context("u/r", "a.safetensors")
assert len(calls) == 2
class TestHashBasedVersionMatching:
"""A renamed file must still find its own example images."""
ST1000_HASH = "b" * 64
ST8000_HASH = "a" * 64
@staticmethod
def _patch(monkeypatch):
async def fake_fetch_json(url, **_kwargs):
return 200, _modelscope_detail_payload()
monkeypatch.setattr(
"py.services.model_sources.modelscope.fetch_json", fake_fetch_json
)
@pytest.mark.asyncio
async def test_renamed_file_is_matched_by_sha256(self, monkeypatch):
self._patch(monkeypatch)
context = await ModelScopeSource().fetch_model_card_context(
"u/r", "krea脸模-st1000-我改的名字.safetensors", sha256=self.ST1000_HASH
)
assert context.example_images == [
"https://resources.modelscope.cn/cover-images/b.png",
"https://resources.modelscope.cn/cover-images/c.png",
]
assert context.trigger_words == ["kreaface", "kreamodel"]
@pytest.mark.asyncio
async def test_renamed_file_without_a_hash_finds_nothing(self, monkeypatch):
"""Pins the behaviour the hash match exists to fix."""
self._patch(monkeypatch)
context = await ModelScopeSource().fetch_model_card_context(
"u/r", "krea脸模-st1000-我改的名字.safetensors"
)
assert context.example_images == []
# Repo-wide fields are unaffected by the miss.
assert context.base_model == "krea/Krea-2-Turbo"
@pytest.mark.asyncio
async def test_hash_wins_over_a_filename_that_matches_another_version(
self, monkeypatch
):
"""An inconsistent name/hash pair trusts the content hash."""
self._patch(monkeypatch)
context = await ModelScopeSource().fetch_model_card_context(
"u/r", "Krea-2-LORA_c1-st8000.safetensors", sha256=self.ST1000_HASH
)
assert context.example_images == [
"https://resources.modelscope.cn/cover-images/b.png",
"https://resources.modelscope.cn/cover-images/c.png",
]
@pytest.mark.asyncio
async def test_unknown_hash_falls_back_to_the_filename(self, monkeypatch):
"""A re-encoded file still matches by name rather than losing its images."""
self._patch(monkeypatch)
context = await ModelScopeSource().fetch_model_card_context(
"u/r", "Krea-2-LORA_c1-st1000.safetensors", sha256="f" * 64
)
assert context.example_images == [
"https://resources.modelscope.cn/cover-images/b.png",
"https://resources.modelscope.cn/cover-images/c.png",
]
@pytest.mark.asyncio
async def test_hash_is_case_insensitive(self, monkeypatch):
self._patch(monkeypatch)
context = await ModelScopeSource().fetch_model_card_context(
"u/r", "renamed.safetensors", sha256=self.ST1000_HASH.upper()
)
assert len(context.example_images) == 2
@pytest.mark.asyncio
async def test_blank_hash_is_ignored(self, monkeypatch):
self._patch(monkeypatch)
context = await ModelScopeSource().fetch_model_card_context(
"u/r", "Krea-2-LORA_c1-st8000.safetensors", sha256=" "
)
assert context.example_images == [
"https://resources.modelscope.cn/cover-images/a.png"
]
@pytest.mark.asyncio
async def test_missing_model_infos_degrades_to_filename_matching(self, monkeypatch):
payload = _modelscope_detail_payload()
del payload["Data"]["ModelInfos"]
async def fake_fetch_json(url, **_kwargs):
return 200, payload
monkeypatch.setattr(
"py.services.model_sources.modelscope.fetch_json", fake_fetch_json
)
context = await ModelScopeSource().fetch_model_card_context(
"u/r", "Krea-2-LORA_c1-st1000.safetensors", sha256=self.ST1000_HASH
)
assert len(context.example_images) == 2
@pytest.mark.asyncio
async def test_real_published_hashes_are_used_by_the_agent(self):
"""The agent must pass the recorded hash, not just the filename."""
service = AgentService()
with (
mock.patch(
"py.services.model_sources.modelscope.ModelScopeSource.fetch_model_card",
new=mock.AsyncMock(return_value="# card"),
),
mock.patch(
"py.services.model_sources.modelscope.ModelScopeSource.fetch_model_card_context",
new=mock.AsyncMock(return_value=ModelCardContext()),
) as mock_ctx,
):
await service._load_source_card(
"/models/loras/renamed.safetensors",
{
"source_platform": "modelscope",
"source_url": "https://modelscope.cn/models/u/r",
"sha256": "c" * 64,
},
)
assert mock_ctx.call_args.kwargs["sha256"] == "c" * 64