diff --git a/src/modelscope_hub/_legacy_api.py b/src/modelscope_hub/_legacy_api.py index 2fc7547..ae8a95d 100644 --- a/src/modelscope_hub/_legacy_api.py +++ b/src/modelscope_hub/_legacy_api.py @@ -264,6 +264,16 @@ def create_repo(self, repo_type: str, body: dict[str, Any]) -> dict: resp = self._request("POST", segment, json_body=body) return self._json_data(resp) + def create_aigc_model(self, body: dict[str, Any]) -> dict: + """POST /api/v1/models/aigc — create an AIGC model repository. + + AIGC repositories use a dedicated legacy endpoint and payload shape; + routing them through :meth:`create_repo` would incorrectly target the + ordinary ``/models`` endpoint. + """ + resp = self._request("POST", "models/aigc", json_body=body) + return self._json_data(resp) + def get_repo_info(self, repo_id: str, repo_type: str) -> dict: """GET /api/v1/{type}s/{repo_id} — fetch repository metadata. @@ -638,6 +648,11 @@ def list_revisions_detail( ) return [], [] + def create_aigc_model_tag(self, body: dict[str, Any]) -> dict: + """POST /api/v1/models/aigc/repo/tag — create an AIGC model version.""" + resp = self._request("POST", "models/aigc/repo/tag", json_body=body) + return self._json_data(resp) + def create_tag( self, repo_id: str, diff --git a/src/modelscope_hub/compat/hub_api.py b/src/modelscope_hub/compat/hub_api.py index 9750d48..c9e6fe0 100644 --- a/src/modelscope_hub/compat/hub_api.py +++ b/src/modelscope_hub/compat/hub_api.py @@ -14,7 +14,7 @@ from urllib.parse import urlencode from ..api import HubApi -from ..constants import RepoType +from ..constants import RepoType, Visibility from ..errors import ( AlreadyExistsError, AuthenticationError, @@ -35,6 +35,32 @@ META_FILES_FORMAT = {".json", ".csv", ".jsonl", ".tsv", ".py"} +class _AigcUploadAdapter: + """Expose model-only upload methods expected by legacy ``AigcModel``.""" + + def __init__(self, api: HubApi) -> None: + self._api = api + + def upload_file(self, *, repo_id: str, path_or_fileobj: Any, path_in_repo: str, **kwargs: Any) -> dict: + kwargs.pop("token", None) + return self._api.upload_file( + repo_id=repo_id, + repo_type=RepoType.MODEL, + path_or_fileobj=path_or_fileobj, + path_in_repo=path_in_repo, + **kwargs, + ) + + def upload_folder(self, *, repo_id: str, folder_path: Any, **kwargs: Any) -> dict | list[dict] | None: + kwargs.pop("token", None) + return self._api.upload_folder( + repo_id=repo_id, + repo_type=RepoType.MODEL, + folder_path=folder_path, + **kwargs, + ) + + class LegacyHubApi: """Drop-in replacement for the old ``modelscope.hub.api.HubApi``. @@ -164,14 +190,17 @@ def create_repo( def create_model(self, model_id: str, **kwargs: Any) -> str: """Create a model repo (legacy signature). - Returns the model repository URL for backward compatibility. - Converts authentication errors to ``ValueError`` for legacy callers. + AIGC models retain their dedicated endpoint and payload mapping. Plain + models continue to use the unified :meth:`create_repo` path. """ # Pre-normalize: convert numeric string to int for backward compatibility visibility = kwargs.get("visibility") if isinstance(visibility, str) and visibility.isdigit(): kwargs["visibility"] = int(visibility) try: + aigc_model = kwargs.pop("aigc_model", None) + if aigc_model is not None: + return self._create_aigc_model(model_id, aigc_model, kwargs) self.create_repo(model_id, repo_type="model", **kwargs) except (AuthenticationError, InvalidParameter) as e: if _is_auth_related(e): @@ -180,6 +209,116 @@ def create_model(self, model_id: str, **kwargs: Any) -> str: ep = self._endpoint or self._api._config.endpoint return f"{ep}/models/{model_id}" + def _create_aigc_model(self, model_id: str, aigc_model: Any, kwargs: dict[str, Any]) -> str: + """Create an AIGC model without changing the plain-model code path.""" + token = kwargs.pop("token", None) + endpoint = kwargs.pop("endpoint", None) + visibility = kwargs.pop("visibility", None) + license_name = kwargs.pop("license", None) + chinese_name = kwargs.pop("chinese_name", None) + original_model_id = kwargs.pop("original_model_id", "") + gated_mode = kwargs.pop("gated_mode", None) + if kwargs: + unexpected = ", ".join(sorted(kwargs)) + raise TypeError(f"create_model() got unexpected keyword argument(s): {unexpected}") + + api = self._api + if token or endpoint: + api = HubApi(token=token, endpoint=endpoint or self._endpoint) + owner, name = api._parse_repo_id(model_id) + normalised_visibility = api._normalize_visibility(visibility) + if normalised_visibility is None: + normalised_visibility = int(Visibility.PUBLIC) + + body: dict[str, Any] = { + "Path": owner, + "Name": name, + "ChineseName": chinese_name, + "Visibility": normalised_visibility, + "License": license_name or "Apache License 2.0", + "OriginalModelId": original_model_id, + "TrainId": os.environ.get("MODELSCOPE_TRAIN_ID", ""), + "TagShowName": aigc_model.tag, + "CoverImages": aigc_model.cover_images, + "AigcType": aigc_model.aigc_type, + "TagDescription": aigc_model.description, + "VisionFoundation": aigc_model.base_model_type, + "BaseModel": aigc_model.base_model_id or original_model_id, + "WeightsName": aigc_model.weight_filename, + "WeightsSha256": aigc_model.weight_sha256, + "WeightsSize": aigc_model.weight_size, + "ModelPath": aigc_model.model_path, + "TriggerWords": aigc_model.trigger_words, + "ModelSource": aigc_model.model_source, + "SubVisionFoundation": aigc_model.base_model_sub_type, + } + if aigc_model.official_tags: + body["OfficialTags"] = aigc_model.official_tags + if gated_mode is not None: + if normalised_visibility == int(Visibility.PRIVATE): + body["ProtectedMode"] = 1 if gated_mode else 2 + else: + logger.warning("gated_mode is only effective when visibility is PRIVATE, ignored.") + + cookies = api.get_cookies(access_token=token, cookies_required=True) + aigc_model.preupload_weights( + cookies=cookies, + headers={}, + endpoint=api._config.endpoint, + ) + api.legacy.create_aigc_model(body) + aigc_model.upload_to_repo(_AigcUploadAdapter(api), model_id, token) + return f"{api._config.endpoint}/models/{model_id}" + + def create_model_tag( + self, + model_id: str, + tag_name: str, + endpoint: str | None = None, + token: str | None = None, + aigc_model: Any = None, + ) -> str: + """Create a model tag while preserving the AIGC-specific endpoint.""" + if not model_id: + raise InvalidParameter("model_id is required!") + if not tag_name: + raise InvalidParameter("tag_name is required!") + if tag_name.lower() in {"main", "master"}: + raise InvalidParameter( + f'tag_name "{tag_name}" is not allowed. ' + 'Please use a different tag name (e.g., "v1.0", "v1.1", "latest"). ' + 'Reserved names: main, master' + ) + + api = self._api + if token or endpoint: + api = HubApi(token=token, endpoint=endpoint or self._endpoint) + if aigc_model is None: + api.create_repo_tag(model_id, RepoType.MODEL, tag_name, revision="master") + else: + owner, name = api._parse_repo_id(model_id) + cookies = api.get_cookies(access_token=token, cookies_required=True) + aigc_model.preupload_weights( + cookies=cookies, + headers={}, + endpoint=api._config.endpoint, + ) + api.legacy.create_aigc_model_tag( + { + "CoverImages": aigc_model.cover_images, + "Name": name, + "Path": owner, + "TagShowName": tag_name, + "WeightsName": aigc_model.weight_filename, + "WeightsSha256": aigc_model.weight_sha256, + "WeightsSize": aigc_model.weight_size, + "TriggerWords": aigc_model.trigger_words, + "AigcType": aigc_model.aigc_type, + "VisionFoundation": aigc_model.base_model_type, + } + ) + return f"{api._config.endpoint}/models/{model_id}/tags/{tag_name}" + def push_model(self, model_id: str, model_dir: str, **kwargs: Any) -> None: """Upload a model directory (legacy signature).""" # Pre-validate model_dir diff --git a/tests/test_compat_get_model_files.py b/tests/test_compat_get_model_files.py index e6c098c..7d4023b 100644 --- a/tests/test_compat_get_model_files.py +++ b/tests/test_compat_get_model_files.py @@ -1,9 +1,7 @@ -"""Unit tests for the legacy-compatible ``LegacyHubApi.get_model_files``. +"""Unit tests for selected legacy-compatible ``LegacyHubApi`` methods. -Network-free: the underlying ``HubApi.list_repo_files`` is mocked so we only -verify the compat wrapper's signature and parameter forwarding. Regression -guard for callers (e.g. vLLM) that pass the historical ``revision`` / ``root`` -keyword arguments. +Network-free: the bottom-level HTTP transport is mocked where full compat +call-chain coverage matters. """ from __future__ import annotations @@ -14,6 +12,33 @@ from modelscope_hub.compat import LegacyHubApi +class _FakeAigcModel: + tag = "v1.0" + cover_images = ["data:image/png;base64,AAAA"] + aigc_type = "LoRA" + description = "AIGC compatibility test" + base_model_type = "SD_XL" + base_model_id = "owner/base-model" + weight_filename = "model.safetensors" + weight_sha256 = "abc123" + weight_size = 42 + model_path = "/tmp/model.safetensors" + trigger_words = ["trigger"] + model_source = "USER_UPLOAD" + base_model_sub_type = "SD_XL" + official_tags = ["photography"] + + def __init__(self): + self.preupload_weights = mock.MagicMock() + self.upload_to_repo = mock.MagicMock(return_value=True) + + +def _response(data=None): + response = mock.MagicMock() + response.json.return_value = {"Data": data or {}} + return response + + def _fake_files(): return [ SimpleNamespace(path="config.json", size=10), @@ -59,3 +84,125 @@ def test_default_revision_none_forwarded(self): _, kwargs = m.call_args assert kwargs["revision"] is None assert kwargs["recursive"] is True + + +class TestCreateModelLegacyCompat: + def test_aigc_model_uses_dedicated_endpoint_and_payload(self): + api = LegacyHubApi(endpoint="https://modelscope.cn", token="ms-test") + aigc_model = _FakeAigcModel() + + with mock.patch.object(api._api.legacy, "_request", return_value=_response()) as request: + url = api.create_model( + "owner/aigc-model", + visibility=1, + license="Apache License 2.0", + chinese_name="AIGC 模型", + original_model_id="owner/original-model", + aigc_model=aigc_model, + gated_mode=False, + ) + + request.assert_called_once() + method, path = request.call_args.args + body = request.call_args.kwargs["json_body"] + assert (method, path) == ("POST", "models/aigc") + assert body == { + "Path": "owner", + "Name": "aigc-model", + "ChineseName": "AIGC 模型", + "Visibility": 1, + "License": "Apache License 2.0", + "OriginalModelId": "owner/original-model", + "TrainId": "", + "TagShowName": "v1.0", + "CoverImages": ["data:image/png;base64,AAAA"], + "AigcType": "LoRA", + "TagDescription": "AIGC compatibility test", + "VisionFoundation": "SD_XL", + "BaseModel": "owner/base-model", + "WeightsName": "model.safetensors", + "WeightsSha256": "abc123", + "WeightsSize": 42, + "ModelPath": "/tmp/model.safetensors", + "TriggerWords": ["trigger"], + "ModelSource": "USER_UPLOAD", + "SubVisionFoundation": "SD_XL", + "OfficialTags": ["photography"], + "ProtectedMode": 2, + } + assert url == "https://modelscope.cn/models/owner/aigc-model" + aigc_model.preupload_weights.assert_called_once() + preupload_kwargs = aigc_model.preupload_weights.call_args.kwargs + assert preupload_kwargs["cookies"]["m_session_id"] == "ms-test" + assert preupload_kwargs["endpoint"] == "https://modelscope.cn" + aigc_model.upload_to_repo.assert_called_once() + upload_api, model_id, token = aigc_model.upload_to_repo.call_args.args + assert upload_api._api is api._api + assert model_id == "owner/aigc-model" + assert token is None + + def test_plain_model_keeps_generic_create_repo_path(self): + api = LegacyHubApi(endpoint="https://modelscope.cn", token="ms-test") + + with mock.patch.object(api._api.legacy, "_request", return_value=_response()) as request: + url = api.create_model( + "owner/plain-model", + visibility=1, + license="Apache License 2.0", + chinese_name="普通模型", + aigc_model=None, + ) + + request.assert_called_once() + method, path = request.call_args.args + body = request.call_args.kwargs["json_body"] + assert (method, path) == ("POST", "models") + assert body["Path"] == "owner" + assert body["Name"] == "plain-model" + assert "TagShowName" not in body + assert "aigc_model" not in body + assert url == "https://modelscope.cn/models/owner/plain-model" + + def test_aigc_model_tag_uses_dedicated_endpoint(self): + api = LegacyHubApi(endpoint="https://modelscope.cn", token="ms-test") + aigc_model = _FakeAigcModel() + + with mock.patch.object(api._api.legacy, "_request", return_value=_response()) as request: + url = api.create_model_tag( + "owner/aigc-model", + "v1.1", + aigc_model=aigc_model, + ) + + request.assert_called_once() + method, path = request.call_args.args + assert (method, path) == ("POST", "models/aigc/repo/tag") + assert request.call_args.kwargs["json_body"] == { + "CoverImages": ["data:image/png;base64,AAAA"], + "Name": "aigc-model", + "Path": "owner", + "TagShowName": "v1.1", + "WeightsName": "model.safetensors", + "WeightsSha256": "abc123", + "WeightsSize": 42, + "TriggerWords": ["trigger"], + "AigcType": "LoRA", + "VisionFoundation": "SD_XL", + } + aigc_model.preupload_weights.assert_called_once() + assert url == "https://modelscope.cn/models/owner/aigc-model/tags/v1.1" + + def test_plain_model_tag_keeps_generic_endpoint(self): + api = LegacyHubApi(endpoint="https://modelscope.cn", token="ms-test") + + with mock.patch.object(api._api.legacy, "_request", return_value=_response()) as request: + url = api.create_model_tag("owner/plain-model", "v1.1") + + request.assert_called_once() + method, path = request.call_args.args + assert (method, path) == ("POST", "models/owner/plain-model/repo/tag") + assert request.call_args.kwargs["json_body"] == { + "TagName": "v1.1", + "Ref": "master", + } + assert url == "https://modelscope.cn/models/owner/plain-model/tags/v1.1"