diff --git a/src/modelscope_hub/agent/__init__.py b/src/modelscope_hub/agent/__init__.py index 50b788c..025d267 100644 --- a/src/modelscope_hub/agent/__init__.py +++ b/src/modelscope_hub/agent/__init__.py @@ -11,12 +11,17 @@ (download/commit/LFS/list/create/delete). - :class:`RemoteFileInfo` -- metadata for a single remote file. - :func:`is_lfs_file` -- decide whether a file must use the LFS upload path. +- ``agent_visibility_label`` / ``agent_last_modified`` -- read renamed agent + metadata fields from an API item, tolerating both JSON spellings + (snake_case and PascalCase) and legacy keys. """ -from ._api import AgentApi, RemoteFileInfo, is_lfs_file +from ._api import AgentApi, RemoteFileInfo, agent_last_modified, agent_visibility_label, is_lfs_file __all__ = [ "AgentApi", "RemoteFileInfo", "is_lfs_file", + "agent_visibility_label", + "agent_last_modified", ] diff --git a/src/modelscope_hub/agent/_api.py b/src/modelscope_hub/agent/_api.py index 2939ab7..16effcc 100644 --- a/src/modelscope_hub/agent/_api.py +++ b/src/modelscope_hub/agent/_api.py @@ -89,6 +89,57 @@ _LFS_SIZE_THRESHOLD: int = 1 * 1024 * 1024 # 1 MB +def agent_visibility_label(item: dict) -> str: + """Read an agent's visibility from an API item as a public/private label. + + The agent API replaced the ``visibility`` string with a boolean ``private`` + of INVERTED meaning (``private=false`` is public), in both snake_case + (OpenAPI / detail) and PascalCase (list / search) spellings. Reading the + raw field directly is a trap: ``False`` is falsy, so an ``or``-chain would + silently report a public agent as unknown. Legacy ``visibility`` keys are + still honoured so this works against older servers. + """ + # Current field: a plain bool, so truthiness is the whole story -- no + # casing/whitespace normalization applies. ``key in item`` (not ``or``) + # because ``private=False`` means PUBLIC and would be skipped as falsy. + for key in ("Private", "private"): + if key in item and item[key] is not None: + return Visibility.PRIVATE.label if item[key] else Visibility.PUBLIC.label + + # Legacy field, which arrived in several shapes: a label of any casing + # (``"Public"``), an int enum (1/3/5) or its numeric string -- hence the + # normalization below. Unknown values are echoed rather than guessed: + # the old server logic treated everything != "public" as private, which + # is exactly how ``"Public"`` used to flip an agent private by accident. + raw = item.get("Visibility") + if raw is None: + raw = item.get("visibility") + if raw is None or raw == "": + return "-" + if isinstance(raw, bool): # bool is an int subclass -- check it first + return Visibility.PRIVATE.label if raw else Visibility.PUBLIC.label + try: + if isinstance(raw, int): + return Visibility(raw).label + return Visibility.from_label(str(raw).strip().lower()).label + except (ValueError, KeyError): + return str(raw).strip().lower() or "-" + + +def agent_last_modified(item: dict) -> str: + """Read an agent's last-modified timestamp from an API item. + + ``last_modified`` / ``LastModified`` superseded ``gmt_modified`` and is now + UTC RFC3339, so it must not be shown as if it were local time. Older keys + are accepted as a fallback. + """ + for key in ("LastModified", "last_modified", "GmtModified", "gmt_modified", "LastUpdatedDate", "last_updated_date"): + val = item.get(key) + if val: + return str(val) + return "-" + + @dataclass class RemoteFileInfo: """Metadata for a single file in the remote repository.""" @@ -235,12 +286,22 @@ def create_repo(self, path: str, name: str, framework: str | None = None, visibi repo (e.g. "qoder", "nanobot"). Defaults to server-side default when omitted. visibility: Repository visibility, ``"public"`` (default) or - ``"private"``. + ``"private"``. Kept as a label for a stable caller- + facing API; it is sent over the wire as the boolean + ``private`` field (see below). """ allowed = (Visibility.PUBLIC.label, Visibility.PRIVATE.label) if visibility not in allowed: raise ValueError(f"visibility must be one of {allowed}, got {visibility!r}") - body: dict = {"path": path, "name": name, "visibility": visibility} + # The agent API takes a boolean ``private`` (INVERTED semantics), not + # the old ``visibility`` string. A string here would be rejected with + # 400, and omitting it would silently default to public, so always + # send an explicit bool. + body: dict = { + "path": path, + "name": name, + "private": visibility == Visibility.PRIVATE.label, + } if framework: body["framework"] = framework return self._openapi.request("POST", "/agents", json_body=body) diff --git a/src/modelscope_hub/cli/agent.py b/src/modelscope_hub/cli/agent.py index e99e0b3..3f8fe33 100644 --- a/src/modelscope_hub/cli/agent.py +++ b/src/modelscope_hub/cli/agent.py @@ -14,7 +14,7 @@ from argparse import RawDescriptionHelpFormatter from pathlib import Path -from ..agent import AgentApi, is_lfs_file +from ..agent import AgentApi, agent_last_modified, agent_visibility_label, is_lfs_file from ..constants import Visibility from ..errors import APIError from .base import CLICommand, SubParsers @@ -87,8 +87,8 @@ def _cmd_list(owner, page_number, page_size, *, endpoint, token) -> int: name = item.get("Name") or item.get("name") or "" repo_id = f"{owner_name}/{name}" if owner_name else name fw = item.get("Framework") or item.get("framework") or "-" - vis = item.get("Visibility") or item.get("visibility") or "-" - updated = item.get("LastUpdatedDate") or item.get("last_updated_date") or "-" + vis = agent_visibility_label(item) + updated = agent_last_modified(item) if isinstance(updated, str) and "T" in updated: updated = updated.split("T")[0] rows.append((repo_id, fw, vis, updated))