From eba03800b91728ab7ec3ec6344cd80628c1fbd53 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Thu, 17 Sep 2026 20:46:42 +0800 Subject: [PATCH] feat(other): answer model-versions-status read-only for unsupported types Civitai types with no scanner at all (Wildcards, Workflows, Hypernetwork, Poses, AestheticGradient) used to get a 400 'Model type "x" is not supported', which hid the Civitai version list from clients. The handler now answers 200 with supported:false, a machine-readable reason (model_type_unsupported, or other_models_disabled when the opt-in master switch is off) and the versions marked read-only. The interactive payload gains an explicit supported:true. Legacy clients only read success/versions, so they are unaffected. --- py/routes/handlers/misc_handlers.py | 37 +++++++- tests/routes/test_misc_routes.py | 132 ++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+), 4 deletions(-) diff --git a/py/routes/handlers/misc_handlers.py b/py/routes/handlers/misc_handlers.py index 1541d5dd..cc8f397d 100644 --- a/py/routes/handlers/misc_handlers.py +++ b/py/routes/handlers/misc_handlers.py @@ -2764,12 +2764,40 @@ class ModelLibraryHandler: normalized_type, scanner = await self._get_scanner_for_type(model_type) if not normalized_type: + # The lookup cannot be served as a fully interactive list. Two + # cases share this branch: a CivitAI type with no scanner at all + # (Wildcards, Workflows, Hypernetwork, Poses, AestheticGradient) + # and an Other-model type while the opt-in master switch is off. + # Answer 200 with the CivitAI list marked read-only plus a + # machine-readable reason, so clients can still show the + # versions and explain why the actions are missing. Legacy + # clients keep working: they only read `success`/`versions`. + reason = ( + "other_models_disabled" + if self._normalize_model_type(model_type) == "other" + else "model_type_unsupported" + ) return web.json_response( { - "success": False, - "error": f'Model type "{model_type}" is not supported', - }, - status=400, + "success": True, + "modelId": model_id, + "modelName": model_name, + "modelType": model_type, + "supported": False, + "reason": reason, + "versions": [ + { + "id": version.get("id"), + "name": version.get("name", ""), + "thumbnailUrl": version.get("images")[0]["url"] + if version.get("images") + else None, + "inLibrary": False, + "hasBeenDownloaded": False, + } + for version in versions + ], + } ) if not scanner: @@ -2811,6 +2839,7 @@ class ModelLibraryHandler: "modelId": model_id, "modelName": model_name, "modelType": model_type, + "supported": True, "versions": enriched_versions, } ) diff --git a/tests/routes/test_misc_routes.py b/tests/routes/test_misc_routes.py index 0d918446..cb8406a8 100644 --- a/tests/routes/test_misc_routes.py +++ b/tests/routes/test_misc_routes.py @@ -2425,3 +2425,135 @@ async def test_get_init_status_reports_pending_scanners(): assert "embedding" in payload["details"] assert "recipe" in payload["details"] assert "lora" not in payload["details"] + + +class StaticMetadataProvider: + """Metadata provider returning one fixed CivitAI model payload.""" + + def __init__(self, payload): + self.payload = payload + + async def get_model_versions(self, _model_id): + return self.payload + + async def get_user_models(self, _username, cursor=None): + return {"items": [], "nextCursor": None} + + async def get_creator_model_count(self, _username): + return None + + +def _versions_status_handler(payload, *, other_scanner=None): + async def metadata_factory(): + return StaticMetadataProvider(payload) + + async def other_factory(): + return other_scanner + + return ModelLibraryHandler( + ServiceRegistryAdapter( + get_lora_scanner=fake_scanner_factory, + get_checkpoint_scanner=fake_scanner_factory, + get_embedding_scanner=fake_scanner_factory, + get_other_scanner=other_factory, + get_downloaded_version_history_service=fake_download_history_service_factory, + ), + metadata_provider_factory=metadata_factory, + ) + + +@pytest.mark.asyncio +async def test_get_model_versions_status_unsupported_type_is_read_only(): + """A type with no scanner answers 200 with a read-only list + reason.""" + handler = _versions_status_handler( + { + "name": "Wildcards pack", + "type": "Wildcards", + "modelVersions": [ + {"id": 11, "name": "v1", "images": [{"url": "https://img/1.png"}]}, + {"id": 12, "name": "v2", "images": []}, + ], + } + ) + + response = await handler.get_model_versions_status( + FakeRequest(query={"modelId": "45448"}) # pyright: ignore[reportArgumentType] + ) + payload = _json_payload(response) + + assert response.status == 200 + assert payload["success"] is True + assert payload["supported"] is False + assert payload["reason"] == "model_type_unsupported" + assert payload["modelType"] == "wildcards" + assert payload["versions"] == [ + { + "id": 11, + "name": "v1", + "thumbnailUrl": "https://img/1.png", + "inLibrary": False, + "hasBeenDownloaded": False, + }, + { + "id": 12, + "name": "v2", + "thumbnailUrl": None, + "inLibrary": False, + "hasBeenDownloaded": False, + }, + ] + + +@pytest.mark.asyncio +async def test_get_model_versions_status_other_disabled_is_read_only(): + """The opt-in gate keeps its own reason instead of the permanent one.""" + _set_other_models_enabled(False) + handler = _versions_status_handler( + { + "name": "SDXL VAE", + "type": "VAE", + "modelVersions": [{"id": 333245, "name": "SDXL-VAE", "images": []}], + } + ) + + response = await handler.get_model_versions_status( + FakeRequest(query={"modelId": "296576"}) # pyright: ignore[reportArgumentType] + ) + payload = _json_payload(response) + + assert response.status == 200 + assert payload["success"] is True + assert payload["supported"] is False + assert payload["reason"] == "other_models_disabled" + assert payload["modelType"] == "vae" + + +@pytest.mark.asyncio +async def test_get_model_versions_status_supported_type_stays_interactive(): + """A managed type keeps the existing enriched, fully interactive payload.""" + handler = _versions_status_handler( + { + "name": "Some LoRA", + "type": "LORA", + "modelVersions": [{"id": 1, "name": "v1", "images": []}], + } + ) + + response = await handler.get_model_versions_status( + FakeRequest(query={"modelId": "5"}) # pyright: ignore[reportArgumentType] + ) + payload = _json_payload(response) + + assert response.status == 200 + assert payload["success"] is True + assert payload["supported"] is True + assert "reason" not in payload + assert payload["versions"] == [ + { + "id": 1, + "name": "v1", + "thumbnailUrl": None, + "inLibrary": False, + "hasBeenDownloaded": False, + } + ]