diff --git a/README.md b/README.md index 7c27b19..d91c85d 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,23 @@ The official Python SDK & CLI for [ModelScope Hub](https://modelscope.cn) — do ## News +**v0.4.0** (2026-09-01) +- **Feature**: full MCP/Studios OpenAPI coverage: Studio lists, variables and configuration options; hosted MCP discovery; protected visibility and runtime metadata; read-only tokens can log in and rejected writes name the required tier +- **Fix**: Studio compat calls no longer leak connection options or API tokens, or drop cover images; errors distinguish permission, quota and conflicts; anonymous Studio info and log pagination work +- **Enhance**: MCP verb negotiation and Studio-owner listings adapt to endpoint behaviour +- **Quality**: the vendored OpenAPI spec and operation registry flag unimplemented published MCP/Studios endpoints + +**v0.3.1** (2026-09-01) +- **Refactor**: upload environment variables now state their units and semantics; deprecated names remain supported with warnings +- **Fix**: logout works; download locks avoid same-basename contention; `whoami` and legacy responses normalise pre-production field names + +**v0.3.0** (2026-08-19) +- **Feature**: this package owns all four console scripts (`modelscope`, `ms`, `modelscope-hub`, `ms-hub`), adds `HubApi.get_current_username()`, and supports both agent visibility wire formats +- **Fix**: large `repo/files` listings are re-enumerated when the server silently truncates results + +
+Older releases + **v0.2.0** (2026-08-01) - **Breaking**: `HubApi.login()` now raises `NetworkError` / `ServerError` / `RequestTimeoutError` for non-authentication failures instead of always raising `AuthenticationError` — widen `except AuthenticationError` to `HubError` if you catch *any* login failure - **Fix**: network, timeout and 5xx errors during login are no longer misreported as an invalid token; a failed login no longer deletes stored credentials; bare and upper-case endpoints are accepted (e.g. `--endpoint modelscope.ai`) @@ -49,9 +66,6 @@ The official Python SDK & CLI for [ModelScope Hub](https://modelscope.cn) — do - **Fix**: forward `progress_callbacks` through `HubApi.download_repo` so custom download-progress callbacks work end-to-end; harden legacy (pre-1.38) cache auto-detection (reuse existing `{cache}/models/...` and default `{cache}/hub/models/...` layouts); normal (non-LFS) file upload - **Packaging**: rename console scripts to `modelscope-hub` / `ms-hub` to avoid a file conflict with the `modelscope` package (e.g. FreeBSD pkg) -
-Older releases - **v0.1.7** (2026-07-07) - **Feature**: intra-/inter-region cloud download acceleration, with a source marker in the progress bar - **Fix**: align `snapshot_download` cache path with the CLI; add legacy cache fallback @@ -264,6 +278,36 @@ ms-hub login --token $MY_TOKEN # non-interactive |--------|-------------| | `--token TOKEN` | API token; prompted interactively if omitted | +#### Token permission levels + +Tokens are issued with one of three permission levels, set when you create the +token at [modelscope.cn/my/myaccesstoken](https://modelscope.cn/my/myaccesstoken): + +| Level | Can do | +|-------|--------| +| `read` | Browse and download; list your Studios, secrets and variables | +| `write` | Everything above, plus create/upload/deploy and change settings | +| `admin` | Everything above, plus administrative operations | + +What this means in practice: + +- **A read-only token can log in.** `ms-hub login` succeeds and prints a warning + that git and session credentials were not issued, so pushes and uploads will + need a `write` token. Downloads and browsing work normally. +- **A rejected write is explicit.** Attempting a write with a read-only token + fails with `[E3002] Permission denied` and a suggestion naming the level the + operation requires, rather than leaving you to guess. +- **Quota exhaustion is not a permission problem.** The service reports both as + HTTP 403; the SDK separates them into `PermissionDeniedError` and + `QuotaExceededError` (the latter is never retried, since retrying an exhausted + quota only delays the error). +- **Public reads never fail because of a token.** For public endpoints the SDK + retries once without credentials, so a read-scoped or stale token cannot hide + data any anonymous caller can see. + +> The SDK cannot tell you which level *your* token has: the API does not report +> it. Check the token settings page if you are unsure. + ### `ms-hub whoami` Show the user associated with the current token. @@ -400,6 +444,7 @@ ms-hub create my-org/my-model --repo-type model --visibility private ms-hub create my-org/demo --repo-type studio --sdk-type gradio ms-hub info my-org/my-model --repo-type model ms-hub list --repo-type model --owner my-org --page-size 20 +ms-hub list --repo-type studio --owner my-org ms-hub delete my-org/my-model --repo-type model --yes ``` @@ -414,7 +459,7 @@ ms-hub delete my-org/my-model --repo-type model --yes |-------------------|----------|-------------| | `repo_id` | yes | Repository identifier | | `--repo-type` | yes | `model`, `dataset`, `studio`, or `skill` | -| `--visibility` | no | `public`, `private`, or `internal` | +| `--visibility` | no | `public`, `private` or `internal`. For Studios also `protected` (app public, code repository hidden) | | `--license` | no | SPDX license identifier (e.g. `apache-2.0`) | | `--chinese-name` | no | Display name in Chinese | | `--description` | no | Repository description | @@ -453,15 +498,24 @@ ms-hub stop my-org/chat-demo --repo-type studio
-### `ms-hub secret` +### `ms-hub secret` / `ms-hub studio variable` -Manage secrets for Studio spaces (studio only, `--repo-type` defaults to `studio`). +Manage a Studio's environment variables. Two commands, because the disclosure +differs: a **secret**'s value is never returned by the API, a **variable**'s value +is publicly visible. ```bash +# Secrets — values are write-only ms-hub secret add my-org/demo API_KEY sk-xxx ms-hub secret list my-org/demo ms-hub secret update my-org/demo API_KEY sk-new ms-hub secret delete my-org/demo API_KEY --yes + +# Plaintext variables — values are readable by anyone +ms-hub studio variable add my-org/demo MODEL_NAME Qwen2.5-7B +ms-hub studio variable list my-org/demo +ms-hub studio variable update my-org/demo MODEL_NAME Qwen2.5-14B +ms-hub studio variable delete my-org/demo MODEL_NAME --yes ```
@@ -469,12 +523,64 @@ ms-hub secret delete my-org/demo API_KEY --yes | Subcommand | Arguments | Description | |------------|-----------|-------------| -| `add` | `repo_id key value` | Add a new secret | -| `list` | `repo_id` | List all secret keys | -| `update` | `repo_id key value` | Update a secret value | -| `delete` | `repo_id key [--yes]` | Delete a secret | +| `add` | `repo_id key value` | Add a new entry | +| `list` | `repo_id` | List entries (`secret` shows keys only; `variable` shows keys and values) | +| `update` | `repo_id key value` | Update a value | +| `delete` | `repo_id key [--yes]` | Delete an entry | -All subcommands accept `--repo-type` (default: `studio`, currently the only supported type). +`ms-hub secret` accepts `--repo-type` (default: `studio`, currently the only supported type). + +> **Put anything sensitive in a secret.** A plaintext variable's value is public. + +
+ +### `ms-hub studio` + +Browse Studio spaces and the runtime resources they can be configured with. + +```bash +# Browse +ms-hub studio list --search chat --sort likes --page-size 20 +ms-hub studio list --owner my-org --status all +ms-hub studio list --mcp-support --hardware-type xgpu + +# Discover valid values for --hardware / --base-image / --sdk-version +ms-hub studio hardware --sdk-type gradio +ms-hub studio hardware --studio my-org/demo +ms-hub studio base-images +ms-hub studio sdk-versions --sdk-type gradio + +# Runtime management (also available as the top-level deploy/stop/logs/settings) +ms-hub studio deploy my-org/demo +ms-hub studio logs my-org/demo --type build +ms-hub studio settings my-org/demo --visibility protected +``` + +
+Subcommands + +| Subcommand | Arguments | Key Options | +|------------|-----------|-------------| +| `list` | — | `--search`, `--owner`, `--sort {default,last_modified,view_num,likes}`, `--status {running,all}`, `--mcp-support`/`--no-mcp-support`, `--hardware-type {xgpu,amd}`, `--page`, `--page-size` | +| `hardware` | — | `--sdk-type`, `--studio owner/name` | +| `base-images` | — | — | +| `sdk-versions` | — | `--sdk-type` (default `gradio`; only gradio publishes versions) | +| `deploy` / `stop` | `studio_id` | — | +| `logs` | `studio_id` | `--type {run,build}`, `--keyword`, `--page`, `--page-size` (max 500) | +| `settings` | `studio_id [key=val...]` | `--display-name`, `--description`, `--license`, `--cover-image`, `--sdk-type`, `--sdk-version`, `--base-image`, `--hardware`, `--visibility`, `--private`/`--public` | +| `secret` | see above | — | +| `variable` | see above | — | + +`--visibility` accepts three values: `public` (code and app public), `protected` +(app public, code repository hidden) and `private`. `--private` / `--public` +remain as shorthands. + +Paid hardware tiers are selected as `--hardware paid/`; run +`ms-hub studio hardware` to see the available identifiers. + +`--status` defaults to `all` whenever `--owner` is given, so listing your own +spaces includes stopped ones. Without `--owner` the server's own default applies +(running spaces only).
@@ -484,6 +590,7 @@ Manage MCP (Model Context Protocol) servers. ```bash ms-hub mcp list --search weather --page-size 10 +ms-hub mcp list --hosted # only your own hosted servers, with live URLs ms-hub mcp info my-org/weather-mcp ms-hub mcp deploy my-org/weather-mcp ms-hub mcp undeploy my-org/weather-mcp @@ -494,11 +601,17 @@ ms-hub mcp undeploy my-org/weather-mcp | Subcommand | Arguments | Key Options | |------------|-----------|-------------| -| `list` | — | `--search`, `--page`, `--page-size` | +| `list` | — | `--search`, `--hosted`, `--page`, `--page-size` | | `info` | `server_id` | — | -| `deploy` | `server_id` | — | +| `deploy` | `server_id` | `--transport-type`, `--expiration-minutes`, `--auth-check`, `--env KEY=VALUE` | | `undeploy` | `server_id` | — | +`--hosted` lists the servers you currently have hosted along with their +`operational_urls`. It takes no search or paging options, because the underlying +endpoint accepts none. + +> Discovery is capped at `page * page_size <= 100` by the service. +
### `ms-hub cache` @@ -598,7 +711,7 @@ api = HubApi(token="...", endpoint="https://modelscope.ai") | | `whoami()` | Get current user info | | **Repo** | `create_repo(repo_id, repo_type, ...)` | Create a repository | | | `get_repo(repo_id, repo_type)` | Get repository metadata | -| | `list_repos(repo_type, ...)` | Paginated listing | +| | `list_repos(repo_type, ...)` | Paginated listing (`model`, `dataset`, `studio`, `skill`, `mcp`) | | | `delete_repo(repo_id, repo_type)` | Delete a repository *(deprecated — see note below)* | | | `repo_exists(repo_id, repo_type)` | Check existence | | **Files** | `upload_file(repo_id, repo_type, local, remote)` | Upload a single file | @@ -614,10 +727,18 @@ api = HubApi(token="...", endpoint="https://modelscope.ai") | | `get_repo_logs(repo_id, ...)` | Fetch logs | | | `update_repo_settings(repo_id, repo_type, ...)` | Update settings | | **Secrets** | `add_secret(repo_id, key, value)` | Add a secret | -| | `list_secrets(repo_id)` | List secrets | +| | `list_secrets(repo_id)` | List secret keys (values are never returned) | | | `update_secret(repo_id, key, value)` | Update a secret | | | `delete_secret(repo_id, key)` | Delete a secret | +| **Variables** | `add_variable(repo_id, key, value)` | Add a plaintext variable *(value is public)* | +| | `list_variables(repo_id)` | List variables with their values | +| | `update_variable(repo_id, key, value)` | Update a variable | +| | `delete_variable(repo_id, key)` | Delete a variable | +| **Studio resources** | `list_studio_hardware(...)` | Hardware tiers a Studio can run on | +| | `list_studio_base_images()` | Available base images | +| | `list_studio_sdk_versions(...)` | Available SDK versions | | **MCP** | `list_mcp_servers(...)` | List available MCP servers | +| | `list_operational_mcp_servers()` | List your hosted servers, with live URLs | | | `get_mcp_server(server_id)` | Get server details | | | `deploy_mcp_server(server_id)` | Deploy an MCP server | | | `undeploy_mcp_server(server_id)` | Undeploy an MCP server | @@ -628,6 +749,11 @@ api = HubApi(token="...", endpoint="https://modelscope.ai") > - `delete_repo` is deprecated for security reasons (emits `DeprecationWarning`). Will be restored with token-scoped auth. Use the [web console](https://modelscope.cn) instead. > - `delete_files` requires cookie-based session auth; API tokens (`ms-...`) may receive a 401 error. +> **Token permission levels:** every write method needs a token issued with +> `write` permission or higher. A rejected write raises `PermissionDeniedError` +> whose `suggestion` names the required level; an exhausted quota raises +> `QuotaExceededError` instead. See [Token permission levels](#token-permission-levels). + --- diff --git a/src/modelscope_hub/__init__.py b/src/modelscope_hub/__init__.py index 8b3641a..342d70d 100644 --- a/src/modelscope_hub/__init__.py +++ b/src/modelscope_hub/__init__.py @@ -12,7 +12,7 @@ from ._download import ProgressCallback, TqdmCallback from .api import HubApi from .config import HubConfig, get_default_config, set_default_config -from .constants import License, RepoType, Visibility +from .constants import License, RepoType, StudioVisibility, TokenScope, Visibility from .errors import ( APIError, AuthenticationError, @@ -28,6 +28,7 @@ NotSupportedError, PermissionDeniedError, PermissionError, + QuotaExceededError, RateLimitError, RequestTimeoutError, ServerError, @@ -58,6 +59,8 @@ # Enums "License", "RepoType", + "StudioVisibility", + "TokenScope", "Visibility", # Progress callbacks "ProgressCallback", @@ -85,6 +88,7 @@ "NotExistError", "NotSupportedError", "PermissionDeniedError", + "QuotaExceededError", "RateLimitError", "RequestTimeoutError", "ServerError", diff --git a/src/modelscope_hub/_openapi.py b/src/modelscope_hub/_openapi.py index 198007e..30592f7 100644 --- a/src/modelscope_hub/_openapi.py +++ b/src/modelscope_hub/_openapi.py @@ -6,11 +6,16 @@ Design goals ------------ -* A single :meth:`OpenAPIClient._request` chokepoint owns transport concerns — +* A single :meth:`OpenAPIClient._send` chokepoint owns transport concerns — URL composition, authentication injection, retry/back-off, error decoding - and ``data`` envelope unwrapping. -* Each of the 25 OpenAPI endpoints maps to exactly one method, named after the - resource it manipulates and grouped by section comments. + and ``data`` envelope unwrapping. :meth:`OpenAPIClient._request` wraps it with + the authorisation policy (required token tier, anonymous fallback) and is what + the endpoint methods call. +* Each OpenAPI endpoint maps to exactly one method, named after the resource it + manipulates and grouped by section comments. :data:`OPERATION_REGISTRY` records + that mapping for the tags this client covers in full, and a test checks it + against the vendored specification so a newly published endpoint cannot go + unnoticed. * Filter-style query parameters (``filter.task=...`` etc.) are accepted as a flat ``filters`` mapping and serialised transparently. """ @@ -27,7 +32,7 @@ import requests from .config import HubConfig, get_default_config -from .constants import API_CONNECT_TIMEOUT, API_MAX_RETRIES, API_TIMEOUT, OPENAPI_PREFIX +from .constants import API_CONNECT_TIMEOUT, API_MAX_RETRIES, API_TIMEOUT, OPENAPI_PREFIX, TokenScope from .errors import ( APIError, AuthenticationError, @@ -48,7 +53,7 @@ ) from .utils.logger import get_logger -__all__ = ["OpenAPIClient"] +__all__ = ["OPERATION_REGISTRY", "OpenAPIClient"] _logger = get_logger("openapi") @@ -65,11 +70,87 @@ RateLimitError, ) +# Where a user checks which permission tier their token was issued with. +_TOKEN_SETTINGS_URL = "https://modelscope.cn/my/myaccesstoken" + +# Closed value sets published by the Studios section of the specification. +# Validating client-side turns a silently-ignored typo into a named error. +_STUDIO_SORTS: tuple[str, ...] = ("default", "last_modified", "view_num", "likes") +_STUDIO_STATUS_FILTERS: tuple[str, ...] = ("running", "all") +_STUDIO_HARDWARE_TYPES: tuple[str, ...] = ("xgpu", "amd") +_STUDIO_SDK_TYPES: tuple[str, ...] = ("gradio", "streamlit", "docker", "static") +_STUDIO_LOG_TYPES: tuple[str, ...] = ("build", "run") + +# ``GET /studios/{owner}/{repo}/logs/{type}`` caps page_size at 500. +_STUDIO_LOG_MAX_PAGE_SIZE = 500 + JSON = dict[str, Any] QueryParams = list[tuple[str, str]] Filters = Mapping[str, str | int | float | bool] | None +def _validate_choice(name: str, value: str | None, allowed: tuple[str, ...]) -> None: + """Reject a value the endpoint's enum does not accept. + + The server ignores an unknown enum value rather than complaining, which turns + a typo in e.g. ``sort`` into silently wrong results. Failing here names the + parameter and lists what it accepts. + """ + if value is not None and value not in allowed: + raise InvalidParameter(f"{name} must be one of {', '.join(allowed)} (got {value!r}).") + + +def _as_wire_bool(value: bool | None) -> str | None: + """Render a tri-state flag the way query strings expect (``true``/``false``).""" + if value is None: + return None + return "true" if value else "false" + + +# --------------------------------------------------------------------------- +# Operation registry +# +# Maps each specification ``operationId`` to the method implementing it and the +# minimum token permission tier it needs. Two jobs: +# +# 1. ``tests/test_openapi_coverage.py`` checks it against the vendored spec, so a +# newly published operation fails the suite by name instead of going unnoticed +# -- which is exactly how the gaps this table closes accumulated. +# 2. It documents the required tier per operation in one place, since the spec +# itself declares no scopes. +# +# Only the tags the SDK claims to cover are listed; the rest are enumerated as +# deferred in that test. +# --------------------------------------------------------------------------- +OPERATION_REGISTRY: dict[str, tuple[str, TokenScope]] = { + # -- MCP ---------------------------------------------------------------- + "listMcpServers": ("list_mcp_servers", TokenScope.READ), + "listOperationalMcpServers": ("list_operational_mcp_servers", TokenScope.READ), + "getMcpServer": ("get_mcp_server", TokenScope.READ), + "deployMcpServer": ("deploy_mcp_server", TokenScope.WRITE), + "undeployMcpServer": ("undeploy_mcp_server", TokenScope.WRITE), + # -- Studios ------------------------------------------------------------ + "listStudios": ("list_studios", TokenScope.READ), + "createStudio": ("create_studio", TokenScope.WRITE), + "getStudio": ("get_studio", TokenScope.READ), + "updateStudioSettings": ("update_studio_settings", TokenScope.WRITE), + "deployStudio": ("deploy_studio", TokenScope.WRITE), + "stopStudio": ("stop_studio", TokenScope.WRITE), + "getStudioLogs": ("get_studio_logs", TokenScope.READ), + "listHardware": ("list_studio_hardware", TokenScope.READ), + "listBaseImages": ("list_studio_base_images", TokenScope.READ), + "listSdkVersions": ("list_studio_sdk_versions", TokenScope.READ), + "listStudioSecrets": ("list_studio_secrets", TokenScope.READ), + "addStudioSecret": ("add_studio_secret", TokenScope.WRITE), + "updateStudioSecret": ("update_studio_secret", TokenScope.WRITE), + "deleteStudioSecret": ("delete_studio_secret", TokenScope.WRITE), + "listStudioVariables": ("list_studio_variables", TokenScope.READ), + "addStudioVariable": ("add_studio_variable", TokenScope.WRITE), + "updateStudioVariable": ("update_studio_variable", TokenScope.WRITE), + "deleteStudioVariable": ("delete_studio_variable", TokenScope.WRITE), +} + + class OpenAPIClient: """Thin, typed wrapper around the public ``/openapi/v1`` endpoints. @@ -102,6 +183,9 @@ def __init__( float(timeout) if timeout is not None else (float(API_CONNECT_TIMEOUT), float(API_TIMEOUT)) ) self._max_retries = int(max_retries) if max_retries is not None else int(API_MAX_RETRIES) + # Tri-state cache for whether this deployment serves ``GET /mcp/servers``. + # ``None`` means "not probed yet"; see :meth:`list_mcp_servers`. + self._mcp_list_supports_get: bool | None = None # ------------------------------------------------------------------ # Lifecycle @@ -267,11 +351,95 @@ def _request( anonymous: bool = False, unwrap: bool = True, timeout: float | None = None, + required_scope: TokenScope | None = None, + anonymous_retry: bool = False, ) -> Any: - """Execute an HTTP request and return the unwrapped ``data`` payload. + """Execute a request, then interpret authorisation failures. + + Wraps :meth:`_send` (which owns transport, retries and envelope + decoding) with the two behaviours that depend on *why* a call was made + rather than *how*: + + *required_scope* + The minimum token permission tier the endpoint needs. Purely + advisory -- the Hub does not publish a token's tier, so nothing can + be pre-validated; on a 403 it turns "permission denied" into a + message that names the missing tier. + *anonymous_retry* + For endpoints whose content is public, retry once without + credentials when the token is rejected. A read-scoped or stale token + must not be able to hide data that any anonymous caller can see. + Never enable this for account-private data: degrading to anonymous + there would answer "empty" or "not found" and bury the real cause. + """ + try: + return self._send( + method, + path, + url=url, + params=params, + json_body=json_body, + data=data, + files=files, + headers=headers, + require_token=require_token, + anonymous=anonymous, + unwrap=unwrap, + timeout=timeout, + ) + except (AuthenticationError, PermissionDeniedError) as exc: + if anonymous_retry and not anonymous and self._resolve_token(): + _logger.debug( + "Credentials rejected for %s %s (%s); retrying anonymously", + method.upper(), + url or path, + exc.__class__.__name__, + ) + return self._send( + method, + path, + url=url, + params=params, + json_body=json_body, + data=data, + files=files, + headers=headers, + require_token=False, + anonymous=True, + unwrap=unwrap, + timeout=timeout, + ) + if required_scope is not None and isinstance(exc, PermissionDeniedError): + # Shadow the class-level suggestion on this instance only, so the + # error code, status, request id and traceback all stay intact. + exc.suggestion = ( + f"This operation requires a token with '{required_scope.value}' permission. " + f"Verify the token's permission level at {_TOKEN_SETTINGS_URL}" + ) + raise - The method centralises authentication, retries on transient errors, - and decoding of the standard ``{"success": ..., "data": ...}`` envelope. + def _send( + self, + method: str, + path: str = "", + *, + url: str | None = None, + params: Mapping[str, Any] | QueryParams | None = None, + json_body: Any | None = None, + data: Any | None = None, + files: Any | None = None, + headers: Mapping[str, str] | None = None, + require_token: bool = True, + anonymous: bool = False, + unwrap: bool = True, + timeout: float | None = None, + ) -> Any: + """Send one logical request and return the unwrapped ``data`` payload. + + Owns the transport concerns only: authentication injection, retries on + transient errors, and decoding of the standard + ``{"success": ..., "data": ...}`` envelope. Authorisation policy lives in + :meth:`_request`, which is what callers should use. When *url* is given, it is used as-is (absolute URL). Otherwise the final URL is derived from *path* via :meth:`_url`. @@ -584,13 +752,129 @@ def update_skill_settings( # ================================================================== # Studios # ================================================================== + def list_studios( + self, + *, + search: str | None = None, + owner: str | None = None, + sort: str | None = None, + page_number: int = 1, + page_size: int = 10, + status: str | None = None, + mcp_support: bool | None = None, + hardware_type: str | None = None, + ) -> JSON: + """``GET /studios`` — list Studio spaces with pagination and filters. + + Note on *status*: the server defaults to ``running`` for a plain search + but to ``all`` when ``owner`` is set. That switch is deliberately left to + the server rather than second-guessed here, so passing nothing yields + whichever default the endpoint considers correct for the query. + + Unlike the other list endpoints this one answers *without* the + ``{"success": ..., "data": ...}`` envelope -- ``studios`` sits at the top + level of the response body. + """ + if page_number * page_size > 3000: + raise InvalidParameter(f"page_number * page_size must be <= 3000 (got {page_number * page_size}).") + _validate_choice("sort", sort, _STUDIO_SORTS) + _validate_choice("status", status, _STUDIO_STATUS_FILTERS) + _validate_choice("hardware_type", hardware_type, _STUDIO_HARDWARE_TYPES) + params = self._merge_params( + { + "search": search, + "owner": owner, + "sort": sort, + "page_number": page_number, + "page_size": page_size, + "status": status, + "mcp_support": _as_wire_bool(mcp_support), + "hardware_type": hardware_type, + } + ) + return self._request( + "GET", + "/studios", + params=params, + require_token=False, + required_scope=TokenScope.READ, + anonymous_retry=True, + ) + + def list_studio_hardware( + self, + *, + sdk_type: str | None = None, + studio: str | None = None, + ) -> JSON: + """``GET /studios/hardware`` — hardware tiers available to the caller. + + Anonymous callers get the default free tier; an authenticated caller also + gets the paid tiers with prices. Pass *studio* (``owner/repo_name``) to + scope the free tiers to what that space may actually use. Paid resources + are selected as ``paid/`` in the ``hardware`` field of + :meth:`create_studio` / :meth:`update_studio_settings`. + """ + _validate_choice("sdk_type", sdk_type, _STUDIO_SDK_TYPES) + params = self._merge_params({"sdk_type": sdk_type, "studio": studio}) + return self._request( + "GET", + "/studios/hardware", + params=params, + require_token=False, + required_scope=TokenScope.READ, + anonymous_retry=True, + ) + + def list_studio_base_images(self) -> JSON: + """``GET /studios/base-images`` — base images available to Studio spaces.""" + return self._request( + "GET", + "/studios/base-images", + require_token=False, + required_scope=TokenScope.READ, + anonymous_retry=True, + ) + + def list_studio_sdk_versions(self, *, sdk_type: str | None = None) -> JSON: + """``GET /studios/sdk-versions`` — SDK versions available to Studio spaces. + + Only ``sdk_type="gradio"`` yields versions; every other SDK type (and + omitting it) returns an empty list. + """ + _validate_choice("sdk_type", sdk_type, _STUDIO_SDK_TYPES) + params = self._merge_params({"sdk_type": sdk_type}) + return self._request( + "GET", + "/studios/sdk-versions", + params=params, + require_token=False, + required_scope=TokenScope.READ, + anonymous_retry=True, + ) + def create_studio(self, payload: CreateStudioPayload | Mapping[str, Any]) -> JSON: """``POST /studios`` — create a new Studio space.""" - return self._request("POST", "/studios", json_body=dict(payload)) + return self._request( + "POST", + "/studios", + json_body=dict(payload), + required_scope=TokenScope.WRITE, + ) def get_studio(self, owner: str, repo_name: str) -> JSON: - """``GET /studios/{owner}/{repo_name}`` — fetch Studio metadata.""" - return self._request("GET", f"/studios/{owner}/{repo_name}") + """``GET /studios/{owner}/{repo_name}`` — fetch Studio metadata. + + Public and experience-public (``protected``) spaces are readable without + credentials, so no token is demanded up front. + """ + return self._request( + "GET", + f"/studios/{owner}/{repo_name}", + require_token=False, + required_scope=TokenScope.READ, + anonymous_retry=True, + ) def deploy_studio( self, @@ -598,16 +882,27 @@ def deploy_studio( repo_name: str, payload: Mapping[str, Any] | None = None, ) -> JSON: - """``POST /studios/{owner}/{repo_name}/deploy`` — trigger a deployment.""" + """``POST /studios/{owner}/{repo_name}/deploy`` — trigger a deployment. + + The specification defines no request body for this operation; *payload* + is forwarded when given only so that callers written against an older + server keep working. + """ return self._request( "POST", f"/studios/{owner}/{repo_name}/deploy", json_body=dict(payload) if payload else None, + required_scope=TokenScope.WRITE, ) def stop_studio(self, owner: str, repo_name: str) -> JSON: """``POST /studios/{owner}/{repo_name}/stop`` — stop a running Studio.""" - return self._request("POST", f"/studios/{owner}/{repo_name}/stop", json_body=None) + return self._request( + "POST", + f"/studios/{owner}/{repo_name}/stop", + json_body=None, + required_scope=TokenScope.WRITE, + ) def get_studio_logs( self, @@ -621,7 +916,14 @@ def get_studio_logs( start_timestamp: int | None = None, end_timestamp: int | None = None, ) -> JSON: - """``GET /studios/{owner}/{repo_name}/logs/{log_type}`` — paginated logs.""" + """``GET /studios/{owner}/{repo_name}/logs/{log_type}`` — paginated logs. + + The response payload carries ``logs``, ``page_num``, ``page_size``, + ``total_count`` and ``total_page_num``. + """ + _validate_choice("log_type", log_type, _STUDIO_LOG_TYPES) + if page_size > _STUDIO_LOG_MAX_PAGE_SIZE: + raise InvalidParameter(f"page_size must be <= {_STUDIO_LOG_MAX_PAGE_SIZE} (got {page_size}).") params = self._merge_params( { "page_num": page_num, @@ -635,11 +937,20 @@ def get_studio_logs( "GET", f"/studios/{owner}/{repo_name}/logs/{log_type}", params=params, + required_scope=TokenScope.READ, ) def list_studio_secrets(self, owner: str, repo_name: str) -> JSON: - """``GET /studios/{owner}/{repo_name}/secrets`` — list configured secrets.""" - return self._request("GET", f"/studios/{owner}/{repo_name}/secrets") + """``GET /studios/{owner}/{repo_name}/secrets`` — list secret keys. + + Only the keys are returned; values are never disclosed. Use + :meth:`list_studio_variables` for the plaintext counterpart. + """ + return self._request( + "GET", + f"/studios/{owner}/{repo_name}/secrets", + required_scope=TokenScope.READ, + ) def add_studio_secret(self, owner: str, repo_name: str, key: str, value: str) -> JSON: """``POST /studios/{owner}/{repo_name}/secrets`` — add a new secret.""" @@ -647,6 +958,7 @@ def add_studio_secret(self, owner: str, repo_name: str, key: str, value: str) -> "POST", f"/studios/{owner}/{repo_name}/secrets", json_body={"key": key, "value": value}, + required_scope=TokenScope.WRITE, ) def update_studio_secret(self, owner: str, repo_name: str, key: str, value: str) -> JSON: @@ -655,6 +967,7 @@ def update_studio_secret(self, owner: str, repo_name: str, key: str, value: str) "PUT", f"/studios/{owner}/{repo_name}/secrets", json_body={"key": key, "value": value}, + required_scope=TokenScope.WRITE, ) def delete_studio_secret(self, owner: str, repo_name: str, key: str) -> JSON: @@ -663,6 +976,53 @@ def delete_studio_secret(self, owner: str, repo_name: str, key: str) -> JSON: "DELETE", f"/studios/{owner}/{repo_name}/secrets", json_body={"key": key}, + required_scope=TokenScope.WRITE, + ) + + # -- Plaintext variables -------------------------------------------- + # Mirrors the secrets block above one-for-one. The only difference is + # disclosure: a variable's value is public, a secret's never is. + def list_studio_variables(self, owner: str, repo_name: str) -> JSON: + """``GET /studios/{owner}/{repo_name}/variables`` — list plaintext variables. + + Both keys and values are returned, because unlike secrets the values of + plaintext variables are publicly visible. + """ + return self._request( + "GET", + f"/studios/{owner}/{repo_name}/variables", + required_scope=TokenScope.READ, + ) + + def add_studio_variable(self, owner: str, repo_name: str, key: str, value: str) -> JSON: + """``POST /studios/{owner}/{repo_name}/variables`` — add a plaintext variable. + + Both key and value are publicly visible; use + :meth:`add_studio_secret` for anything sensitive. + """ + return self._request( + "POST", + f"/studios/{owner}/{repo_name}/variables", + json_body={"key": key, "value": value}, + required_scope=TokenScope.WRITE, + ) + + def update_studio_variable(self, owner: str, repo_name: str, key: str, value: str) -> JSON: + """``PUT /studios/{owner}/{repo_name}/variables`` — overwrite a plaintext variable.""" + return self._request( + "PUT", + f"/studios/{owner}/{repo_name}/variables", + json_body={"key": key, "value": value}, + required_scope=TokenScope.WRITE, + ) + + def delete_studio_variable(self, owner: str, repo_name: str, key: str) -> JSON: + """``DELETE /studios/{owner}/{repo_name}/variables`` — remove a variable by key.""" + return self._request( + "DELETE", + f"/studios/{owner}/{repo_name}/variables", + json_body={"key": key}, + required_scope=TokenScope.WRITE, ) def update_studio_settings( @@ -671,11 +1031,17 @@ def update_studio_settings( repo_name: str, settings: UpdateStudioSettingsPayload | Mapping[str, Any], ) -> JSON: - """``PATCH /studios/{owner}/{repo_name}/settings`` — update Studio settings.""" + """``PATCH /studios/{owner}/{repo_name}/settings`` — update Studio settings. + + Only the fields present in *settings* are modified. Changes to + ``sdk_type`` / ``sdk_version`` / ``base_image`` / ``hardware`` take effect + on the next deployment. + """ return self._request( "PATCH", f"/studios/{owner}/{repo_name}/settings", json_body=dict(settings), + required_scope=TokenScope.WRITE, ) # ================================================================== @@ -706,16 +1072,17 @@ def list_mcp_servers( *, search: str | None = None, page_number: int = 1, - page_size: int = 10, + page_size: int = 20, filter: Mapping[str, Any] | None = None, extra: Mapping[str, Any] | None = None, ) -> JSON: - """``GET /mcp/servers`` — discover MCP servers. + """``PUT /mcp/servers`` — discover MCP servers. - Falls back to the historical ``PUT /mcp/servers`` endpoint while the - service rolls out GET support. If an optional token is rejected by the - legacy PUT list endpoint, retry anonymously so readonly or stale tokens - do not block public MCP discovery. + ``PUT`` is the only method the specification defines for this route, so it + is tried first. A ``GET`` variant is probed only after a ``PUT`` failure, + because probing first cost every single call a wasted round trip to a 404. + Whichever verb a deployment turns out to serve is remembered for the life + of this client, so the loser is attempted at most once. Parameters ---------- @@ -723,6 +1090,9 @@ def list_mcp_servers( Nested filter object. Supported keys: ``category``, ``is_hosted``. """ if page_number * page_size > 100: + # The service enforces this itself, answering 403 QuotaLimitExceed with + # exactly this rule. Checking here spares the round trip and reports it + # as what it is -- a caller parameter mistake, not an exhausted quota. raise InvalidParameter( f"page_number * page_size must be <= 100 for MCP servers (got {page_number * page_size})." ) @@ -736,30 +1106,58 @@ def list_mcp_servers( if extra: body.update(extra) body = {k: v for k, v in body.items() if v is not None} - params = self._flatten_mcp_list_params(body) - has_token = self._resolve_token() is not None - try: - return self._request("GET", "/mcp/servers", params=params, require_token=False) - except APIError as exc: - if not self._is_method_or_route_unsupported(exc): - raise + + def _get() -> JSON: + return self._request( + "GET", + "/mcp/servers", + params=self._flatten_mcp_list_params(body), + require_token=False, + required_scope=TokenScope.READ, + anonymous_retry=True, + ) + + if self._mcp_list_supports_get: + # This deployment already answered GET and refused PUT, so leading + # with PUT again would just repeat a known 404 on every call. + return _get() try: - return self._request("PUT", "/mcp/servers", json_body=body, require_token=False) - except (AuthenticationError, PermissionDeniedError): - if not has_token: - raise return self._request( "PUT", "/mcp/servers", json_body=body, require_token=False, - anonymous=True, + required_scope=TokenScope.READ, + anonymous_retry=True, ) + except APIError as exc: + if self._mcp_list_supports_get is False or not self._is_method_or_route_unsupported(exc): + raise + + try: + data = _get() + except APIError as exc: + if self._is_method_or_route_unsupported(exc): + # Serves neither verb: stop probing GET on subsequent calls. + self._mcp_list_supports_get = False + raise + # Serves GET but not PUT: skip the PUT attempt from now on. + self._mcp_list_supports_get = True + return data def list_operational_mcp_servers(self) -> JSON: - """``GET /mcp/servers/operational`` — list servers deployed by the caller.""" - return self._request("GET", "/mcp/servers/operational") + """``GET /mcp/servers/operational`` — list servers deployed by the caller. + + Answers with the caller's own hosting, so no anonymous fallback: degrading + to an anonymous request would report "nothing deployed" for what is really + a credential problem. + """ + return self._request( + "GET", + "/mcp/servers/operational", + required_scope=TokenScope.READ, + ) def get_mcp_server( self, @@ -768,8 +1166,15 @@ def get_mcp_server( get_operational_url: bool | None = None, ) -> JSON: """``GET /mcp/servers/{id}`` — fetch a single MCP server's manifest.""" - params = self._merge_params({"get_operational_url": get_operational_url}) - return self._request("GET", f"/mcp/servers/{server_id}", params=params, require_token=False) + params = self._merge_params({"get_operational_url": _as_wire_bool(get_operational_url)}) + return self._request( + "GET", + f"/mcp/servers/{server_id}", + params=params, + require_token=False, + required_scope=TokenScope.READ, + anonymous_retry=True, + ) def deploy_mcp_server( self, @@ -785,11 +1190,16 @@ def deploy_mcp_server( "POST", f"/mcp/servers/{server_id}/deploy", json_body=body, + required_scope=TokenScope.WRITE, ) def undeploy_mcp_server(self, server_id: str | int) -> JSON: """``DELETE /mcp/servers/{id}/undeploy`` — tear down a deployed MCP server.""" - return self._request("DELETE", f"/mcp/servers/{server_id}/undeploy") + return self._request( + "DELETE", + f"/mcp/servers/{server_id}/undeploy", + required_scope=TokenScope.WRITE, + ) # Re-export iterables-of-strings helper for parity with other modules that may diff --git a/src/modelscope_hub/api.py b/src/modelscope_hub/api.py index 226953e..fd22be0 100644 --- a/src/modelscope_hub/api.py +++ b/src/modelscope_hub/api.py @@ -39,7 +39,7 @@ from ._openapi import OpenAPIClient from ._upload import UploadManager from .config import HubConfig, get_default_config -from .constants import DEFAULT_ENDPOINT, RepoType, Visibility +from .constants import DEFAULT_ENDPOINT, RepoType, StudioVisibility, Visibility from .errors import ( AuthenticationError, HubError, @@ -47,6 +47,7 @@ NetworkError, NotExistError, NotSupportedError, + PermissionDeniedError, ) from .types import CacheInfo, CacheVerification, FileInfo, PagedResult, RepoInfo, UserInfo from .utils.logger import get_logger @@ -87,8 +88,12 @@ } -_STUDIO_FIELD_RENAMES: dict[str, str] = { - "cover_image": "coverImage", +# Studio payload keys the SDK once renamed on the way out. The specification +# spells the field ``cover_image``, so the old outbound rename silently dropped +# every cover image the caller supplied; the mapping now runs the other way, to +# normalise callers still passing the camelCase spelling. +_STUDIO_FIELD_ALIASES: dict[str, str] = { + "coverImage": "cover_image", } # Reserved fields that are controlled by create_repo method parameters. @@ -96,6 +101,36 @@ _RESERVED_EXTRA_FIELDS: frozenset[str] = frozenset({"Path", "Owner", "Name"}) +def _unwrap_list(payload: Any, keys: tuple[str, ...]) -> list[dict]: + """Pull a list out of an OpenAPI payload, tolerating either shape. + + Some endpoints answer with the collection at the top level and others nest it + under a resource-specific key, so both are accepted rather than assuming one. + """ + if isinstance(payload, list): + return payload + if isinstance(payload, dict): + for key in keys: + if isinstance(payload.get(key), list): + return payload[key] + return [] + + +def _normalise_studio_fields(fields: Mapping[str, Any]) -> dict[str, Any]: + """Map legacy Studio field spellings onto the ones the endpoint accepts. + + Also expands ``visibility`` into the ``private`` companion flag so a caller + who reaches the Studio endpoints through ``**settings`` gets the same + treatment as one who passes ``visibility=`` explicitly. + """ + normalised = {_STUDIO_FIELD_ALIASES.get(key, key): value for key, value in fields.items()} + studio_visibility = StudioVisibility.parse(normalised.get("visibility")) + if studio_visibility is not None: + normalised["visibility"] = studio_visibility.value + normalised.setdefault("private", studio_visibility is StudioVisibility.PRIVATE) + return normalised + + class HubApi: """Unified client for ModelScope Hub operations. @@ -269,6 +304,8 @@ def _normalize_visibility(visibility: int | str | Visibility | None) -> int | No "models", "datasets", "skills", + "studios", + "variables", "servers", "mcp_server_list", "Models", @@ -367,7 +404,20 @@ def _repo_info_from_payload( # The OpenAPI surface uses ``private`` bool for visibility. # gated is orthogonal and does not affect visibility mapping. - if normalised.get("visibility") is None: + raw_visibility = normalised.get("visibility") + if isinstance(raw_visibility, str): + # Studio payloads report visibility as a string enum. ``public`` and + # ``private`` have integer equivalents; ``protected`` (app public, + # code hidden) does not, so it is preserved verbatim rather than + # forced into a value that would misrepresent it. + studio_visibility = StudioVisibility.parse(raw_visibility) + if studio_visibility is StudioVisibility.PUBLIC: + normalised["visibility"] = Visibility.PUBLIC + elif studio_visibility is StudioVisibility.PRIVATE: + normalised["visibility"] = Visibility.PRIVATE + elif studio_visibility is not None: + normalised["visibility"] = studio_visibility.value + elif raw_visibility is None: private_flag = normalised.get("private") if isinstance(private_flag, bool): if private_flag: @@ -499,6 +549,15 @@ def login(self, token: str) -> UserInfo: caller's only working one, so revoking it on failure would turn a mistyped token into an unintended logout. + Tokens are issued with a permission tier (read / write / admin). The + legacy login endpoint exists to mint git credentials, which a read-only + token is not entitled to -- yet such a token authenticates fine and is + all a caller needs in order to browse and download. So when that + endpoint rejects the credential, the token is re-checked against + ``GET /users/me`` before concluding it is bad: if that succeeds the + login completes without cookies or a git token, and warns that write + operations will need a higher tier. + Examples -------- >>> api = HubApi() @@ -520,6 +579,15 @@ def login(self, token: str) -> UserInfo: try: data, cookies = self.legacy.login(token) + except (AuthenticationError, PermissionDeniedError) as exc: + scoped = self._login_with_scoped_token(token) + if scoped is not None: + return scoped + self._restore_credential_state(previous_token, previous_logged_out) + explained = self._explain_login_failure(token, exc) + if explained is exc: + raise + raise explained from exc except HubError as exc: self._restore_credential_state(previous_token, previous_logged_out) explained = self._explain_login_failure(token, exc) @@ -534,11 +602,54 @@ def login(self, token: str) -> UserInfo: self._config.save_cookies(cookies) if git_token: self._config.save_git_token(git_token) + else: + logger.debug("Login returned no git access token; git-based operations will be unavailable.") if username: self._config.save_user_info(username, email or "") return self.whoami() + def _login_with_scoped_token(self, token: str) -> UserInfo | None: + """Complete a reduced-capability login, or return ``None`` if impossible. + + Called only when the legacy login endpoint refused the credential, at + which point *token* is already installed on the config -- so a successful + ``GET /users/me`` proves the token itself is valid, and the refusal was + about the permission tier rather than the token. Reporting that as + "invalid token" would send the caller after the wrong remedy. + + The token has to be persisted explicitly here. On the normal path it is + stored as a side effect of saving the server-issued session cookies, + which this tier does not receive -- without this the login would appear + to succeed yet leave nothing on disk for the next process to find. + + Like :meth:`_token_valid_on`, the probe is advisory: retries are disabled + so a dead network cannot stall the error report, and any exception simply + means "cannot confirm", which hands the original rejection back untouched. + """ + from .constants import API_CONNECT_TIMEOUT + + probe = OpenAPIClient(self._config, timeout=API_CONNECT_TIMEOUT, max_retries=0) + try: + payload = probe.get_current_user() + except Exception: # advisory only -- never mask the original failure + return None + finally: + probe.close() + + profile = UserInfo.from_dict(payload if isinstance(payload, dict) else {}) + if not profile.username: + return None + logger.warning( + "Token accepted with reduced permissions: signed in as %s, but git and session " + "credentials were not issued. Uploads, pushes and other write operations need a " + "token with 'write' permission or higher.", + profile.username, + ) + self._config.save_token(token) + self._config.save_user_info(profile.username, profile.email or "") + return profile + def _restore_credential_state(self, token: str | None, logged_out: bool) -> None: """Roll the in-memory credential back to its pre-login value. @@ -749,7 +860,8 @@ def create_repo( f"create_repo does not support repo_type={rt.value!r}. Supported types: {supported}." ) owner, name = self._parse_repo_id(repo_id) - vis = self._normalize_visibility(visibility) + studio_visibility = StudioVisibility.parse(visibility) if rt is RepoType.STUDIO else None + vis = None if studio_visibility is not None else self._normalize_visibility(visibility) if license is not None: license = _LICENSE_DISPLAY_TO_SPDX.get(license, license) @@ -760,7 +872,14 @@ def create_repo( "owner": owner, "repo_name": name, } - if vis is not None: + if studio_visibility is not None: + # ``visibility`` is the expressive form (it is the only way to + # ask for ``protected``); ``private`` is sent alongside it for + # servers that predate the tri-state field. The two are + # consistent by construction, so they cannot disagree. + payload["visibility"] = studio_visibility.value + payload["private"] = studio_visibility is StudioVisibility.PRIVATE + elif vis is not None: payload["private"] = is_private if chinese_name is not None: payload["display_name"] = chinese_name @@ -777,10 +896,10 @@ def create_repo( payload["license"] = license if description is not None: payload["description"] = description - for old_key, new_key in _STUDIO_FIELD_RENAMES.items(): - if old_key in extra: - extra[new_key] = extra.pop(old_key) - payload.update(extra) + # Only Studios get the Studio field normalisation: a Skill has no + # cover image and no visibility field, so rewriting those keys on a + # Skill payload would invent fields its endpoint does not accept. + payload.update(_normalise_studio_fields(extra) if rt is RepoType.STUDIO else extra) data = self.openapi.create_studio(payload) if rt is RepoType.STUDIO else self.openapi.create_skill(payload) return self._repo_info_from_payload(data, rt, owner_hint=owner, name_hint=name) @@ -915,8 +1034,7 @@ def list_repos( Parameters ---------- repo_type : str or RepoType - One of ``"model"``, ``"dataset"``, ``"skill"``, ``"mcp"``. - ``"studio"`` raises :class:`NotSupportedError` (no list endpoint). + One of ``"model"``, ``"dataset"``, ``"studio"``, ``"skill"``, ``"mcp"``. owner : str, optional Restrict results to repositories owned by this user/org. search : str, optional @@ -938,7 +1056,7 @@ def list_repos( Raises ------ NotSupportedError - When ``repo_type`` is ``"studio"`` (no list endpoint yet). + When ``repo_type`` is not one of the listed values. Examples -------- @@ -992,7 +1110,14 @@ def list_repos( filter=clean_filters or None, ) elif rt is RepoType.STUDIO: - raise NotSupportedError("Listing studios is not supported by the OpenAPI surface yet.") + payload = self.openapi.list_studios( + search=search, + owner=owner, + sort=sort, + page_number=page_number, + page_size=page_size, + **clean_filters, + ) else: # pragma: no cover - defensive raise NotSupportedError(f"list_repos not supported for {rt}") @@ -1007,6 +1132,7 @@ def list_repos( RepoType.MODEL: "models", RepoType.DATASET: "datasets", RepoType.SKILL: "skills", + RepoType.STUDIO: "studios", RepoType.MCP: "servers", } key = _COLLECTION_KEYS.get(rt, "items") @@ -1805,11 +1931,8 @@ def update_repo_settings( """ rt = self._normalize_repo_type(repo_type) owner, name = self._parse_repo_id(repo_id) - for old_key, new_key in _STUDIO_FIELD_RENAMES.items(): - if old_key in settings: - settings[new_key] = settings.pop(old_key) if rt is RepoType.STUDIO: - return self.openapi.update_studio_settings(owner, name, settings) + return self.openapi.update_studio_settings(owner, name, _normalise_studio_fields(settings)) if rt is RepoType.SKILL: return self.openapi.update_skill_settings(owner, name, settings) raise NotSupportedError(f"update_repo_settings is not supported for repo_type={rt.value!r}.") @@ -1818,25 +1941,22 @@ def update_repo_settings( # Secrets # ================================================================== def list_secrets(self, repo_id: str, repo_type: RepoTypeLike = RepoType.STUDIO) -> list[dict]: - """List secrets attached to a Studio. + """List the keys of the secrets attached to a Studio. + + Values are never disclosed by the endpoint. Use :meth:`list_variables` + for the plaintext counterpart, whose values are returned. Examples -------- >>> api.list_secrets("alice/chat-demo") - [{'key': 'OPENAI_API_KEY', 'updated_at': 1712345678}, ...] + [{'key': 'OPENAI_API_KEY'}, ...] """ rt = self._normalize_repo_type(repo_type) if rt is not RepoType.STUDIO: raise NotSupportedError(f"Secret management is only supported for studio (got {rt.value!r}).") owner, name = self._parse_repo_id(repo_id) data = self.openapi.list_studio_secrets(owner, name) - if isinstance(data, list): - return data - if isinstance(data, dict): - for key in ("items", "secrets", "list"): - if isinstance(data.get(key), list): - return data[key] - return [] + return _unwrap_list(data, ("secrets", "items", "list")) def add_secret( self, @@ -1894,6 +2014,144 @@ def delete_secret( owner, name = self._parse_repo_id(repo_id) return self.openapi.delete_studio_secret(owner, name, key) + # ================================================================== + # Plaintext variables + # + # The counterpart to the secrets block above. Same shape, different + # disclosure: a variable's value is publicly readable, a secret's is not. + # ================================================================== + def list_variables(self, repo_id: str, repo_type: RepoTypeLike = RepoType.STUDIO) -> list[dict]: + """List plaintext environment variables attached to a Studio. + + Unlike :meth:`list_secrets`, the values are returned too -- plaintext + variables are publicly visible by design. + + Examples + -------- + >>> api.list_variables("alice/chat-demo") + [{'key': 'MODEL_NAME', 'value': 'Qwen2.5-7B'}, ...] + """ + rt = self._normalize_repo_type(repo_type) + if rt is not RepoType.STUDIO: + raise NotSupportedError(f"Variable management is only supported for studio (got {rt.value!r}).") + owner, name = self._parse_repo_id(repo_id) + data = self.openapi.list_studio_variables(owner, name) + return _unwrap_list(data, ("variables", "items", "list")) + + def add_variable( + self, + repo_id: str, + key: str, + value: str, + repo_type: RepoTypeLike = RepoType.STUDIO, + ) -> dict: + """Add a plaintext environment variable to a Studio. + + Both key and value are publicly visible; use :meth:`add_secret` for + anything sensitive. + + Examples + -------- + >>> api.add_variable("alice/chat-demo", "MODEL_NAME", "Qwen2.5-7B") + """ + rt = self._normalize_repo_type(repo_type) + if rt is not RepoType.STUDIO: + raise NotSupportedError("Only studio variables are supported.") + owner, name = self._parse_repo_id(repo_id) + return self.openapi.add_studio_variable(owner, name, key, value) + + def update_variable( + self, + repo_id: str, + key: str, + value: str, + repo_type: RepoTypeLike = RepoType.STUDIO, + ) -> dict: + """Update an existing plaintext variable's value. + + Examples + -------- + >>> api.update_variable("alice/chat-demo", "MODEL_NAME", "Qwen2.5-14B") + """ + rt = self._normalize_repo_type(repo_type) + if rt is not RepoType.STUDIO: + raise NotSupportedError("Only studio variables are supported.") + owner, name = self._parse_repo_id(repo_id) + return self.openapi.update_studio_variable(owner, name, key, value) + + def delete_variable( + self, + repo_id: str, + key: str, + repo_type: RepoTypeLike = RepoType.STUDIO, + ) -> dict: + """Delete a plaintext variable from a Studio. + + Examples + -------- + >>> api.delete_variable("alice/chat-demo", "MODEL_NAME") + """ + rt = self._normalize_repo_type(repo_type) + if rt is not RepoType.STUDIO: + raise NotSupportedError("Only studio variables are supported.") + owner, name = self._parse_repo_id(repo_id) + return self.openapi.delete_studio_variable(owner, name, key) + + # ================================================================== + # Studio resource discovery + # ================================================================== + def list_studio_hardware( + self, + *, + sdk_type: str | None = None, + repo_id: str | None = None, + ) -> list[dict]: + """List the hardware tiers a Studio may be deployed on. + + Answers the question ``--hardware`` used to leave to guesswork. Anonymous + callers see the default free tier; authenticated callers also see paid + tiers with prices, which are selected as ``paid/``. + + Parameters + ---------- + sdk_type : str, optional + Keep only tiers supporting this SDK type. + repo_id : str, optional + Scope the free tiers to what this ``owner/name`` space may use. + + Examples + -------- + >>> [h["name"] for h in api.list_studio_hardware(sdk_type="gradio")] + ['CPU basic 2 vCPU 16GB', ...] + """ + data = self.openapi.list_studio_hardware(sdk_type=sdk_type, studio=repo_id) + return _unwrap_list(data, ("hardware", "items", "list")) + + def list_studio_base_images(self) -> list[dict]: + """List the base images available to Studio spaces. + + Examples + -------- + >>> api.list_studio_base_images() + [{'name': 'ubuntu22.04-py311-torch2.3.1', 'tag': '...'}, ...] + """ + data = self.openapi.list_studio_base_images() + return _unwrap_list(data, ("base_images", "items", "list")) + + def list_studio_sdk_versions(self, *, sdk_type: str | None = None) -> list[dict]: + """List the SDK versions available to Studio spaces. + + Only ``sdk_type="gradio"`` yields versions; the other SDK types (and + omitting it) return an empty list. + + Examples + -------- + >>> [v["version"] for v in api.list_studio_sdk_versions(sdk_type="gradio")] + ['4.44.1', ...] + """ + data = self.openapi.list_studio_sdk_versions(sdk_type=sdk_type) + return _unwrap_list(data, ("sdk_versions", "items", "list")) + # ================================================================== # MCP convenience wrappers # ================================================================== @@ -1942,6 +2200,26 @@ def list_mcp_servers( items, total, page, size = self._extract_paged(payload) return PagedResult(items=list(items), total_count=total, page_number=page, page_size=size) + def list_operational_mcp_servers(self) -> PagedResult[dict]: + """List the MCP servers the caller currently has hosted. + + Returns the same :class:`PagedResult` shape as :meth:`list_mcp_servers`, + so both can be consumed identically. Each entry additionally carries + ``operational_urls`` with the live endpoint(s). + + Unlike :meth:`list_mcp_servers` this always requires a valid token: the + answer is account-private, so there is no anonymous view to fall back on. + + Examples + -------- + >>> page = api.list_operational_mcp_servers() + >>> [s["id"] for s in page.items] + ['alice/weather-mcp', ...] + """ + payload = self.openapi.list_operational_mcp_servers() + items, total, page, size = self._extract_paged(payload) + return PagedResult(items=list(items), total_count=total, page_number=page, page_size=size) + def get_mcp_server( self, server_id: str, diff --git a/src/modelscope_hub/cli/mcp.py b/src/modelscope_hub/cli/mcp.py index d383dbf..70c501d 100644 --- a/src/modelscope_hub/cli/mcp.py +++ b/src/modelscope_hub/cli/mcp.py @@ -36,6 +36,12 @@ class _McpList(CLICommand): def register(subparsers: SubParsers) -> None: p = subparsers.add_parser("list", help="List MCP servers.") p.add_argument("--search", default=None) + p.add_argument( + "--hosted", + action="store_true", + default=False, + help="List only the servers you currently have hosted, with their live URLs.", + ) p.add_argument("--page", dest="page_number", type=int, default=1) p.add_argument("--page-size", dest="page_size", type=int, default=20) add_subcmd_token_endpoint(p) @@ -43,6 +49,9 @@ def register(subparsers: SubParsers) -> None: def execute(self) -> None: api = make_api(self.args) + if self.args.hosted: + self._list_hosted(api) + return result = api.list_mcp_servers( search=self.args.search, page_number=self.args.page_number, @@ -63,6 +72,28 @@ def execute(self) -> None: info(render_table(rows, headers=["id", "name", "status", "description"])) info(f"\npage {result.page_number} / total {result.total_count}") + @staticmethod + def _list_hosted(api) -> None: + """Render the caller's own hosted servers, including their endpoints. + + ``--search`` and paging are not forwarded: the endpoint takes neither, so + accepting them silently would imply a filter that never applied. + """ + result = api.list_operational_mcp_servers() + if not result.items: + info("(no hosted MCP servers)") + return + for item in result.items: + server_id = item.get("id") or "-" + name = item.get("name") or "-" + info(f"{server_id} ({name})") + for url in item.get("operational_urls") or []: + if not isinstance(url, dict): + continue + transport = url.get("transport_type") or "-" + info(f" {transport}: {url.get('url') or '-'}") + info(f"\ntotal {result.total_count}") + class _McpInfo(CLICommand): @staticmethod diff --git a/src/modelscope_hub/cli/repo.py b/src/modelscope_hub/cli/repo.py index c6ac7da..7072896 100644 --- a/src/modelscope_hub/cli/repo.py +++ b/src/modelscope_hub/cli/repo.py @@ -238,6 +238,7 @@ def _add_arguments(p) -> None: choices=[ RepoType.MODEL.value, RepoType.DATASET.value, + RepoType.STUDIO.value, RepoType.SKILL.value, RepoType.MCP.value, ], @@ -254,6 +255,17 @@ def _add_arguments(p) -> None: _MAX_PAGE_SIZE = 50 + def _extra_filters(self) -> dict: + """Per-type filters this generic listing has to supply. + + Studios are documented as switching their own status default to ``all`` + once ``owner`` is set, but the endpoint keeps filtering to running spaces, + so ``list --repo-type studio --owner me`` answered with nothing at all. + """ + if self.args.repo_type == RepoType.STUDIO.value and self.args.owner: + return {"status": "all"} + return {} + def execute(self) -> None: if self.args.envs: print_env_table() @@ -279,6 +291,7 @@ def execute(self) -> None: search=self.args.search, page_number=self.args.page_number, page_size=self.args.page_size, + **self._extra_filters(), ) if not result.items: info("(no repositories found)") @@ -297,6 +310,7 @@ def _fetch_all_pages(self, api) -> list[RepoInfo]: search=self.args.search, page_number=page_number, page_size=page_size, + **self._extra_filters(), ) all_items.extend(result.items) if not result.has_next or not result.items: diff --git a/src/modelscope_hub/cli/studio.py b/src/modelscope_hub/cli/studio.py index ba825bd..feb30c9 100644 --- a/src/modelscope_hub/cli/studio.py +++ b/src/modelscope_hub/cli/studio.py @@ -12,11 +12,14 @@ from argparse import SUPPRESS from typing import Any -from ..constants import RepoType -from .base import CLICommand, SubParsers, info, make_api, parse_kv_pairs, success +from ..constants import RepoType, StudioVisibility +from .base import CLICommand, SubParsers, info, make_api, parse_kv_pairs, render_table, success _LOG_TYPES = ("run", "build") _STUDIO_SDK_TYPES = ("gradio", "streamlit", "docker", "static") +_STUDIO_SORTS = ("default", "last_modified", "view_num", "likes") +_STUDIO_STATUS_FILTERS = ("running", "all") +_STUDIO_HARDWARE_TYPES = ("xgpu", "amd") _SETTINGS_FIELDS = ( "display_name", "description", @@ -26,6 +29,7 @@ "sdk_version", "base_image", "hardware", + "visibility", "private", ) @@ -33,6 +37,14 @@ class StudioCommand(CLICommand): """Top-level dispatcher for ``studio`` subcommands.""" + # The other built-in commands do not need this, because the parser name is + # the string handed to ``add_parser``. This one does: it is the single command + # that crosses the package boundary (the umbrella SDK re-exports it as + # ``StudioCMD``, and its plugin classes are all identified by ``name``), so + # plugin-collision detection can key off the class instead of the entry-point + # label it happens to be registered under. + name = "studio" + @staticmethod def register(subparsers: SubParsers) -> None: parser = subparsers.add_parser("studio", help="Manage ModelScope Studio spaces.") @@ -40,17 +52,28 @@ def register(subparsers: SubParsers) -> None: actions = parser.add_subparsers(dest="studio_action", metavar="ACTION") actions.required = True + _StudioList.register(actions) _StudioDeploy.register(actions) _StudioStop.register(actions) _StudioLogs.register(actions) _StudioSettings.register(actions) _StudioSecret.register(actions) + _StudioVariable.register(actions) + _StudioHardware.register(actions) + _StudioBaseImages.register(actions) + _StudioSdkVersions.register(actions) parser.set_defaults(_command=StudioCommand) def execute(self) -> None: leaf = getattr(self.args, "_studio_leaf", None) - if leaf is None: # pragma: no cover - argparse enforces this + if leaf is None: + # Namespaces built by hand -- as embedders and the umbrella SDK's + # tests do -- carry only the action name, because ``_studio_leaf`` is + # set by argparse during dispatch. Fall back to the action so a + # programmatic caller is not forced to know that internal detail. + leaf = _STUDIO_LEAVES.get(getattr(self.args, "studio_action", None) or "") + if leaf is None: raise SystemExit("No studio subcommand specified. Run 'modelscope studio --help'.") leaf(self.args).execute() @@ -75,6 +98,72 @@ def _add_leaf_auth_args(parser) -> None: parser.add_argument("--endpoint", dest="subcmd_endpoint", default=SUPPRESS, help=SUPPRESS) +class _StudioList(CLICommand): + @staticmethod + def register(subparsers: SubParsers) -> None: + p = subparsers.add_parser("list", help="List Studio spaces.") + p.add_argument("--search", default=None, help="Match name, display name or author.") + p.add_argument("--owner", default=None, help="Restrict to spaces owned by this user/org.") + p.add_argument("--sort", choices=_STUDIO_SORTS, default=None) + p.add_argument( + "--status", + choices=_STUDIO_STATUS_FILTERS, + default=None, + help="Runtime status filter. Defaults to 'all' when --owner is given, else to the server default.", + ) + mcp = p.add_mutually_exclusive_group() + mcp.add_argument("--mcp-support", dest="mcp_support", action="store_const", const=True, default=None) + mcp.add_argument("--no-mcp-support", dest="mcp_support", action="store_const", const=False) + p.add_argument("--hardware-type", dest="hardware_type", choices=_STUDIO_HARDWARE_TYPES, default=None) + p.add_argument("--page", dest="page_number", type=int, default=1) + p.add_argument("--page-size", dest="page_size", type=int, default=10) + _add_leaf_auth_args(p) + p.set_defaults(_command=StudioCommand, _studio_leaf=_StudioList) + + def execute(self) -> None: + api = make_api(self.args) + result = api.list_repos( + RepoType.STUDIO, + search=self.args.search, + owner=self.args.owner, + sort=self.args.sort, + page_number=self.args.page_number, + page_size=self.args.page_size, + status=self._status(), + mcp_support=self.args.mcp_support, + hardware_type=self.args.hardware_type, + ) + if not result.items: + info("(no studios found)") + return + rows = [ + ( + r.repo_id or "-", + _visibility_label(r.visibility), + r.sdk_type or "-", + r.hardware or "-", + _runtime_status(r), + r.likes, + ) + for r in result.items + ] + info(render_table(rows, headers=["repo_id", "visibility", "sdk_type", "hardware", "status", "likes"])) + info(f"\npage {result.page_number} / total {result.total_count} (page_size={result.page_size})") + + def _status(self) -> str | None: + """Resolve the status filter, defaulting to ``all`` when listing an owner. + + The endpoint is documented as switching its own default to ``all`` once + ``owner`` is set, but it does not: it keeps filtering to running spaces, + so "list my spaces" answered with nothing at all. Asking for ``all`` + explicitly gives the command the meaning a user expects, while leaving + the SDK faithful to whatever the server actually does. + """ + if self.args.status: + return str(self.args.status) + return "all" if self.args.owner else None + + class _StudioDeploy(CLICommand): @staticmethod def register(subparsers: SubParsers) -> None: @@ -146,6 +235,12 @@ def register(subparsers: SubParsers) -> None: p.add_argument("--base-image", dest="base_image", default=None) p.add_argument("--hardware", default=None) visibility = p.add_mutually_exclusive_group() + visibility.add_argument( + "--visibility", + choices=[v.value for v in StudioVisibility], + default=None, + help="public (code and app public), protected (app public, code hidden) or private.", + ) visibility.add_argument("--private", dest="private", action="store_const", const=True, default=None) visibility.add_argument("--public", dest="private", action="store_const", const=False) p.set_defaults(_command=StudioCommand, _studio_leaf=_StudioSettings) @@ -155,7 +250,8 @@ def execute(self) -> None: if not settings: raise ValueError( "No setting specified. Provide key=value or one of: --display-name, --description, --license, " - "--cover-image, --sdk-type, --sdk-version, --base-image, --hardware, --private/--public." + "--cover-image, --sdk-type, --sdk-version, --base-image, --hardware, --visibility, " + "--private/--public." ) api = make_api(self.args) data = api.update_repo_settings(self.args.studio_id, RepoType.STUDIO, **settings) @@ -218,6 +314,159 @@ def execute(self) -> None: raise ValueError(f"Unknown secret subcommand: {action}") +class _StudioVariable(CLICommand): + """``studio variable`` — plaintext environment variables. + + Deliberately separate from ``studio secret``: a variable's value is publicly + visible, so ``list`` prints it, whereas a secret's value is never disclosed. + """ + + @staticmethod + def register(subparsers: SubParsers) -> None: + parser = subparsers.add_parser( + "variable", + help="Manage Studio plaintext environment variables (values are public).", + ) + actions = parser.add_subparsers(dest="variable_action", metavar="ACTION") + actions.required = True + + list_p = actions.add_parser("list", help="List variable keys and values.") + _add_studio_id(list_p) + list_p.set_defaults(_command=StudioCommand, _studio_leaf=_StudioVariable) + + add_p = actions.add_parser("add", help="Add a plaintext variable.") + _add_studio_id(add_p) + add_p.add_argument("key") + add_p.add_argument("value") + add_p.set_defaults(_command=StudioCommand, _studio_leaf=_StudioVariable) + + update_p = actions.add_parser("update", help="Update an existing plaintext variable.") + _add_studio_id(update_p) + update_p.add_argument("key") + update_p.add_argument("value") + update_p.set_defaults(_command=StudioCommand, _studio_leaf=_StudioVariable) + + delete_p = actions.add_parser("delete", help="Delete a plaintext variable.") + _add_studio_id(delete_p) + delete_p.add_argument("key") + delete_p.add_argument("--yes", "-y", action="store_true", help="Skip confirmation.") + delete_p.set_defaults(_command=StudioCommand, _studio_leaf=_StudioVariable) + + def execute(self) -> None: + action = self.args.variable_action + if action == "delete" and not getattr(self.args, "yes", False): + answer = input(f"Delete variable {self.args.key!r} from {self.args.studio_id}? [y/N] ").strip().lower() + if answer not in ("y", "yes"): + info("Aborted.") + return + api = make_api(self.args) + if action == "list": + variables = api.list_variables(self.args.studio_id, RepoType.STUDIO) + if not variables: + info("(no variables)") + return + rows = [ + (item.get("key") or "-", item.get("value") or "") if isinstance(item, dict) else (str(item), "") + for item in variables + ] + info(render_table(rows, headers=["key", "value"])) + return + if action == "add": + api.add_variable(self.args.studio_id, self.args.key, self.args.value, RepoType.STUDIO) + success(f"Variable {self.args.key!r} added.") + return + if action == "update": + api.update_variable(self.args.studio_id, self.args.key, self.args.value, RepoType.STUDIO) + success(f"Variable {self.args.key!r} updated.") + return + if action == "delete": + api.delete_variable(self.args.studio_id, self.args.key, RepoType.STUDIO) + success(f"Variable {self.args.key!r} deleted.") + return + raise ValueError(f"Unknown variable subcommand: {action}") + + +class _StudioHardware(CLICommand): + @staticmethod + def register(subparsers: SubParsers) -> None: + p = subparsers.add_parser("hardware", help="List hardware tiers a Studio can be deployed on.") + p.add_argument("--sdk-type", dest="sdk_type", choices=_STUDIO_SDK_TYPES, default=None) + p.add_argument( + "--studio", + dest="studio_id", + default=None, + help="Scope free tiers to what this 'owner/name' space may use.", + ) + _add_leaf_auth_args(p) + p.set_defaults(_command=StudioCommand, _studio_leaf=_StudioHardware) + + def execute(self) -> None: + api = make_api(self.args) + items = api.list_studio_hardware(sdk_type=self.args.sdk_type, repo_id=self.args.studio_id) + if not items: + info("(no hardware available)") + return + rows = [ + ( + item.get("name") or "-", + item.get("instance_type") or "-", + item.get("resource_type") or "-", + item.get("gpu_type") or "-", + _stock_label(item), + _cost_label(item), + ) + for item in items + ] + info( + render_table( + rows, + headers=["name", "instance_type", "resource_type", "gpu", "stock", "cost"], + ) + ) + info("\nPass a paid tier as --hardware paid/.") + + +class _StudioBaseImages(CLICommand): + @staticmethod + def register(subparsers: SubParsers) -> None: + p = subparsers.add_parser("base-images", help="List base images available to Studio spaces.") + _add_leaf_auth_args(p) + p.set_defaults(_command=StudioCommand, _studio_leaf=_StudioBaseImages) + + def execute(self) -> None: + api = make_api(self.args) + items = api.list_studio_base_images() + if not items: + info("(no base images available)") + return + rows = [(item.get("name") or "-", item.get("tag") or "-") for item in items] + info(render_table(rows, headers=["name", "tag"])) + + +class _StudioSdkVersions(CLICommand): + @staticmethod + def register(subparsers: SubParsers) -> None: + p = subparsers.add_parser("sdk-versions", help="List SDK versions available to Studio spaces.") + p.add_argument( + "--sdk-type", + dest="sdk_type", + choices=_STUDIO_SDK_TYPES, + default="gradio", + help="Only 'gradio' publishes versions (default: gradio).", + ) + _add_leaf_auth_args(p) + p.set_defaults(_command=StudioCommand, _studio_leaf=_StudioSdkVersions) + + def execute(self) -> None: + api = make_api(self.args) + items = api.list_studio_sdk_versions(sdk_type=self.args.sdk_type) + if not items: + info(f"(no SDK versions published for sdk_type={self.args.sdk_type!r})") + return + rows = [(item.get("sdk_type") or "-", item.get("version") or "-", item.get("tag") or "-") for item in items] + info(render_table(rows, headers=["sdk_type", "version", "tag"])) + + def _collect_settings(args) -> dict[str, Any]: settings: dict[str, Any] = parse_kv_pairs(getattr(args, "settings", []) or []) for field in _SETTINGS_FIELDS: @@ -227,6 +476,59 @@ def _collect_settings(args) -> dict[str, Any]: return settings +# Action name -> leaf handler, for callers that dispatch without argparse. +# Declared after the leaf classes because it references them. +_STUDIO_LEAVES: dict[str, type[CLICommand]] = { + "list": _StudioList, + "deploy": _StudioDeploy, + "stop": _StudioStop, + "logs": _StudioLogs, + "settings": _StudioSettings, + "secret": _StudioSecret, + "variable": _StudioVariable, + "hardware": _StudioHardware, + "base-images": _StudioBaseImages, + "sdk-versions": _StudioSdkVersions, +} + + +def _visibility_label(value: object) -> str: + """Render a visibility that may be an enum or the raw ``protected`` string.""" + if value is None: + return "-" + return getattr(value, "label", None) or getattr(value, "name", None) or str(value) + + +def _runtime_status(repo: object) -> str: + runtime = getattr(repo, "runtime", None) + if isinstance(runtime, dict): + return str(runtime.get("status") or "-") + return "-" + + +def _stock_label(item: dict) -> str: + """Summarise availability, distinguishing "none left" from "not reported".""" + if item.get("has_stock") is False: + return "out of stock" + stock = item.get("stock") + if stock is None: + return "-" + return str(stock) + + +def _cost_label(item: dict) -> str: + """Show the discounted price, noting the original when it differs.""" + cost = item.get("cost_after_discount") + original = item.get("original_cost") + if cost is None and original is None: + return "free" + if cost is None: + return str(original) + if original in (None, cost): + return str(cost) + return f"{cost} (was {original})" + + def _print_status(data: object) -> None: if not data: return @@ -253,6 +555,16 @@ def _print_logs(payload: object, *, page_num: int, page_size: int) -> None: info(f"[{ts}] {msg}" if ts else str(msg)) else: info(str(entry)) - total = payload.get("total") - if total is not None: - info(f"-- page {page_num} (size {page_size}), total {total} --") + # The response reports ``total_count`` / ``total_page_num``; the older + # ``total`` spelling this used to read never existed, so the footer was + # always suppressed. + total = payload.get("total_count") + if total is None: + total = payload.get("total") + if total is None: + return + footer = f"-- page {page_num} (size {page_size}), total {total}" + total_pages = payload.get("total_page_num") + if total_pages is not None: + footer += f" across {total_pages} page(s)" + info(f"{footer} --") diff --git a/src/modelscope_hub/compat/hub_api.py b/src/modelscope_hub/compat/hub_api.py index 21b4b6d..8ed9629 100644 --- a/src/modelscope_hub/compat/hub_api.py +++ b/src/modelscope_hub/compat/hub_api.py @@ -36,6 +36,30 @@ META_FILES_FORMAT = {".json", ".csv", ".jsonl", ".tsv", ".py"} +# Transport/credential arguments the legacy surface accepts on almost every +# method. They steer *how* a call is made and are never part of a request body. +_CONTROL_KWARGS: frozenset[str] = frozenset({"token", "endpoint", "cookies", "headers", "timeout", "max_retries"}) + + +def _split_control_kwargs(kwargs: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: + """Split legacy ``**kwargs`` into ``(control, passthrough)``. + + The old ``modelscope.hub.api.HubApi`` signature let callers append + ``token=`` / ``endpoint=`` to any method, so shims declared ``**kwargs`` + and forwarded it wholesale. For the Studio endpoints that turned out to be + actively harmful: ``update_studio_settings`` forwards its kwargs as the + settings payload, so the caller's API token ended up inside the ``PATCH`` + request body (and therefore in server-side request logs), while + ``get_studio_logs`` forwards into a keyword-only signature and raised + ``TypeError: got an unexpected keyword argument 'token'``. + + Splitting them apart keeps the permissive legacy signature while ensuring + only genuine business fields reach the wire. + """ + control = {k: v for k, v in kwargs.items() if k in _CONTROL_KWARGS} + passthrough = {k: v for k, v in kwargs.items() if k not in _CONTROL_KWARGS} + return control, passthrough + class _AigcUploadAdapter: """Expose model-only upload methods expected by legacy ``AigcModel``.""" @@ -529,33 +553,90 @@ def download_model( # ------------------------------------------------------------------ # Studio operations # ------------------------------------------------------------------ + def _api_for(self, control: dict[str, Any]) -> HubApi: + """Resolve the :class:`HubApi` a single legacy call should run against. + + The legacy surface treats ``token`` / ``endpoint`` as per-call + overrides. Honouring them requires a separate client, because the + ambient one is bound to the credential it was constructed with -- which + is why passing ``token=`` used to be silently ignored here. + """ + token = control.get("token") + endpoint = control.get("endpoint") + config = self._api._config + if (token and token != config.token) or (endpoint and endpoint != config.endpoint): + return HubApi(token=token or config.token, endpoint=endpoint or config.endpoint) + return self._api + def deploy_studio(self, studio_id: str, **kwargs: Any) -> dict: - return self._api.deploy_repo( + control, passthrough = _split_control_kwargs(kwargs) + return self._api_for(control).deploy_repo( studio_id, RepoType.STUDIO, - payload=kwargs.get("payload"), + payload=passthrough.get("payload"), ) def stop_studio(self, studio_id: str, **kwargs: Any) -> dict: - return self._api.stop_repo(studio_id, RepoType.STUDIO) + control, _ = _split_control_kwargs(kwargs) + return self._api_for(control).stop_repo(studio_id, RepoType.STUDIO) def get_studio_logs(self, studio_id: str, **kwargs: Any) -> dict: - return self._api.get_repo_logs(studio_id, RepoType.STUDIO, **kwargs) + control, passthrough = _split_control_kwargs(kwargs) + return self._api_for(control).get_repo_logs(studio_id, RepoType.STUDIO, **passthrough) def update_studio_settings(self, studio_id: str, **kwargs: Any) -> dict: - return self._api.update_repo_settings(studio_id, RepoType.STUDIO, **kwargs) + control, settings = _split_control_kwargs(kwargs) + return self._api_for(control).update_repo_settings(studio_id, RepoType.STUDIO, **settings) def list_studio_secrets(self, studio_id: str, **kwargs: Any) -> list: - return self._api.list_secrets(studio_id, RepoType.STUDIO) + control, _ = _split_control_kwargs(kwargs) + return self._api_for(control).list_secrets(studio_id, RepoType.STUDIO) def add_studio_secret(self, studio_id: str, key: str, value: str, **kwargs: Any) -> None: - self._api.add_secret(studio_id, key, value, RepoType.STUDIO) + control, _ = _split_control_kwargs(kwargs) + self._api_for(control).add_secret(studio_id, key, value, RepoType.STUDIO) def update_studio_secret(self, studio_id: str, key: str, value: str, **kwargs: Any) -> None: - self._api.update_secret(studio_id, key, value, RepoType.STUDIO) + control, _ = _split_control_kwargs(kwargs) + self._api_for(control).update_secret(studio_id, key, value, RepoType.STUDIO) def delete_studio_secret(self, studio_id: str, key: str, **kwargs: Any) -> None: - self._api.delete_secret(studio_id, key, RepoType.STUDIO) + control, _ = _split_control_kwargs(kwargs) + self._api_for(control).delete_secret(studio_id, key, RepoType.STUDIO) + + def list_studios(self, **kwargs: Any) -> dict: + """List Studio spaces as a raw paginated dict.""" + control, passthrough = _split_control_kwargs(kwargs) + result = self._api_for(control).list_repos(RepoType.STUDIO, **passthrough) + return result.to_dict() + + def list_studio_variables(self, studio_id: str, **kwargs: Any) -> list: + control, _ = _split_control_kwargs(kwargs) + return self._api_for(control).list_variables(studio_id, RepoType.STUDIO) + + def add_studio_variable(self, studio_id: str, key: str, value: str, **kwargs: Any) -> None: + control, _ = _split_control_kwargs(kwargs) + self._api_for(control).add_variable(studio_id, key, value, RepoType.STUDIO) + + def update_studio_variable(self, studio_id: str, key: str, value: str, **kwargs: Any) -> None: + control, _ = _split_control_kwargs(kwargs) + self._api_for(control).update_variable(studio_id, key, value, RepoType.STUDIO) + + def delete_studio_variable(self, studio_id: str, key: str, **kwargs: Any) -> None: + control, _ = _split_control_kwargs(kwargs) + self._api_for(control).delete_variable(studio_id, key, RepoType.STUDIO) + + def list_studio_hardware(self, **kwargs: Any) -> list: + control, passthrough = _split_control_kwargs(kwargs) + return self._api_for(control).list_studio_hardware(**passthrough) + + def list_studio_base_images(self, **kwargs: Any) -> list: + control, _ = _split_control_kwargs(kwargs) + return self._api_for(control).list_studio_base_images() + + def list_studio_sdk_versions(self, **kwargs: Any) -> list: + control, passthrough = _split_control_kwargs(kwargs) + return self._api_for(control).list_studio_sdk_versions(**passthrough) # ------------------------------------------------------------------ # Revision resolution diff --git a/src/modelscope_hub/constants.py b/src/modelscope_hub/constants.py index 0a244c8..8dd7326 100644 --- a/src/modelscope_hub/constants.py +++ b/src/modelscope_hub/constants.py @@ -105,6 +105,55 @@ def from_label(cls, label: str) -> Visibility: raise ValueError(f"Unknown visibility label: {label!r}") from exc +class StudioVisibility(StrEnum): + """Visibility levels a Studio space can be published under. + + Deliberately separate from :class:`Visibility`: models and datasets encode + visibility as the integers 1/3/5, whereas the Studio endpoints take a string + enum and offer a third state the integer encoding cannot express. + + * ``public`` -- both the code and the running app are public. + * ``protected`` -- the app is public, the code repository is not. + * ``private`` -- neither is public. + """ + + PUBLIC = "public" + PROTECTED = "protected" + PRIVATE = "private" + + @classmethod + def parse(cls, value: object) -> StudioVisibility | None: + """Return the matching member, or ``None`` when *value* is not one. + + Returning ``None`` rather than raising lets callers fall back to the + integer :class:`Visibility` encoding for inputs this enum does not own. + """ + if isinstance(value, cls): + return value + if isinstance(value, str): + try: + return cls(value.strip().lower()) + except ValueError: + return None + return None + + +class TokenScope(StrEnum): + """Permission tiers a ModelScope API token can be issued with. + + The Hub grants tokens one of three levels. The OpenAPI specification does + not model them -- ``securitySchemes`` declares a bare bearer scheme with no + scopes, and ``GET /users/me`` does not report the caller's level -- so the + SDK cannot know a token's tier up front and never pre-validates against it. + These values are used only to annotate what an operation needs, so that a + 403 can name the missing permission instead of leaving the user guessing. + """ + + READ = "read" + WRITE = "write" + ADMIN = "admin" + + class License(StrEnum): """Common open-source licenses used on ModelScope Hub.""" @@ -902,7 +951,9 @@ def get_upload_ignore_file_pattern() -> str | None: "RepoType", "StrEnum", "SESSION_FILE_NAME", + "StudioVisibility", "TEMPORARY_FOLDER_NAME", + "TokenScope", "UPLOAD_ADAPTIVE_BATCHING_ENABLED", "UPLOAD_ADAPTIVE_BATCH_SIZE", "UPLOAD_BATCH_CONSECUTIVE_FAILURE_LIMIT", diff --git a/src/modelscope_hub/errors.py b/src/modelscope_hub/errors.py index 52a685b..13d27b4 100644 --- a/src/modelscope_hub/errors.py +++ b/src/modelscope_hub/errors.py @@ -281,6 +281,22 @@ def __init__( self.retry_after = retry_after +# -- Quota (E3027) ---------------------------------------------------------- +class QuotaExceededError(APIError): + """Raised when the account's quota for a resource is exhausted (E3027). + + The OpenAPI surface reports this as HTTP 403 with ``code=QuotaLimitExceed``, + sharing the status with "insufficient permission". It is deliberately *not* + a :class:`RateLimitError` subclass: rate limiting clears on its own and the + transport retries it with back-off, whereas an exhausted quota will keep + failing, so retrying only delays the error the caller needs to see. + """ + + error_code = "E3027" + retryable = False + suggestion = "Quota exceeded for this resource. Free up existing resources or request a higher quota." + + # -- Server (E1002) --------------------------------------------------------- class ServerError(APIError): """Raised on HTTP 5xx -- upstream service failure.""" @@ -390,6 +406,13 @@ class NotSupportedError(HubError): 403: PermissionDeniedError, 404: NotExistError, 405: InvalidParameter, + # The OpenAPI surface answers 409 (``DuplicateEntity``) when creating a + # Studio, a secret or a plaintext variable that already exists. Without + # this entry the failure degraded to a bare APIError, so callers could not + # catch it as AlreadyExistsError like they can on the legacy surface. + 409: AlreadyExistsError, + # 413 is returned by POST /files/upload when the body exceeds 5 MiB. + 413: InvalidParameter, 429: RateLimitError, } @@ -413,6 +436,41 @@ class NotSupportedError(HubError): } +# --------------------------------------------------------------------------- +# OpenAPI string error-code -> exception mapping +# +# The ``/openapi/v1`` surface publishes ``code`` as a string enum, not the +# numeric business code the legacy surface uses, so it never matched +# _BUSINESS_CODE_MAP above. Two of these codes carry information the HTTP status +# alone destroys: 403 covers both "insufficient permission" and "quota +# exhausted", and 400 covers both bad input and a duplicate entity. +# --------------------------------------------------------------------------- +_OPENAPI_CODE_MAP: dict[str, type[APIError]] = { + "InputParameterError": InvalidParameter, + "InvalidAuthentication": AuthenticationError, + "OperationNotAllowed": PermissionDeniedError, + "ResourceNotFound": NotExistError, + "DuplicateEntity": AlreadyExistsError, + "QuotaLimitExceed": QuotaExceededError, + "RateLimitExceed": RateLimitError, + # ServiceUnavailable / InternalServerError arrive with a 5xx status and are + # deliberately absent: the status-code branch already maps them to the + # retryable ServerError, and reclassifying a 5xx would drop that. +} + + +def _openapi_code(body: Any) -> str | None: + """Return the string error code carried by an OpenAPI response body, if any.""" + if not isinstance(body, dict): + return None + raw = body.get("code") + if raw is None: + raw = body.get("Code") + if isinstance(raw, str) and raw.strip(): + return raw.strip() + return None + + def _business_code(body: Any) -> int | None: """Return the numeric business code carried by a response body, if any.""" if not isinstance(body, dict): @@ -521,6 +579,10 @@ def raise_for_status(response: Response) -> None: if status < 500: code = _business_code(body) business_cls = _BUSINESS_CODE_MAP.get(code) if code is not None else None + if business_cls is None: + # The OpenAPI surface publishes a string code instead of a numeric one. + openapi_code = _openapi_code(body) + business_cls = _OPENAPI_CODE_MAP.get(openapi_code) if openapi_code else None if business_cls is not None: exc_cls = business_cls elif exc_cls is InvalidParameter and isinstance(body, dict): @@ -613,6 +675,7 @@ def is_repo_exists_error(exc: BaseException) -> bool: "InvalidParameter", "AlreadyExistsError", # Rate limiting / Server + "QuotaExceededError", "RateLimitError", "ServerError", # Non-HTTP diff --git a/src/modelscope_hub/types.py b/src/modelscope_hub/types.py index 4efcbeb..6761649 100644 --- a/src/modelscope_hub/types.py +++ b/src/modelscope_hub/types.py @@ -171,6 +171,14 @@ class RepoInfo(_FromDictMixin): private: bool | None = None gated: bool | None = None login_required: bool | None = None + # Studio-native fields. Carried here rather than dropped, because the Studio + # payload's runtime configuration is the whole point of inspecting a space. + sdk_type: str | None = None + sdk_version: str | None = None + base_image: str | None = None + hardware: str | None = None + mcp_support: bool | None = None + runtime: dict[str, Any] | None = None def __post_init__(self) -> None: if isinstance(self.repo_type, str): @@ -193,12 +201,25 @@ def to_dict(self) -> dict: and formats datetimes with Z suffix to match OpenAPI spec. """ _INTERNAL_FIELDS = {"owner", "name", "repo_type", "visibility"} + _OPTIONAL_FIELDS = { + "display_name", + "file_size", + "private", + "gated", + "login_required", + "sdk_type", + "sdk_version", + "base_image", + "hardware", + "mcp_support", + "runtime", + } result = {} for f in fields(self): # type: ignore[arg-type] if f.name in _INTERNAL_FIELDS: continue val = getattr(self, f.name) - if val is None and f.name in ("display_name", "file_size", "private", "gated", "login_required"): + if val is None and f.name in _OPTIONAL_FIELDS: continue # skip None optional OpenAPI fields if isinstance(val, Enum): val = val.value @@ -384,9 +405,10 @@ class CreateStudioPayload(TypedDict, total=False): owner: str display_name: str license: str + visibility: str private: bool description: str - coverImage: str + cover_image: str sdk_type: str sdk_version: str base_image: str @@ -398,9 +420,10 @@ class UpdateStudioSettingsPayload(TypedDict, total=False): display_name: str license: str + visibility: str private: bool description: str - coverImage: str + cover_image: str sdk_type: str sdk_version: str base_image: str diff --git a/src/modelscope_hub/version.py b/src/modelscope_hub/version.py index 88db203..f710337 100644 --- a/src/modelscope_hub/version.py +++ b/src/modelscope_hub/version.py @@ -1,3 +1,3 @@ """Version information for modelscope_hub.""" -__version__ = "0.3.1" +__version__ = "0.4.0" diff --git a/tests/.env.example b/tests/.env.example index 1fb05f8..e4b1f74 100644 --- a/tests/.env.example +++ b/tests/.env.example @@ -5,3 +5,12 @@ MODELSCOPE_TEST_ENDPOINT=https://modelscope.cn # Set to true to run @pytest.mark.remote tests (requires valid token/owner above) MODELSCOPE_RUN_REMOTE_TESTS=true + +# Optional: an existing Studio (owner/repo_name) for the plaintext-variable +# lifecycle tests. Skipped when unset. +MODELSCOPE_TEST_STUDIO=your_username/your_studio + +# Optional: a token issued with *read* permission only. Verifies that read-scoped +# tokens can log in and browse, and that a write attempt names the missing +# permission tier. Skipped when unset. +MODELSCOPE_TEST_READONLY_TOKEN= diff --git a/tests/cli/test_mcp.py b/tests/cli/test_mcp.py index 06be932..80db398 100644 --- a/tests/cli/test_mcp.py +++ b/tests/cli/test_mcp.py @@ -204,6 +204,47 @@ def test_list_with_pagination(self, parser, mock_api, capsys): assert kw["page_number"] == 2 assert kw["page_size"] == 5 + def test_hosted_lists_operational_servers_with_urls(self, parser, mock_api, capsys): + mock_api.list_operational_mcp_servers.return_value = PagedResult( + items=[ + { + "id": "alice/weather", + "name": "Weather", + "operational_urls": [ + {"url": "https://mcp.example/uuid/sse", "transport_type": "sse"}, + {"url": "https://mcp.example/uuid/streamable_http", "transport_type": "streamable_http"}, + ], + } + ], + total_count=1, + ) + args = parser.parse_args(["mcp", "list", "--hosted"]) + with patch("modelscope_hub.cli.mcp.make_api", return_value=mock_api): + _McpList(args).execute() + out = capsys.readouterr().out + assert "alice/weather" in out + assert "https://mcp.example/uuid/sse" in out + assert "streamable_http" in out + mock_api.list_operational_mcp_servers.assert_called_once_with() + mock_api.list_mcp_servers.assert_not_called() + + def test_hosted_empty(self, parser, mock_api, capsys): + mock_api.list_operational_mcp_servers.return_value = PagedResult(items=[], total_count=0) + args = parser.parse_args(["mcp", "list", "--hosted"]) + with patch("modelscope_hub.cli.mcp.make_api", return_value=mock_api): + _McpList(args).execute() + assert "no hosted MCP servers" in capsys.readouterr().out + + def test_hosted_tolerates_missing_urls(self, parser, mock_api, capsys): + mock_api.list_operational_mcp_servers.return_value = PagedResult( + items=[{"id": "alice/weather", "name": "Weather"}], + total_count=1, + ) + args = parser.parse_args(["mcp", "list", "--hosted"]) + with patch("modelscope_hub.cli.mcp.make_api", return_value=mock_api): + _McpList(args).execute() + assert "alice/weather" in capsys.readouterr().out + @pytest.mark.mock_only class TestMcpInfoExecute: diff --git a/tests/cli/test_openapi.py b/tests/cli/test_openapi.py index 43c3798..c66c384 100644 --- a/tests/cli/test_openapi.py +++ b/tests/cli/test_openapi.py @@ -13,7 +13,13 @@ from modelscope_hub._openapi import _RETRYABLE_POST_PATHS, OpenAPIClient from modelscope_hub.api import HubApi from modelscope_hub.config import HubConfig -from modelscope_hub.errors import InvalidParameter, PermissionDeniedError, RateLimitError, ServerError +from modelscope_hub.errors import ( + InvalidParameter, + NotExistError, + PermissionDeniedError, + RateLimitError, + ServerError, +) @pytest.fixture @@ -46,59 +52,98 @@ def _mock_response(status_code=200, json_data=None): # ================================================================== # Item 2: list_mcp_servers filter param +# +# PUT is the only method the specification defines for /mcp/servers, so it must +# be the first thing on the wire. Probing GET first cost every single call a +# wasted round trip to a 404. # ================================================================== class TestListMcpServersFilter: - def test_filter_included_in_get_params(self, client): + def test_put_is_tried_first(self, client): + resp = _mock_response(json_data={"success": True, "data": {"mcp_server_list": [], "total": 0}}) + with patch.object(client._session, "request", return_value=resp) as mock_req: + client.list_mcp_servers(search="weather", page_number=1, page_size=2) + assert mock_req.call_count == 1 + call_kwargs = mock_req.call_args.kwargs + assert call_kwargs["method"] == "PUT" + assert call_kwargs["json"] == {"search": "weather", "page_number": 1, "page_size": 2} + + def test_filter_nested_in_put_body(self, client): resp = _mock_response(json_data={"success": True, "data": {"mcp_server_list": [], "total": 0}}) with patch.object(client._session, "request", return_value=resp) as mock_req: client.list_mcp_servers(filter={"category": "tools", "is_hosted": True}) call_kwargs = mock_req.call_args.kwargs - params = dict(call_kwargs["params"]) - assert call_kwargs["method"] == "GET" - assert call_kwargs["json"] is None - assert params["filter.category"] == "tools" - assert params["filter.is_hosted"] == "true" + assert call_kwargs["method"] == "PUT" + assert call_kwargs["json"]["filter"] == {"category": "tools", "is_hosted": True} - def test_filter_none_not_in_get_params(self, client): + def test_filter_absent_when_not_requested(self, client): resp = _mock_response(json_data={"success": True, "data": {"mcp_server_list": [], "total": 0}}) with patch.object(client._session, "request", return_value=resp) as mock_req: client.list_mcp_servers() - call_kwargs = mock_req.call_args.kwargs - params = dict(call_kwargs["params"]) - assert call_kwargs["method"] == "GET" - assert "filter.category" not in params - assert "filter" not in params + assert "filter" not in mock_req.call_args.kwargs["json"] - def test_get_route_unsupported_falls_back_to_legacy_put(self, client): + def test_put_route_unsupported_falls_back_to_get(self, client): not_found = _mock_response(status_code=404, json_data={"message": "not found"}) success = _mock_response(json_data={"success": True, "data": {"mcp_server_list": [], "total": 0}}) with patch.object(client._session, "request", side_effect=[not_found, success]) as mock_req: client.list_mcp_servers(search="weather", page_number=1, page_size=2) first, second = mock_req.call_args_list - assert first.kwargs["method"] == "GET" - assert dict(first.kwargs["params"])["search"] == "weather" - assert second.kwargs["method"] == "PUT" - assert second.kwargs["json"] == {"search": "weather", "page_number": 1, "page_size": 2} + assert first.kwargs["method"] == "PUT" + assert second.kwargs["method"] == "GET" + assert dict(second.kwargs["params"])["search"] == "weather" + + def test_get_probe_is_not_repeated_when_neither_verb_works(self, client): + """A deployment serving neither verb must not be probed twice per call.""" + not_found = _mock_response(status_code=404, json_data={"message": "not found"}) + with patch.object(client._session, "request", return_value=not_found) as mock_req: + with pytest.raises(NotExistError): + client.list_mcp_servers() + assert [c.kwargs["method"] for c in mock_req.call_args_list] == ["PUT", "GET"] + mock_req.reset_mock() + with pytest.raises(NotExistError): + client.list_mcp_servers() + assert [c.kwargs["method"] for c in mock_req.call_args_list] == ["PUT"] + + def test_put_is_not_retried_once_get_is_known_to_work(self, client): + """A GET-only deployment must not pay a PUT 404 on every single call.""" + not_found = _mock_response(status_code=404, json_data={"message": "not found"}) + success = _mock_response(json_data={"success": True, "data": {"mcp_server_list": [], "total": 0}}) + with patch.object(client._session, "request", side_effect=[not_found, success]) as mock_req: + client.list_mcp_servers() + assert [c.kwargs["method"] for c in mock_req.call_args_list] == ["PUT", "GET"] + with patch.object(client._session, "request", return_value=success) as mock_req: + client.list_mcp_servers() + assert [c.kwargs["method"] for c in mock_req.call_args_list] == ["GET"] - def test_get_permission_denied_does_not_fallback_to_put(self, client): + def test_get_fallback_carries_the_same_query(self, client): + not_found = _mock_response(status_code=404, json_data={"message": "not found"}) + success = _mock_response(json_data={"success": True, "data": {"mcp_server_list": [], "total": 0}}) + with patch.object(client._session, "request", side_effect=[not_found, success]) as mock_req: + client.list_mcp_servers(search="weather", filter={"is_hosted": True}) + params = dict(mock_req.call_args_list[1].kwargs["params"]) + assert params["search"] == "weather" + assert params["filter.is_hosted"] == "true" + + def test_permission_denied_does_not_fall_back_to_get(self, client): + """403 means the route exists; retrying it as GET would be pointless.""" denied = _mock_response(status_code=403, json_data={"message": "denied"}) with patch.object(client._session, "request", return_value=denied) as mock_req: with pytest.raises(PermissionDeniedError): client.list_mcp_servers() - assert mock_req.call_count == 1 - assert mock_req.call_args.kwargs["method"] == "GET" + # One credentialled attempt, then one anonymous retry -- never a GET. + assert [c.kwargs["method"] for c in mock_req.call_args_list] == ["PUT", "PUT"] - def test_put_auth_failure_falls_back_to_anonymous_put(self, client): - not_found = _mock_response(status_code=404, json_data={"message": "not found"}) + def test_put_auth_failure_retries_anonymously(self, client): + """A read-scoped or stale token must not hide the public MCP catalogue.""" denied = _mock_response(status_code=403, json_data={"message": "denied"}) success = _mock_response(json_data={"success": True, "data": {"mcp_server_list": [], "total": 0}}) - with patch.object(client._session, "request", side_effect=[not_found, denied, success]) as mock_req: + with patch.object(client._session, "request", side_effect=[denied, success]) as mock_req: client.list_mcp_servers(page_number=1, page_size=2) - assert [call.kwargs["method"] for call in mock_req.call_args_list] == ["GET", "PUT", "PUT"] - assert "Authorization" in mock_req.call_args_list[1].kwargs["headers"] - assert mock_req.call_args_list[1].kwargs["cookies"] - assert "Authorization" not in mock_req.call_args_list[2].kwargs["headers"] - assert mock_req.call_args_list[2].kwargs["cookies"] == {} + first, second = mock_req.call_args_list + assert [first.kwargs["method"], second.kwargs["method"]] == ["PUT", "PUT"] + assert "Authorization" in first.kwargs["headers"] + assert first.kwargs["cookies"] + assert "Authorization" not in second.kwargs["headers"] + assert second.kwargs["cookies"] == {} # ================================================================== @@ -180,18 +225,24 @@ def test_no_json_body_sent(self, client): # ================================================================== -# Item 6: get_studio requires token +# Item 6: get_studio authentication +# +# The specification marks getStudio's security as optional and states that +# public and experience-public (protected) spaces need no credentials, so +# demanding a token up front locked anonymous callers out of public data. # ================================================================== class TestGetStudioAuth: - def test_requires_token_raises_without_token(self): - from modelscope_hub.errors import AuthenticationError - + def test_anonymous_call_is_attempted_rather_than_refused(self): config = HubConfig(token="placeholder", endpoint="https://modelscope.cn") config.token = None client = OpenAPIClient(config) + resp = _mock_response() with patch.object(HubConfig, "load_token", return_value=None): - with pytest.raises(AuthenticationError, match="Missing API token"): + with patch.object(client._session, "request", return_value=resp) as mock_req: client.get_studio("org", "demo") + call_kwargs = mock_req.call_args.kwargs + assert call_kwargs["method"] == "GET" + assert "Authorization" not in call_kwargs["headers"] def test_sends_auth_header_when_token_present(self, client): resp = _mock_response() @@ -201,6 +252,17 @@ def test_sends_auth_header_when_token_present(self, client): assert "Authorization" in call_kwargs["headers"] assert call_kwargs["headers"]["Authorization"] == "Bearer test-token" + def test_rejected_token_falls_back_to_an_anonymous_read(self, client): + """A read-scoped token must not hide a space anyone else can see.""" + denied = _mock_response(status_code=403, json_data={"message": "denied"}) + success = _mock_response() + with patch.object(client._session, "request", side_effect=[denied, success]) as mock_req: + client.get_studio("org", "demo") + first, second = mock_req.call_args_list + assert "Authorization" in first.kwargs["headers"] + assert "Authorization" not in second.kwargs["headers"] + assert second.kwargs["cookies"] == {} + # ================================================================== # Item 8: page_size defaults @@ -234,11 +296,17 @@ def test_list_skills_default_page_size(self, client): assert param_dict.get("page_size") == "10" def test_list_mcp_servers_default_page_size(self, client): + """The specification's default for this endpoint is 20, not 10.""" resp = _mock_response(json_data={"success": True, "data": {"mcp_server_list": [], "total": 0}}) with patch.object(client._session, "request", return_value=resp) as mock_req: client.list_mcp_servers() - call_kwargs = mock_req.call_args.kwargs - params = dict(call_kwargs["params"]) + assert mock_req.call_args.kwargs["json"]["page_size"] == 20 + + def test_list_studios_default_page_size(self, client): + resp = _mock_response(json_data={"studios": [], "total_count": 0}) + with patch.object(client._session, "request", return_value=resp) as mock_req: + client.list_studios() + params = dict(mock_req.call_args.kwargs["params"]) assert params["page_size"] == "10" diff --git a/tests/cli/test_openapi_studio.py b/tests/cli/test_openapi_studio.py new file mode 100644 index 0000000..5a04e46 --- /dev/null +++ b/tests/cli/test_openapi_studio.py @@ -0,0 +1,360 @@ +"""Wire-level tests for the Studio endpoints added in this cycle. + +Every assertion pins what actually reaches the transport -- method, URL, query +string and body -- because the endpoints silently ignore an unrecognised field or +enum value, so a wrong spelling produces plausible-looking but wrong results +rather than an error. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from modelscope_hub._openapi import OpenAPIClient +from modelscope_hub.api import HubApi +from modelscope_hub.config import HubConfig +from modelscope_hub.constants import RepoType +from modelscope_hub.errors import InvalidParameter, NotSupportedError, PermissionDeniedError + +_BASE = "https://modelscope.cn/openapi/v1" + + +@pytest.fixture +def client() -> OpenAPIClient: + return OpenAPIClient(HubConfig(token="test-token", endpoint="https://modelscope.cn")) + + +def _response(payload=None, status_code=200): + resp = MagicMock(spec=requests.Response) + resp.status_code = status_code + resp.headers = {} + resp.content = b"x" + resp.json.return_value = payload if payload is not None else {"success": True, "data": {}} + resp.request = MagicMock(method="GET", path_url="/x", url=_BASE) + resp.url = _BASE + return resp + + +# --------------------------------------------------------------------------- +# GET /studios +# --------------------------------------------------------------------------- +class TestListStudios: + def test_url_and_defaults(self, client): + resp = _response({"studios": [], "total_count": 0, "page_number": 1, "page_size": 10}) + with patch.object(client._session, "request", return_value=resp) as mock_req: + client.list_studios() + kwargs = mock_req.call_args.kwargs + assert kwargs["method"] == "GET" + assert kwargs["url"] == f"{_BASE}/studios" + assert dict(kwargs["params"]) == {"page_number": "1", "page_size": "10"} + + def test_all_filters_are_serialised(self, client): + resp = _response({"studios": [], "total_count": 0}) + with patch.object(client._session, "request", return_value=resp) as mock_req: + client.list_studios( + search="chat", + owner="alice", + sort="likes", + page_number=2, + page_size=20, + status="all", + mcp_support=True, + hardware_type="xgpu", + ) + params = dict(mock_req.call_args.kwargs["params"]) + assert params == { + "search": "chat", + "owner": "alice", + "sort": "likes", + "page_number": "2", + "page_size": "20", + "status": "all", + "mcp_support": "true", + "hardware_type": "xgpu", + } + + def test_false_flag_is_sent_not_dropped(self, client): + """``False`` is a real filter value; dropping it would silently widen the query.""" + resp = _response({"studios": []}) + with patch.object(client._session, "request", return_value=resp) as mock_req: + client.list_studios(mcp_support=False) + assert dict(mock_req.call_args.kwargs["params"])["mcp_support"] == "false" + + @pytest.mark.parametrize( + ("kwargs", "field"), + [ + ({"sort": "downloads"}, "sort"), + ({"status": "stopped"}, "status"), + ({"hardware_type": "nvidia"}, "hardware_type"), + ], + ) + def test_unknown_enum_value_is_rejected(self, client, kwargs, field): + with pytest.raises(InvalidParameter, match=field): + client.list_studios(**kwargs) + + def test_offset_limit_is_enforced(self, client): + with pytest.raises(InvalidParameter, match="<= 3000"): + client.list_studios(page_number=61, page_size=50) + + def test_anonymous_when_no_token(self): + config = HubConfig(token="placeholder", endpoint="https://modelscope.cn") + config.token = None + client = OpenAPIClient(config) + with patch.object(HubConfig, "load_token", return_value=None): + with patch.object(client._session, "request", return_value=_response({"studios": []})) as mock_req: + client.list_studios() + assert "Authorization" not in mock_req.call_args.kwargs["headers"] + + +# --------------------------------------------------------------------------- +# Resource discovery +# --------------------------------------------------------------------------- +class TestStudioResourceDiscovery: + def test_hardware_url_and_params(self, client): + resp = _response({"success": True, "data": {"hardware": []}}) + with patch.object(client._session, "request", return_value=resp) as mock_req: + client.list_studio_hardware(sdk_type="gradio", studio="alice/demo") + kwargs = mock_req.call_args.kwargs + assert kwargs["method"] == "GET" + assert kwargs["url"] == f"{_BASE}/studios/hardware" + assert dict(kwargs["params"]) == {"sdk_type": "gradio", "studio": "alice/demo"} + + def test_hardware_rejects_unknown_sdk_type(self, client): + with pytest.raises(InvalidParameter, match="sdk_type"): + client.list_studio_hardware(sdk_type="flask") + + def test_base_images_url(self, client): + resp = _response({"success": True, "data": {"base_images": []}}) + with patch.object(client._session, "request", return_value=resp) as mock_req: + client.list_studio_base_images() + kwargs = mock_req.call_args.kwargs + assert kwargs["method"] == "GET" + assert kwargs["url"] == f"{_BASE}/studios/base-images" + + def test_sdk_versions_url_and_params(self, client): + resp = _response({"success": True, "data": {"sdk_versions": []}}) + with patch.object(client._session, "request", return_value=resp) as mock_req: + client.list_studio_sdk_versions(sdk_type="gradio") + kwargs = mock_req.call_args.kwargs + assert kwargs["url"] == f"{_BASE}/studios/sdk-versions" + assert dict(kwargs["params"]) == {"sdk_type": "gradio"} + + @pytest.mark.parametrize( + "method_name", + ["list_studio_hardware", "list_studio_base_images", "list_studio_sdk_versions"], + ) + def test_discovery_works_without_a_token(self, method_name): + config = HubConfig(token="placeholder", endpoint="https://modelscope.cn") + config.token = None + client = OpenAPIClient(config) + with patch.object(HubConfig, "load_token", return_value=None): + with patch.object(client._session, "request", return_value=_response()) as mock_req: + getattr(client, method_name)() + assert "Authorization" not in mock_req.call_args.kwargs["headers"] + + +# --------------------------------------------------------------------------- +# Plaintext variables +# --------------------------------------------------------------------------- +class TestStudioVariables: + def test_list_url(self, client): + resp = _response({"success": True, "data": {"variables": []}}) + with patch.object(client._session, "request", return_value=resp) as mock_req: + client.list_studio_variables("alice", "demo") + kwargs = mock_req.call_args.kwargs + assert kwargs["method"] == "GET" + assert kwargs["url"] == f"{_BASE}/studios/alice/demo/variables" + + @pytest.mark.parametrize( + ("method_name", "verb"), + [("add_studio_variable", "POST"), ("update_studio_variable", "PUT")], + ) + def test_write_body(self, client, method_name, verb): + with patch.object(client._session, "request", return_value=_response()) as mock_req: + getattr(client, method_name)("alice", "demo", "MODEL", "Qwen") + kwargs = mock_req.call_args.kwargs + assert kwargs["method"] == verb + assert kwargs["url"] == f"{_BASE}/studios/alice/demo/variables" + assert kwargs["json"] == {"key": "MODEL", "value": "Qwen"} + + def test_delete_body_carries_key_only(self, client): + with patch.object(client._session, "request", return_value=_response()) as mock_req: + client.delete_studio_variable("alice", "demo", "MODEL") + kwargs = mock_req.call_args.kwargs + assert kwargs["method"] == "DELETE" + assert kwargs["json"] == {"key": "MODEL"} + + def test_variables_never_degrade_to_anonymous(self, client): + """Account-private data: an anonymous retry would report a false 'empty'.""" + denied = _response({"message": "denied"}, status_code=403) + with patch.object(client._session, "request", return_value=denied) as mock_req: + with pytest.raises(PermissionDeniedError): + client.list_studio_variables("alice", "demo") + assert mock_req.call_count == 1 + + def test_variables_mirror_the_secrets_routes(self, client): + """The two blocks must stay symmetrical apart from the path segment.""" + captured = [] + with patch.object(client._session, "request", return_value=_response()) as mock_req: + client.add_studio_variable("a", "d", "K", "V") + captured.append(mock_req.call_args.kwargs) + client.add_studio_secret("a", "d", "K", "V") + captured.append(mock_req.call_args.kwargs) + variable, secret = captured + assert variable["json"] == secret["json"] + assert variable["method"] == secret["method"] + assert variable["url"].replace("/variables", "") == secret["url"].replace("/secrets", "") + + +# --------------------------------------------------------------------------- +# Studio logs +# --------------------------------------------------------------------------- +class TestStudioLogs: + def test_page_size_cap_is_enforced(self, client): + with pytest.raises(InvalidParameter, match="<= 500"): + client.get_studio_logs("alice", "demo", "run", page_size=501) + + def test_page_size_at_boundary_is_accepted(self, client): + with patch.object(client._session, "request", return_value=_response()) as mock_req: + client.get_studio_logs("alice", "demo", "run", page_size=500) + assert dict(mock_req.call_args.kwargs["params"])["page_size"] == "500" + + def test_unknown_log_type_is_rejected(self, client): + with pytest.raises(InvalidParameter, match="log_type"): + client.get_studio_logs("alice", "demo", "trace") + + +# --------------------------------------------------------------------------- +# HubApi facade +# --------------------------------------------------------------------------- +class TestFacade: + @pytest.fixture + def api(self): + api = HubApi(token="test-token") + api._openapi = MagicMock() + return api + + def test_list_repos_studio_is_supported(self, api): + api._openapi.list_studios.return_value = { + "studios": [{"id": "alice/demo", "sdk_type": "gradio", "hardware": "cpu"}], + "total_count": 1, + "page_number": 1, + "page_size": 10, + } + page = api.list_repos(RepoType.STUDIO) + assert page.total_count == 1 + assert page.items[0].repo_id == "alice/demo" + assert page.items[0].sdk_type == "gradio" + assert page.collection_key == "studios" + + def test_list_repos_studio_parses_unenveloped_payload(self, api): + """The endpoint omits the {success, data} envelope other lists use.""" + api._openapi.list_studios.return_value = {"studios": [{"id": "a/b"}], "total_count": 7} + page = api.list_repos("studio") + assert [r.repo_id for r in page.items] == ["a/b"] + assert page.total_count == 7 + + def test_list_repos_studio_forwards_filters(self, api): + api._openapi.list_studios.return_value = {"studios": []} + api.list_repos("studio", owner="alice", status="all", mcp_support=True) + kwargs = api._openapi.list_studios.call_args.kwargs + assert kwargs["owner"] == "alice" + assert kwargs["status"] == "all" + assert kwargs["mcp_support"] is True + + def test_variables_unwrap_the_payload(self, api): + api._openapi.list_studio_variables.return_value = {"variables": [{"key": "K", "value": "V"}]} + assert api.list_variables("alice/demo") == [{"key": "K", "value": "V"}] + + def test_variables_tolerate_a_bare_list(self, api): + api._openapi.list_studio_variables.return_value = [{"key": "K", "value": "V"}] + assert api.list_variables("alice/demo") == [{"key": "K", "value": "V"}] + + def test_variables_tolerate_an_empty_payload(self, api): + api._openapi.list_studio_variables.return_value = None + assert api.list_variables("alice/demo") == [] + + @pytest.mark.parametrize( + ("method_name", "args"), + [ + ("list_variables", ()), + ("add_variable", ("K", "V")), + ("update_variable", ("K", "V")), + ("delete_variable", ("K",)), + ], + ) + def test_variables_are_studio_only(self, api, method_name, args): + with pytest.raises(NotSupportedError): + getattr(api, method_name)("alice/demo", *args, repo_type=RepoType.MODEL) + + def test_hardware_unwraps_the_payload(self, api): + api._openapi.list_studio_hardware.return_value = {"hardware": [{"name": "cpu"}]} + assert api.list_studio_hardware() == [{"name": "cpu"}] + + def test_hardware_forwards_repo_id_as_studio(self, api): + api._openapi.list_studio_hardware.return_value = {"hardware": []} + api.list_studio_hardware(sdk_type="gradio", repo_id="alice/demo") + api._openapi.list_studio_hardware.assert_called_once_with(sdk_type="gradio", studio="alice/demo") + + def test_base_images_unwraps_the_payload(self, api): + api._openapi.list_studio_base_images.return_value = {"base_images": [{"name": "ubuntu"}]} + assert api.list_studio_base_images() == [{"name": "ubuntu"}] + + def test_sdk_versions_unwraps_the_payload(self, api): + api._openapi.list_studio_sdk_versions.return_value = {"sdk_versions": [{"version": "4.44.1"}]} + assert api.list_studio_sdk_versions(sdk_type="gradio") == [{"version": "4.44.1"}] + + def test_operational_mcp_servers_matches_list_mcp_servers_shape(self, api): + api._openapi.list_operational_mcp_servers.return_value = { + "mcp_server_list": [{"id": "alice/weather", "operational_urls": [{"url": "https://x/sse"}]}], + "total_count": 1, + } + page = api.list_operational_mcp_servers() + assert page.total_count == 1 + assert page.items[0]["id"] == "alice/weather" + + +# --------------------------------------------------------------------------- +# Studio field normalisation must not leak onto other repo types +# --------------------------------------------------------------------------- +class TestStudioFieldNormalisation: + @pytest.fixture + def api(self): + api = HubApi(token="test-token") + api._openapi = MagicMock() + return api + + def test_camelcase_cover_image_is_normalised_for_studios(self, api): + api.create_repo("alice/demo", RepoType.STUDIO, coverImage="https://img") + payload = api._openapi.create_studio.call_args[0][0] + assert payload["cover_image"] == "https://img" + assert "coverImage" not in payload + + def test_studio_visibility_expands_to_the_private_companion(self, api): + api.create_repo("alice/demo", RepoType.STUDIO, visibility="protected") + payload = api._openapi.create_studio.call_args[0][0] + assert payload["visibility"] == "protected" + assert payload["private"] is False + + def test_skill_payload_is_left_alone(self, api): + """A Skill has no cover image and no visibility field, so the Studio + normaliser must not invent them on a Skill payload.""" + api.create_repo("alice/my-skill", RepoType.SKILL, coverImage="https://img") + payload = api._openapi.create_skill.call_args[0][0] + assert payload["coverImage"] == "https://img" + assert "cover_image" not in payload + + def test_skill_visibility_does_not_gain_a_studio_field(self, api): + api.create_repo("alice/my-skill", RepoType.SKILL, visibility="private") + payload = api._openapi.create_skill.call_args[0][0] + assert "visibility" not in payload + assert payload["private"] is True + + def test_settings_normalisation_applies_to_studios_only(self, api): + api.update_repo_settings("alice/demo", RepoType.STUDIO, coverImage="https://img") + assert api._openapi.update_studio_settings.call_args[0][2] == {"cover_image": "https://img"} + api.update_repo_settings("alice/my-skill", RepoType.SKILL, logo_url="https://logo") + assert api._openapi.update_skill_settings.call_args[0][2] == {"logo_url": "https://logo"} diff --git a/tests/cli/test_repo.py b/tests/cli/test_repo.py index 3e45300..e4400a1 100644 --- a/tests/cli/test_repo.py +++ b/tests/cli/test_repo.py @@ -193,14 +193,30 @@ def test_subcmd_token_endpoint(self, parser): class TestListParser: """``ms list`` argument parsing.""" - @pytest.mark.parametrize("repo_type", ["model", "dataset", "skill", "mcp"]) + @pytest.mark.parametrize("repo_type", ["model", "dataset", "studio", "skill", "mcp"]) def test_all_repo_types(self, parser, repo_type): args = parser.parse_args(["list", "--repo-type", repo_type]) assert args.repo_type == repo_type def test_invalid_repo_type_rejected(self, parser): with pytest.raises(SystemExit): - parser.parse_args(["list", "--repo-type", "studio"]) + parser.parse_args(["list", "--repo-type", "gallery"]) + + def test_studio_owner_listing_asks_for_every_status(self, parser, mock_api): + """The Studios endpoint keeps filtering to running spaces even with an + owner set, so listing your own would otherwise answer with nothing.""" + mock_api.list_repos.return_value = PagedResult(items=[], total_count=0) + args = parser.parse_args(["list", "--repo-type", "studio", "--owner", "alice"]) + with patch("modelscope_hub.cli.repo.make_api", return_value=mock_api): + ListCommand(args).execute() + assert mock_api.list_repos.call_args.kwargs["status"] == "all" + + def test_non_studio_listing_sends_no_status(self, parser, mock_api): + mock_api.list_repos.return_value = PagedResult(items=[], total_count=0) + args = parser.parse_args(["list", "--repo-type", "model", "--owner", "alice"]) + with patch("modelscope_hub.cli.repo.make_api", return_value=mock_api): + ListCommand(args).execute() + assert "status" not in mock_api.list_repos.call_args.kwargs def test_owner_flag(self, parser): args = parser.parse_args(["list", "--repo-type", "model", "--owner", "my-org"]) diff --git a/tests/cli/test_studio.py b/tests/cli/test_studio.py index 23b47dd..62fea32 100644 --- a/tests/cli/test_studio.py +++ b/tests/cli/test_studio.py @@ -8,6 +8,7 @@ from modelscope_hub.cli.studio import StudioCommand from modelscope_hub.constants import RepoType +from modelscope_hub.types import PagedResult, RepoInfo from .conftest import run_cli @@ -101,6 +102,81 @@ def test_secret_add(self, parser): assert args.key == "API_KEY" assert args.value == "value" + def test_list_filters(self, parser): + args = parser.parse_args( + [ + "studio", + "list", + "--search", + "chat", + "--owner", + "alice", + "--sort", + "likes", + "--status", + "all", + "--mcp-support", + "--hardware-type", + "xgpu", + "--page", + "2", + "--page-size", + "20", + ] + ) + assert args.studio_action == "list" + assert args.search == "chat" + assert args.owner == "alice" + assert args.sort == "likes" + assert args.status == "all" + assert args.mcp_support is True + assert args.hardware_type == "xgpu" + assert args.page_number == 2 + assert args.page_size == 20 + + def test_list_no_mcp_support_flag(self, parser): + args = parser.parse_args(["studio", "list", "--no-mcp-support"]) + assert args.mcp_support is False + + def test_list_rejects_unknown_sort(self, parser): + with pytest.raises(SystemExit): + parser.parse_args(["studio", "list", "--sort", "downloads"]) + + def test_variable_add(self, parser): + args = parser.parse_args(["studio", "variable", "add", "org/demo", "MODEL", "Qwen"]) + assert args.studio_action == "variable" + assert args.variable_action == "add" + assert args.key == "MODEL" + assert args.value == "Qwen" + + def test_variable_delete_has_confirmation_flag(self, parser): + args = parser.parse_args(["studio", "variable", "delete", "org/demo", "MODEL", "--yes"]) + assert args.variable_action == "delete" + assert args.yes is True + + def test_hardware_options(self, parser): + args = parser.parse_args(["studio", "hardware", "--sdk-type", "gradio", "--studio", "org/demo"]) + assert args.studio_action == "hardware" + assert args.sdk_type == "gradio" + assert args.studio_id == "org/demo" + + def test_base_images(self, parser): + args = parser.parse_args(["studio", "base-images"]) + assert args.studio_action == "base-images" + + def test_sdk_versions_defaults_to_gradio(self, parser): + args = parser.parse_args(["studio", "sdk-versions"]) + assert args.studio_action == "sdk-versions" + assert args.sdk_type == "gradio" + + def test_settings_visibility_flag(self, parser): + args = parser.parse_args(["studio", "settings", "org/demo", "--visibility", "protected"]) + assert args.visibility == "protected" + + def test_settings_visibility_and_private_are_mutually_exclusive(self, parser): + with pytest.raises(SystemExit): + parser.parse_args(["studio", "settings", "org/demo", "--visibility", "public", "--private"]) + # =================================================================== # Execution tests @@ -223,3 +299,196 @@ def test_secret_delete(self, mock_api): assert code == 0 assert "deleted" in out.lower() mock_api.delete_secret.assert_called_once_with("org/demo", "API_KEY", RepoType.STUDIO) + + # -- list --------------------------------------------------------- + def test_list_renders_studio_columns(self, mock_api): + mock_api.list_repos.return_value = PagedResult( + items=[ + RepoInfo( + owner="alice", + name="demo", + sdk_type="gradio", + hardware="cpu-basic", + likes=7, + runtime={"status": "Running"}, + ) + ], + total_count=1, + page_number=1, + page_size=10, + ) + with patch("modelscope_hub.cli.studio.make_api", return_value=mock_api): + code, out, err = run_cli(["studio", "list"]) + assert code == 0 + for expected in ("alice/demo", "gradio", "cpu-basic", "Running", "7"): + assert expected in out + + def test_list_forwards_every_filter(self, mock_api): + mock_api.list_repos.return_value = PagedResult(items=[], total_count=0) + with patch("modelscope_hub.cli.studio.make_api", return_value=mock_api): + code, out, err = run_cli( + ["studio", "list", "--owner", "alice", "--status", "all", "--hardware-type", "xgpu"] + ) + assert code == 0 + assert "(no studios found)" in out + kwargs = mock_api.list_repos.call_args.kwargs + assert kwargs["owner"] == "alice" + assert kwargs["status"] == "all" + assert kwargs["hardware_type"] == "xgpu" + + def test_owner_listing_defaults_to_every_status(self, mock_api): + """The endpoint keeps filtering to running spaces even with an owner set, + so "list my spaces" would otherwise answer with nothing.""" + mock_api.list_repos.return_value = PagedResult(items=[], total_count=0) + with patch("modelscope_hub.cli.studio.make_api", return_value=mock_api): + run_cli(["studio", "list", "--owner", "alice"]) + assert mock_api.list_repos.call_args.kwargs["status"] == "all" + + def test_explicit_status_wins_over_the_owner_default(self, mock_api): + mock_api.list_repos.return_value = PagedResult(items=[], total_count=0) + with patch("modelscope_hub.cli.studio.make_api", return_value=mock_api): + run_cli(["studio", "list", "--owner", "alice", "--status", "running"]) + assert mock_api.list_repos.call_args.kwargs["status"] == "running" + + def test_search_without_owner_leaves_the_status_to_the_server(self, mock_api): + mock_api.list_repos.return_value = PagedResult(items=[], total_count=0) + with patch("modelscope_hub.cli.studio.make_api", return_value=mock_api): + run_cli(["studio", "list", "--search", "chat"]) + assert mock_api.list_repos.call_args.kwargs["status"] is None + + def test_list_shows_protected_visibility_verbatim(self, mock_api): + """``protected`` has no integer equivalent, so it must survive as a string.""" + mock_api.list_repos.return_value = PagedResult( + items=[RepoInfo(owner="alice", name="demo", visibility="protected")], + total_count=1, + ) + with patch("modelscope_hub.cli.studio.make_api", return_value=mock_api): + code, out, err = run_cli(["studio", "list"]) + assert code == 0 + assert "protected" in out + + # -- variable ----------------------------------------------------- + def test_variable_list_shows_values(self, mock_api): + """Unlike secrets, plaintext variable values are public and are printed.""" + mock_api.list_variables.return_value = [{"key": "MODEL", "value": "Qwen2.5-7B"}] + with patch("modelscope_hub.cli.studio.make_api", return_value=mock_api): + code, out, err = run_cli(["studio", "variable", "list", "org/demo"]) + assert code == 0 + assert "MODEL" in out + assert "Qwen2.5-7B" in out + mock_api.list_variables.assert_called_once_with("org/demo", RepoType.STUDIO) + + def test_variable_list_empty(self, mock_api): + mock_api.list_variables.return_value = [] + with patch("modelscope_hub.cli.studio.make_api", return_value=mock_api): + code, out, err = run_cli(["studio", "variable", "list", "org/demo"]) + assert code == 0 + assert "(no variables)" in out + + def test_variable_add(self, mock_api): + with patch("modelscope_hub.cli.studio.make_api", return_value=mock_api): + code, out, err = run_cli(["studio", "variable", "add", "org/demo", "MODEL", "Qwen"]) + assert code == 0 + assert "added" in out.lower() + mock_api.add_variable.assert_called_once_with("org/demo", "MODEL", "Qwen", RepoType.STUDIO) + + def test_variable_update(self, mock_api): + with patch("modelscope_hub.cli.studio.make_api", return_value=mock_api): + code, out, err = run_cli(["studio", "variable", "update", "org/demo", "MODEL", "Qwen3"]) + assert code == 0 + assert "updated" in out.lower() + mock_api.update_variable.assert_called_once_with("org/demo", "MODEL", "Qwen3", RepoType.STUDIO) + + def test_variable_delete_with_yes(self, mock_api): + with patch("modelscope_hub.cli.studio.make_api", return_value=mock_api): + code, out, err = run_cli(["studio", "variable", "delete", "org/demo", "MODEL", "--yes"]) + assert code == 0 + assert "deleted" in out.lower() + mock_api.delete_variable.assert_called_once_with("org/demo", "MODEL", RepoType.STUDIO) + + def test_variable_delete_aborts_without_confirmation(self, mock_api): + with patch("modelscope_hub.cli.studio.make_api", return_value=mock_api): + with patch("builtins.input", return_value="n"): + code, out, err = run_cli(["studio", "variable", "delete", "org/demo", "MODEL"]) + assert code == 0 + assert "Aborted" in out + mock_api.delete_variable.assert_not_called() + + # -- resource discovery ------------------------------------------- + def test_hardware_table(self, mock_api): + mock_api.list_studio_hardware.return_value = [ + { + "name": "CPU basic", + "instance_type": "ecs.c6.large", + "resource_type": "cpu", + "has_stock": True, + "stock": 5, + }, + { + "name": "GPU A10", + "instance_type": "ecs.gn7i", + "resource_type": "gpu", + "gpu_type": "A10", + "has_stock": False, + "cost_after_discount": 8, + "original_cost": 10, + }, + ] + with patch("modelscope_hub.cli.studio.make_api", return_value=mock_api): + code, out, err = run_cli(["studio", "hardware", "--sdk-type", "gradio"]) + assert code == 0 + assert "CPU basic" in out + assert "ecs.gn7i" in out + assert "out of stock" in out + assert "8 (was 10)" in out + assert "paid/" in out + mock_api.list_studio_hardware.assert_called_once_with(sdk_type="gradio", repo_id=None) + + def test_hardware_scoped_to_a_studio(self, mock_api): + mock_api.list_studio_hardware.return_value = [] + with patch("modelscope_hub.cli.studio.make_api", return_value=mock_api): + code, out, err = run_cli(["studio", "hardware", "--studio", "org/demo"]) + assert code == 0 + mock_api.list_studio_hardware.assert_called_once_with(sdk_type=None, repo_id="org/demo") + + def test_base_images_table(self, mock_api): + mock_api.list_studio_base_images.return_value = [{"name": "ubuntu22.04-py311", "tag": "1.0"}] + with patch("modelscope_hub.cli.studio.make_api", return_value=mock_api): + code, out, err = run_cli(["studio", "base-images"]) + assert code == 0 + assert "ubuntu22.04-py311" in out + + def test_sdk_versions_table(self, mock_api): + mock_api.list_studio_sdk_versions.return_value = [{"sdk_type": "gradio", "version": "4.44.1", "tag": "latest"}] + with patch("modelscope_hub.cli.studio.make_api", return_value=mock_api): + code, out, err = run_cli(["studio", "sdk-versions"]) + assert code == 0 + assert "4.44.1" in out + mock_api.list_studio_sdk_versions.assert_called_once_with(sdk_type="gradio") + + def test_sdk_versions_empty_names_the_sdk_type(self, mock_api): + mock_api.list_studio_sdk_versions.return_value = [] + with patch("modelscope_hub.cli.studio.make_api", return_value=mock_api): + code, out, err = run_cli(["studio", "sdk-versions", "--sdk-type", "docker"]) + assert code == 0 + assert "docker" in out + + # -- settings ----------------------------------------------------- + def test_settings_forwards_visibility(self, mock_api): + with patch("modelscope_hub.cli.studio.make_api", return_value=mock_api): + code, out, err = run_cli(["studio", "settings", "org/demo", "--visibility", "protected"]) + assert code == 0 + mock_api.update_repo_settings.assert_called_once_with("org/demo", RepoType.STUDIO, visibility="protected") + + def test_logs_footer_uses_total_count(self, mock_api): + """The payload reports ``total_count``; reading ``total`` printed nothing.""" + mock_api.get_repo_logs.return_value = { + "logs": ["line1"], + "total_count": 42, + "total_page_num": 5, + } + with patch("modelscope_hub.cli.studio.make_api", return_value=mock_api): + code, out, err = run_cli(["studio", "logs", "org/demo"]) + assert code == 0 + assert "total 42" in out + assert "5 page(s)" in out diff --git a/tests/data/README.md b/tests/data/README.md new file mode 100644 index 0000000..2ff2355 --- /dev/null +++ b/tests/data/README.md @@ -0,0 +1,29 @@ +# Vendored ModelScope OpenAPI specification + +`openapi.json` in this directory is a verbatim copy of the live ModelScope +OpenAPI document, kept in-tree so that spec drift becomes a test failure instead +of a silent gap. + +| | | +|---|---| +| Source | `https://modelscope.cn/openapi/v1` (document served alongside it) | +| `info.version` | `1.1.0+master.20260813T030041Z` | +| `openapi` | `3.1.1` | +| Operations | 56 across 11 tags | + +## Why it is here + +`tests/test_openapi_coverage.py` reads this file and asserts that every +`operationId` in the tags the SDK claims to cover is registered in +`modelscope_hub._openapi.OPERATION_REGISTRY`. When the service publishes a new +operation, refreshing this file makes the guard fail by name rather than leaving +the gap to be discovered by hand later. + +Tags not yet implemented are listed in that test's `_DEFERRED_TAGS`; the set +doubles as the remaining to-do list. + +## Refreshing + +Replace the file with the current document and update `info.version` above. Any +new operation in a covered tag will fail the guard until it is either +implemented and registered, or explicitly deferred. diff --git a/tests/data/openapi.json b/tests/data/openapi.json new file mode 100644 index 0000000..aaed4a3 --- /dev/null +++ b/tests/data/openapi.json @@ -0,0 +1,10183 @@ +{ + "openapi": "3.1.1", + "info": { + "title": "ModelScope OpenAPI", + "description": "魔搭社区 OpenAPI 文档", + "version": "1.1.0+master.20260813T030041Z", + "termsOfService": "https://www.modelscope.cn/protocol/%E8%81%94%E7%B3%BB%E6%88%91%E4%BB%AC", + "contact": { + "name": "modelscope", + "url": "https://www.modelscope.cn/protocol/%E8%81%94%E7%B3%BB%E6%88%91%E4%BB%AC", + "email": "contact@modelscope.cn" + }, + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "servers": [ + { + "url": "https://modelscope.cn/openapi/v1" + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "tags": [ + { + "name": "User", + "description": "用户相关接口" + }, + { + "name": "Models", + "description": "模型相关接口" + }, + { + "name": "Datasets", + "description": "数据集相关接口" + }, + { + "name": "MCP", + "description": "MCP 服务相关接口" + }, + { + "name": "Studios", + "description": "创空间相关接口" + }, + { + "name": "Skills", + "description": "技能相关接口" + }, + { + "name": "Files", + "description": "文件上传相关接口" + }, + { + "name": "Agent-IDP", + "description": "Agent-IDP身份认证服务(beta版本)" + }, + { + "name": "Magicube", + "description": "魔粒体系相关接口" + }, + { + "name": "Collections", + "description": "合集相关接口" + }, + { + "name": "Galleries", + "description": "Gallery 相关接口" + } + ], + "externalDocs": { + "description": "了解更多关于 ModelScope 的信息", + "url": "https://modelscope.cn" + }, + "paths": { + "/users/me": { + "get": { + "operationId": "getCurrentUser", + "summary": "获取当前用户信息", + "description": "获取当前已认证用户的个人信息。", + "tags": [ + "User" + ], + "parameters": [], + "responses": { + "200": { + "$ref": "#/components/responses/get-current-user-success" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + }, + "503": { + "$ref": "#/components/responses/service-unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/models": { + "get": { + "operationId": "listModels", + "summary": "获取模型列表", + "description": "列出模型", + "tags": [ + "Models" + ], + "parameters": [ + { + "name": "search", + "in": "query", + "description": "针对模型名称、作者(包括组织、个人)的子字符串关键词进行搜索", + "required": false, + "schema": { + "type": "string", + "maxLength": 100, + "examples": [ + "Qwen", + "glm" + ] + } + }, + { + "name": "owner", + "in": "query", + "description": "针对模型作者(包括组织、个人)进行搜索", + "required": false, + "schema": { + "type": "string", + "examples": [ + "iic", + "ZhipuAI" + ] + } + }, + { + "name": "sort", + "in": "query", + "description": "排序方式", + "required": false, + "schema": { + "type": "string", + "enum": [ + "default", + "downloads", + "likes", + "last_modified" + ], + "default": "default", + "examples": [ + "default", + "downloads", + "likes", + "last_modified" + ] + } + }, + { + "name": "page_number", + "in": "query", + "description": "页码,限制 page_number * page_size <= 3000", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1, + "examples": [ + 1 + ] + } + }, + { + "name": "page_size", + "in": "query", + "description": "每页大小,限制 page_number * page_size <= 3000", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "default": 10, + "examples": [ + 10 + ] + } + }, + { + "name": "filter.task", + "in": "query", + "description": "按任务筛选(示例:text-generation / image-captioning)", + "required": false, + "schema": { + "type": "string", + "examples": [ + "text-generation", + "image-captioning" + ] + } + }, + { + "name": "filter.library", + "in": "query", + "description": "按库/框架筛选(示例:pytorch / safetensors / diffusers / transformer)", + "required": false, + "schema": { + "type": "string", + "examples": [ + "pytorch", + "safetensors", + "diffusers", + "transformer" + ] + } + }, + { + "name": "filter.model_type", + "in": "query", + "description": "按模型类型筛选(示例:qwen3_moe / glm4v)", + "required": false, + "schema": { + "type": "string", + "examples": [ + "qwen3_moe", + "glm4v" + ] + } + }, + { + "name": "filter.custom_tag", + "in": "query", + "description": "按自定义标签筛选(示例:llm)", + "required": false, + "schema": { + "type": "string", + "examples": [ + "common-crawl", + "markdown", + "math", + "html-parsing" + ] + } + }, + { + "name": "filter.license", + "in": "query", + "description": "按许可证筛选(示例:Apache License 2.0 / MIT License)", + "required": false, + "schema": { + "type": "string", + "examples": [ + "Apache License 2.0", + "MIT License" + ] + } + }, + { + "name": "filter.deploy", + "in": "query", + "description": "按部署方式筛选(示例:swingdeploy)", + "required": false, + "schema": { + "type": "string", + "examples": [ + "swingdeploy" + ] + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/list-models-success" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + }, + "503": { + "$ref": "#/components/responses/service-unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/models/{owner}/{repo_name}": { + "get": { + "operationId": "getModel", + "summary": "获取模型详情", + "description": "根据 owner/repo_name 获取模型详情", + "tags": [ + "Models" + ], + "parameters": [ + { + "$ref": "#/components/parameters/owner" + }, + { + "$ref": "#/components/parameters/repo_name" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/get-model-success" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + }, + "503": { + "$ref": "#/components/responses/service-unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/datasets": { + "get": { + "operationId": "listDatasets", + "summary": "获取数据集列表", + "description": "列出数据集", + "tags": [ + "Datasets" + ], + "parameters": [ + { + "name": "search", + "in": "query", + "description": "针对数据集中文名称、数据集英文名称、作者(包括组织、个人)的子字符串关键词进行搜索", + "required": false, + "schema": { + "type": "string", + "maxLength": 100, + "examples": [ + "ImageNet", + "中文语料库" + ] + } + }, + { + "name": "owner", + "in": "query", + "description": "针对数据集作者(包括组织、个人)进行搜索", + "required": false, + "schema": { + "type": "string", + "examples": [ + "modelscope", + "AI-ModelScope" + ] + } + }, + { + "name": "sort", + "in": "query", + "description": "排序方式", + "required": false, + "schema": { + "type": "string", + "enum": [ + "default", + "downloads", + "likes", + "last_modified" + ], + "default": "default", + "examples": [ + "default", + "downloads", + "likes", + "last_modified" + ] + } + }, + { + "name": "page_number", + "in": "query", + "description": "页码,限制 page_number * page_size <= 3000", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1, + "examples": [ + 1 + ] + } + }, + { + "name": "page_size", + "in": "query", + "description": "每页大小,限制 page_number * page_size <= 3000", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "default": 10, + "examples": [ + 10 + ] + } + }, + { + "name": "filter.task", + "in": "query", + "description": "按任务筛选(示例:text-classification)", + "required": false, + "schema": { + "type": "string", + "examples": [ + "text-classification" + ] + } + }, + { + "name": "filter.license", + "in": "query", + "description": "按许可证筛选(示例:Apache License 2.0 / cc-by-4.0)", + "required": false, + "schema": { + "type": "string", + "examples": [ + "Apache License 2.0", + "cc-by-4.0" + ] + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/list-datasets-success" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + }, + "503": { + "$ref": "#/components/responses/service-unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/datasets/{owner}/{repo_name}": { + "get": { + "operationId": "getDataset", + "summary": "获取数据集详情", + "description": "根据 owner/repo_name 获取数据集详情", + "tags": [ + "Datasets" + ], + "parameters": [ + { + "$ref": "#/components/parameters/owner" + }, + { + "$ref": "#/components/parameters/repo_name" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/get-dataset-success" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + }, + "503": { + "$ref": "#/components/responses/service-unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/mcp/servers": { + "put": { + "operationId": "listMcpServers", + "summary": "获取MCP服务列表", + "description": "返回查询的 ModelScope MCP 广场服务列表", + "tags": [ + "MCP" + ], + "requestBody": { + "description": "查询参数", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "search": { + "type": "string", + "description": "支持针对服务中文名称、服务英文名称、作者/所有者用户名进行搜索" + }, + "filter": { + "type": "object", + "description": "筛选条件", + "properties": { + "category": { + "type": "string", + "description": "指定筛选类别标签。与tag,is_hosted同时传入时,取三者的交集", + "examples": [ + "communication" + ] + }, + "is_hosted": { + "type": "boolean", + "description": "指定筛选hosted或未hosted MCP服务。与tag,category同时传入时,取三者的交集", + "examples": [ + true + ] + } + } + }, + "page_number": { + "type": "integer", + "description": "页码,默认为 1,限制 page_number * page_size <= 100", + "default": 1 + }, + "page_size": { + "type": "integer", + "description": "每页大小,默认为20,限制 page_number * page_size <= 100", + "default": 20 + } + } + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/list-mcp-servers-success" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + }, + "503": { + "$ref": "#/components/responses/service-unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/mcp/servers/operational": { + "get": { + "operationId": "listOperationalMcpServers", + "summary": "获取用户托管MCP服务列表", + "description": "获取用户托管MCP服务列表", + "tags": [ + "MCP" + ], + "responses": { + "200": { + "$ref": "#/components/responses/list-operational-mcp-servers-success" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + }, + "503": { + "$ref": "#/components/responses/service-unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/mcp/servers/{id}": { + "get": { + "operationId": "getMcpServer", + "summary": "获取指定MCP服务详情", + "description": "返回指定 MCP 服务的详细信息", + "tags": [ + "MCP" + ], + "parameters": [ + { + "$ref": "#/components/parameters/mcp_server_id" + }, + { + "name": "get_operational_url", + "in": "query", + "description": "传入true时返回当前用户在modelscope上托管的MCP服务链接(例如SSE_URL),默认false", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/get-mcp-server-success" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + }, + "503": { + "$ref": "#/components/responses/service-unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/mcp/servers/{id}/deploy": { + "post": { + "operationId": "deployMcpServer", + "summary": "部署MCP服务", + "description": "部署MCP服务", + "tags": [ + "MCP" + ], + "parameters": [ + { + "$ref": "#/components/parameters/mcp_server_id" + } + ], + "requestBody": { + "description": "部署参数", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "transport_type": { + "type": "string", + "description": "传输方式: sse/streamable_http", + "enum": [ + "sse", + "streamable_http" + ], + "examples": [ + "streamable_http" + ] + }, + "expiration_minutes": { + "type": "integer", + "description": "有效期(分钟),-1 代表长期有效", + "examples": [ + -1, + 60, + 1440 + ] + }, + "auth_check": { + "type": "boolean", + "description": "远程 url 连接时是否需要提供魔搭访问令牌鉴权", + "default": false + }, + "env_info": { + "type": "object", + "description": "MCP环境变量,具体需要传入的环境变量可在获取指定MCP服务详情接口返回的 env_schema 字段中获取", + "additionalProperties": true + } + } + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/deploy-mcp-server-success" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + }, + "503": { + "$ref": "#/components/responses/service-unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/mcp/servers/{id}/undeploy": { + "delete": { + "operationId": "undeployMcpServer", + "summary": "解除MCP服务部署", + "description": "解除MCP服务部署", + "tags": [ + "MCP" + ], + "parameters": [ + { + "$ref": "#/components/parameters/mcp_server_id" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/undeploy-mcp-server-success" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + }, + "503": { + "$ref": "#/components/responses/service-unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/studios": { + "get": { + "operationId": "listStudios", + "summary": "获取创空间列表", + "description": "列出创空间", + "tags": [ + "Studios" + ], + "parameters": [ + { + "name": "search", + "in": "query", + "description": "针对创空间名称、展示名称、作者(包括组织、个人)的子字符串关键词进行搜索", + "required": false, + "schema": { + "type": "string", + "maxLength": 100, + "examples": [ + "chatbot", + "demo" + ] + } + }, + { + "name": "owner", + "in": "query", + "description": "针对创空间作者(包括组织、个人)进行搜索", + "required": false, + "schema": { + "type": "string", + "examples": [ + "iic", + "ZhipuAI" + ] + } + }, + { + "name": "sort", + "in": "query", + "description": "排序方式", + "required": false, + "schema": { + "type": "string", + "enum": [ + "default", + "last_modified", + "view_num", + "likes" + ], + "default": "default", + "examples": [ + "default", + "last_modified", + "view_num", + "likes" + ] + } + }, + { + "name": "page_number", + "in": "query", + "description": "页码,限制 page_number * page_size <= 3000", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1, + "examples": [ + 1 + ] + } + }, + { + "name": "page_size", + "in": "query", + "description": "每页大小(1~50),限制 page_number * page_size <= 3000", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "default": 10, + "examples": [ + 10 + ] + } + }, + { + "name": "status", + "in": "query", + "description": "按创空间运行状态筛选。正常搜索仅支持 running;当按 owner 筛选时,默认为 all", + "required": false, + "schema": { + "type": "string", + "enum": [ + "running", + "all" + ], + "default": "running", + "examples": [ + "running", + "all" + ] + } + }, + { + "name": "mcp_support", + "in": "query", + "description": "是否支持通过MCP使用", + "required": false, + "schema": { + "type": "boolean", + "examples": [ + true, + false + ] + } + }, + { + "name": "hardware_type", + "in": "query", + "description": "根据创空间硬件类型筛选,当前仅支持筛选 xGPU", + "required": false, + "schema": { + "type": "string", + "enum": [ + "xgpu", + "amd" + ], + "examples": [ + "xgpu" + ] + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/list-studios-success" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "createStudio", + "summary": "创建创空间", + "description": "创建一个新的 Studio(创空间)", + "tags": [ + "Studios" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateStudioRequest" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/create-studio-success" + }, + "400": { + "$ref": "#/components/responses/bad-request" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "409": { + "$ref": "#/components/responses/conflict" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/studios/hardware": { + "get": { + "operationId": "listHardware", + "summary": "查询可用硬件配置", + "description": "查询创空间可用的硬件配置列表。\n- 未登录:返回默认免费硬件\n- 已登录:返回用户可用的免费硬件和带价格的付费硬件\n- 已登录且指定创空间:免费硬件以该创空间的可用免费资源为准\n- 付费资源:使用 `paid/` 作为创建或更新 Studio 时的 `hardware` 参数值\n- `sdk_type` 用于按 `supported_sdk_types` 过滤硬件配置\n", + "tags": [ + "Studios" + ], + "parameters": [ + { + "name": "sdk_type", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/StudioSDKType" + }, + "description": "过滤支持该 SDK 类型的硬件" + }, + { + "name": "studio", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "创空间路径(owner/repo_name),指定后返回该创空间可选的硬件" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/list-hardware-success" + }, + "400": { + "$ref": "#/components/responses/bad-request" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + } + }, + "security": [ + { + "bearerAuth": [] + }, + {} + ] + } + }, + "/studios/sdk-versions": { + "get": { + "operationId": "listSdkVersions", + "summary": "查询可用 SDK 版本", + "description": "查询创空间可用的 SDK 版本列表。\n仅当 `sdk_type=gradio` 时返回 Gradio 版本列表;其他 SDK 类型或未传 `sdk_type` 时返回空列表。\n", + "tags": [ + "Studios" + ], + "parameters": [ + { + "name": "sdk_type", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/StudioSDKType" + }, + "description": "SDK 类型,仅 `gradio` 返回版本列表", + "example": "gradio" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/list-sdk-versions-success" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + } + }, + "security": [ + { + "bearerAuth": [] + }, + {} + ] + } + }, + "/studios/base-images": { + "get": { + "operationId": "listBaseImages", + "summary": "查询可用基础镜像", + "description": "查询创空间可用的基础镜像列表,登录与否返回结果一致", + "tags": [ + "Studios" + ], + "responses": { + "200": { + "$ref": "#/components/responses/list-base-images-success" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + } + }, + "security": [ + { + "bearerAuth": [] + }, + {} + ] + } + }, + "/studios/{owner}/{repo_name}": { + "get": { + "operationId": "getStudio", + "summary": "获取创空间详情", + "description": "获取指定 Studio 的详细信息。\n公开(public)和公开体验(protected)类型的创空间无需认证即可访问;私有(private)创空间需要认证。\n", + "tags": [ + "Studios" + ], + "parameters": [ + { + "$ref": "#/components/parameters/studio_owner" + }, + { + "$ref": "#/components/parameters/studio_repo_name" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/get-studio-success" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + } + }, + "security": [ + { + "bearerAuth": [] + }, + {} + ] + } + }, + "/studios/{owner}/{repo_name}/settings": { + "patch": { + "operationId": "updateStudioSettings", + "summary": "更新创空间设置", + "description": "更新指定 Studio 的设置,传入哪个字段修改哪个字段,不传的字段不修改。注意:sdk_type、sdk_version、base_image、hardware 修改后需重新部署才能生效", + "tags": [ + "Studios" + ], + "parameters": [ + { + "$ref": "#/components/parameters/studio_owner" + }, + { + "$ref": "#/components/parameters/studio_repo_name" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateStudioSettingsRequest" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/update-studio-settings-success" + }, + "400": { + "$ref": "#/components/responses/bad-request" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/studios/{owner}/{repo_name}/secrets": { + "get": { + "operationId": "listStudioSecrets", + "summary": "获取创空间密文变量列表", + "description": "获取指定 Studio 的所有密文变量 key 列表,不返回 value", + "tags": [ + "Studios" + ], + "parameters": [ + { + "$ref": "#/components/parameters/studio_owner" + }, + { + "$ref": "#/components/parameters/studio_repo_name" + } + ], + "responses": { + "200": { + "description": "成功获取密文变量列表", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListStudioSecretsResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/bad-request" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "addStudioSecret", + "summary": "添加创空间密文变量", + "description": "为指定的 Studio 添加一个密文变量,value 不会公开展示", + "tags": [ + "Studios" + ], + "parameters": [ + { + "$ref": "#/components/parameters/studio_owner" + }, + { + "$ref": "#/components/parameters/studio_repo_name" + } + ], + "requestBody": { + "description": "添加密文变量请求参数", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddStudioSecretRequest" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/studio-operation-success" + }, + "400": { + "$ref": "#/components/responses/bad-request" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "409": { + "$ref": "#/components/responses/conflict" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "updateStudioSecret", + "summary": "更新创空间密文变量", + "description": "更新指定 Studio 的一个密文变量值", + "tags": [ + "Studios" + ], + "parameters": [ + { + "$ref": "#/components/parameters/studio_owner" + }, + { + "$ref": "#/components/parameters/studio_repo_name" + } + ], + "requestBody": { + "description": "更新密文变量请求参数", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateStudioSecretRequest" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/studio-operation-success" + }, + "400": { + "$ref": "#/components/responses/bad-request" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "delete": { + "operationId": "deleteStudioSecret", + "summary": "删除创空间密文变量", + "description": "删除指定 Studio 的一个密文变量", + "tags": [ + "Studios" + ], + "parameters": [ + { + "$ref": "#/components/parameters/studio_owner" + }, + { + "$ref": "#/components/parameters/studio_repo_name" + } + ], + "requestBody": { + "description": "删除密文变量请求参数", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteStudioSecretRequest" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/studio-operation-success" + }, + "400": { + "$ref": "#/components/responses/bad-request" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/studios/{owner}/{repo_name}/variables": { + "get": { + "operationId": "listStudioVariables", + "summary": "获取创空间明文变量列表", + "description": "获取指定 Studio 的所有明文变量的 key 和 value(与密文变量不同,明文变量的值公开可见)", + "tags": [ + "Studios" + ], + "parameters": [ + { + "$ref": "#/components/parameters/studio_owner" + }, + { + "$ref": "#/components/parameters/studio_repo_name" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/list-studio-variables-success" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "addStudioVariable", + "summary": "添加创空间明文变量", + "description": "为指定的 Studio 添加一个明文变量(key 和 value 都公开可见,敏感信息请使用密文变量接口)", + "tags": [ + "Studios" + ], + "parameters": [ + { + "$ref": "#/components/parameters/studio_owner" + }, + { + "$ref": "#/components/parameters/studio_repo_name" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddStudioVariableRequest" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/studio-operation-success" + }, + "400": { + "$ref": "#/components/responses/bad-request" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "409": { + "$ref": "#/components/responses/conflict" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "updateStudioVariable", + "summary": "更新创空间明文变量", + "description": "更新指定 Studio 的一个明文变量值", + "tags": [ + "Studios" + ], + "parameters": [ + { + "$ref": "#/components/parameters/studio_owner" + }, + { + "$ref": "#/components/parameters/studio_repo_name" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateStudioVariableRequest" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/studio-operation-success" + }, + "400": { + "$ref": "#/components/responses/bad-request" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "delete": { + "operationId": "deleteStudioVariable", + "summary": "删除创空间明文变量", + "description": "删除指定 Studio 的一个明文变量", + "tags": [ + "Studios" + ], + "parameters": [ + { + "$ref": "#/components/parameters/studio_owner" + }, + { + "$ref": "#/components/parameters/studio_repo_name" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteStudioVariableRequest" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/studio-operation-success" + }, + "400": { + "$ref": "#/components/responses/bad-request" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/studios/{owner}/{repo_name}/deploy": { + "post": { + "operationId": "deployStudio", + "summary": "部署创空间", + "description": "部署指定的 Studio(会重新拉取代码并重建),无论当前状态是停止还是运行中均可调用", + "tags": [ + "Studios" + ], + "parameters": [ + { + "$ref": "#/components/parameters/studio_owner" + }, + { + "$ref": "#/components/parameters/studio_repo_name" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/studio-runtime-success" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/studios/{owner}/{repo_name}/stop": { + "post": { + "operationId": "stopStudio", + "summary": "停止创空间", + "description": "停止指定的 Studio", + "tags": [ + "Studios" + ], + "parameters": [ + { + "$ref": "#/components/parameters/studio_owner" + }, + { + "$ref": "#/components/parameters/studio_repo_name" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/studio-runtime-success" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/studios/{owner}/{repo_name}/logs/{log_type}": { + "get": { + "operationId": "getStudioLogs", + "summary": "获取创空间日志", + "description": "获取指定 Studio 的运行日志", + "tags": [ + "Studios" + ], + "parameters": [ + { + "$ref": "#/components/parameters/studio_owner" + }, + { + "$ref": "#/components/parameters/studio_repo_name" + }, + { + "$ref": "#/components/parameters/studio_log_type" + }, + { + "name": "page_num", + "in": "query", + "description": "页码,默认 1", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "page_size", + "in": "query", + "description": "每页数量,默认 100,最大 500", + "required": false, + "schema": { + "type": "integer", + "default": 100, + "maximum": 500 + } + }, + { + "name": "keyword", + "in": "query", + "description": "关键字过滤", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "start_timestamp", + "in": "query", + "description": "开始时间戳(秒),可不填,自动根据 end_timestamp 计算", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "end_timestamp", + "in": "query", + "description": "结束时间戳(秒),可不填,默认当前时间", + "required": false, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/get-studio-logs-success" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/skills": { + "get": { + "operationId": "listSkills", + "summary": "获取技能列表", + "description": "返回 ModelScope 技能广场的技能列表,支持关键词搜索与多维度筛选", + "tags": [ + "Skills" + ], + "parameters": [ + { + "name": "search", + "in": "query", + "description": "关键词搜索,支持技能名称、描述等", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "filter.developer", + "in": "query", + "description": "按开发者筛选", + "required": false, + "schema": { + "type": "string" + }, + "examples": { + "amap-web": { + "value": "AMap-Web", + "summary": "开发者 AMap-Web" + } + } + }, + { + "name": "filter.category", + "in": "query", + "description": "按分类筛选", + "required": false, + "schema": { + "$ref": "#/components/schemas/SkillCategory" + }, + "examples": { + "developer-tools": { + "value": "developer-tools", + "summary": "开发工具" + }, + "ai-media": { + "value": "ai-media", + "summary": "AI 媒体" + } + } + }, + { + "name": "filter.license", + "in": "query", + "description": "按许可证筛选", + "required": false, + "schema": { + "type": "string" + }, + "examples": { + "mit": { + "value": "MIT License", + "summary": "MIT 许可证" + } + } + }, + { + "name": "filter.custom_tag", + "in": "query", + "description": "按自定义标签筛选", + "required": false, + "schema": { + "type": "string" + }, + "examples": { + "api-design": { + "value": "api-design", + "summary": "API 设计" + } + } + }, + { + "name": "filter.owner", + "in": "query", + "description": "按所有者筛选", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "page_number", + "in": "query", + "description": "页码,从 1 开始", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "page_size", + "in": "query", + "description": "每页条数", + "required": false, + "schema": { + "type": "integer", + "default": 20 + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/list-skills-success" + }, + "400": { + "$ref": "#/components/responses/skill-bad-request" + }, + "401": { + "$ref": "#/components/responses/skill-unauthorized" + }, + "500": { + "$ref": "#/components/responses/skill-internal-server-error" + }, + "503": { + "$ref": "#/components/responses/skill-service-unavailable" + } + } + }, + "post": { + "operationId": "createSkill", + "summary": "创建技能", + "description": "创建一个新的 Skill(技能)。\n\n创建流程:\n1. 先调用 `POST /files/upload` 上传 Skill 项目文件(zip 包,根目录必须且仅可包含 1 个 SKILL.md 文件),获取 `file_id`。\n2. 再调用本接口传入 `skill_file`(上一步返回的 file_id)及其他元信息完成创建。\n\n注意:`skill_name` 仅允许小写字母、数字和连字符,创建后不可修改;`owner` 创建后不可修改。\n", + "tags": [ + "Skills" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSkillRequest" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/create-skill-success" + }, + "400": { + "$ref": "#/components/responses/skill-bad-request" + }, + "401": { + "$ref": "#/components/responses/skill-unauthorized" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "409": { + "$ref": "#/components/responses/skill-conflict" + }, + "500": { + "$ref": "#/components/responses/skill-internal-server-error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/skills/{id}": { + "get": { + "operationId": "getSkill", + "summary": "获取指定技能详情", + "description": "根据技能 ID 返回指定技能的详细信息,ID 格式为 @author/skill_name(例如 @Alipay/alipay-payment-integration),其中 @ 和 / 无需 URL 编码", + "tags": [ + "Skills" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "description": "技能 ID,格式为 @author/skill_name,其中 @ 和 / 无需 URL 编码", + "required": true, + "schema": { + "type": "string" + }, + "examples": { + "alipay-payment": { + "value": "@Alipay/alipay-payment-integration", + "summary": "支付宝支付集成" + }, + "summarize": { + "value": "@steipete/summarize", + "summary": "内容总结" + } + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/get-skill-success" + }, + "401": { + "$ref": "#/components/responses/skill-unauthorized" + }, + "404": { + "$ref": "#/components/responses/skill-not-found" + }, + "500": { + "$ref": "#/components/responses/skill-internal-server-error" + }, + "503": { + "$ref": "#/components/responses/skill-service-unavailable" + } + } + } + }, + "/skills/{owner}/{skill_name}/settings": { + "patch": { + "operationId": "updateSkillSettings", + "summary": "更新技能设置", + "description": "更新指定 Skill 的设置,传入哪个字段修改哪个字段,不传的字段不修改保持原值。\n\n语义说明:\n- `skill_file` 传入时整体覆盖原有项目文件;不传则保留原文件不变。\n- `tags` 传入时整体覆盖原标签列表。\n- `logo_url` 传空字符串时恢复为平台默认图标。\n- `skill_name` 和 `owner` 创建后不可修改,故不在本接口内。\n", + "tags": [ + "Skills" + ], + "parameters": [ + { + "$ref": "#/components/parameters/skill_owner_path" + }, + { + "$ref": "#/components/parameters/skill_name_path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSkillSettingsRequest" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/update-skill-settings-success" + }, + "400": { + "$ref": "#/components/responses/skill-bad-request" + }, + "401": { + "$ref": "#/components/responses/skill-unauthorized" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/skill-not-found" + }, + "500": { + "$ref": "#/components/responses/skill-internal-server-error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/files/upload": { + "post": { + "operationId": "uploadFile", + "summary": "上传文件", + "description": "通用文件上传接口(最大 5MB),返回文件 ID。该 ID 可作为入参传给其他接口,用于引用已上传的文件。", + "tags": [ + "Files" + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/UploadFileRequest" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/upload-file-success" + }, + "400": { + "$ref": "#/components/responses/bad-request" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "413": { + "$ref": "#/components/responses/bad-request" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/agent_ids": { + "post": { + "operationId": "createAgentIdentity", + "summary": "创建 Agent 身份", + "description": "客户端本地生成 Ed25519 密钥对后,将公钥(JWK 格式)上传到 ModelScope,注册一个新的 Agent 身份。\n\n- 私钥仅由客户端保存,服务端只存储公钥\n- `public_key` 必须包含 `kty=OKP`、`crv=Ed25519`、`x`(base64url 无 padding 编码的公钥)和 `kid`\n- `token_expire_time` 可选 300/600/1800/3600 秒(默认 3600)\n", + "tags": [ + "Agent-IDP" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAgentIdentityRequest" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/create-agent-identity-success" + }, + "400": { + "$ref": "#/components/responses/bad-request" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/agent_ids/{agent_id}": { + "get": { + "operationId": "getAgentIdentity", + "summary": "获取 Agent 身份详情", + "description": "根据 agent_id 返回该 Agent 的完整信息(含当前公钥、状态、Token 配置等)", + "tags": [ + "Agent-IDP" + ], + "parameters": [ + { + "name": "agent_id", + "in": "path", + "description": "Agent 身份 ID", + "required": true, + "schema": { + "type": "string" + }, + "examples": { + "sample": { + "value": "agent_id:modelscope:agent_1234567890ab" + } + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/get-agent-identity-success" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "operationId": "updateAgentIdentity", + "summary": "更新 Agent 身份设置", + "description": "更新 Agent 名称、描述或 Token 过期时间。所有字段均为可选。", + "tags": [ + "Agent-IDP" + ], + "parameters": [ + { + "name": "agent_id", + "in": "path", + "description": "Agent 身份 ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateAgentIdentityRequest" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/update-agent-identity-success" + }, + "400": { + "$ref": "#/components/responses/bad-request" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "delete": { + "operationId": "deleteAgentIdentity", + "summary": "删除 Agent 身份", + "description": "删除指定的 Agent 身份。删除后该 Agent 将无法再签发 Token,已签发的 Token 仍可使用直至过期。", + "tags": [ + "Agent-IDP" + ], + "parameters": [ + { + "name": "agent_id", + "in": "path", + "description": "Agent 身份 ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/delete-agent-identity-success" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/agent_ids/{agent_id}/key_pairs": { + "put": { + "operationId": "resetAgentKeyPair", + "summary": "重置 Agent 密钥对", + "description": "客户端本地生成新的 Ed25519 密钥对后,上传新公钥替换旧公钥。\n\n- 旧 kid 立即失效,新 kid 立即生效\n- 已签发的旧 Token 不受影响(仍可使用直至过期),新签发的 Token 使用新 kid\n", + "tags": [ + "Agent-IDP" + ], + "parameters": [ + { + "name": "agent_id", + "in": "path", + "description": "Agent 身份 ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetKeyPairRequest" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/reset-key-pair-success" + }, + "400": { + "$ref": "#/components/responses/bad-request" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/agent_ids/{agent_id}/paused": { + "post": { + "operationId": "pauseAgent", + "summary": "启用/停用 Agent", + "description": "设置 Agent 的 paused 状态:\n\n- `paused=true` 停用:该 Agent 无法再签发 Token,但已签发的 Token 仍有效\n- `paused=false` 启用:恢复签发 Token 能力\n", + "tags": [ + "Agent-IDP" + ], + "parameters": [ + { + "name": "agent_id", + "in": "path", + "description": "Agent 身份 ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PauseAgentRequest" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/pause-agent-success" + }, + "400": { + "$ref": "#/components/responses/bad-request" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/agent_ids/{agent_id}/jwt_id_tokens": { + "get": { + "operationId": "listAgentTokenRecords", + "summary": "获取 Agent 的 Token 签发记录", + "description": "按 audience 维度返回该 Agent 最近一次给每个 Hub 应用签发的 Token 记录。\n", + "tags": [ + "Agent-IDP" + ], + "parameters": [ + { + "name": "agent_id", + "in": "path", + "description": "Agent 身份 ID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "description": "页码(默认 1)", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "page_size", + "in": "query", + "description": "每页数量(默认 20,最大 50)", + "required": false, + "schema": { + "type": "integer", + "default": 20, + "maximum": 50 + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/list-token-records-success" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/users/{username}/agent_ids": { + "get": { + "operationId": "listUserAgentIdentities", + "summary": "获取指定用户的 Agent 身份列表", + "description": "按用户名维度返回该用户名下所有 Agent 身份摘要列表,支持分页和按状态筛选。", + "tags": [ + "Agent-IDP" + ], + "parameters": [ + { + "name": "username", + "in": "path", + "description": "用户名", + "required": true, + "schema": { + "type": "string" + }, + "examples": { + "sample": { + "value": "alice" + } + } + }, + { + "name": "status", + "in": "query", + "description": "按状态筛选", + "required": false, + "schema": { + "$ref": "#/components/schemas/AgentIdentityStatus" + } + }, + { + "name": "page", + "in": "query", + "description": "页码(默认 1)", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "page_size", + "in": "query", + "description": "每页数量(默认 20,最大 50)", + "required": false, + "schema": { + "type": "integer", + "default": 20, + "maximum": 50 + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/list-agent-identities-success" + }, + "400": { + "$ref": "#/components/responses/bad-request" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/agent_id/token": { + "post": { + "operationId": "issueAgentToken", + "summary": "签发 Agent Token", + "description": "Agent 用其私钥对 message=`agent_id|kid|audience|timestamp` 进行 Ed25519 签名,\n服务端用注册时上传的公钥验签通过后,签发一个面向指定 Hub 应用(audience)的 JWT。\n\n本端点为公开端点,不需要 bearerAuth;签名本身即为身份证明。\n", + "tags": [ + "Agent-IDP" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenSignRequest" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/issue-token-success" + }, + "400": { + "$ref": "#/components/responses/bad-request" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + } + }, + "security": [] + } + }, + "/agent_id/.well-known/agentid-configuration": { + "get": { + "operationId": "getAgentIdConfiguration", + "summary": "获取 Agent IDP OIDC 配置", + "description": "OIDC Discovery 端点,返回 Agent IDP 服务的核心元数据:\n`issuer`、`token_endpoint`、`jwks_uri`、`registration_endpoint`、`activity_endpoint`、\n`id_token_signing_alg_values_supported`。\n\n客户端及外部接入方应通过本端点动态发现各端点 URL,避免硬编码。\n本端点为公开端点,不需要 bearerAuth。\n", + "tags": [ + "Agent-IDP" + ], + "responses": { + "200": { + "$ref": "#/components/responses/agentid-configuration-success" + }, + "429": { + "$ref": "#/components/responses/too-many-requests" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + } + }, + "security": [] + } + }, + "/agent_id/.well-known/agentid-jwks": { + "get": { + "operationId": "getAgentIdJWKS", + "summary": "获取 Agent IDP JWKS", + "description": "返回服务端用于签发 Agent JWT 的公钥集合(JSON Web Key Set)。\n\n资源方收到 Agent JWT 后,应通过本端点拉取 JWKS,根据 JWT header 中的 `kid` 选择对应公钥验签。\n支持多 kid 同时存在以实现密钥轮换:旧 kid 在过渡期内继续返回,新签发的 JWT 使用新 kid。\n\n本端点为公开端点,不需要 bearerAuth。\n", + "tags": [ + "Agent-IDP" + ], + "responses": { + "200": { + "$ref": "#/components/responses/agentid-jwks-success" + }, + "429": { + "$ref": "#/components/responses/too-many-requests" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + } + }, + "security": [] + } + }, + "/magicubes/balance": { + "get": { + "operationId": "getBalance", + "summary": "查询余额", + "description": "查询当前用户的魔粒余额信息", + "tags": [ + "Magicube" + ], + "responses": { + "200": { + "$ref": "#/components/responses/get-balance-success" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/collections": { + "get": { + "operationId": "listCollections", + "summary": "获取 Collection 列表", + "description": "获取 Collection 列表,支持搜索、按所有者过滤与排序、分页。公开列表无需 Token;查询私有 Collection 需 Bearer Token。", + "tags": [ + "Collections" + ], + "parameters": [ + { + "name": "search", + "in": "query", + "description": "针对标题、描述的子字符串搜索", + "required": false, + "schema": { + "type": "string", + "examples": [ + "nlp" + ] + } + }, + { + "name": "owner", + "in": "query", + "description": "所有者过滤。仅允许过滤当前 Token 所属用户自己的 Collection,过滤他人(或匿名使用)返回 403 OperationNotAllowed。", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sort", + "in": "query", + "description": "排序方式:default(综合)/ last_modified(最近更新)/ likes(喜欢数)", + "required": false, + "schema": { + "type": "string", + "enum": [ + "default", + "last_modified", + "likes" + ], + "default": "default", + "examples": [ + "likes" + ] + } + }, + { + "name": "page_number", + "in": "query", + "description": "页码(≥1)", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1, + "examples": [ + 1 + ] + } + }, + { + "name": "page_size", + "in": "query", + "description": "每页大小(1~50)", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "default": 10, + "examples": [ + 10 + ] + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/list-collections-success" + }, + "400": { + "$ref": "#/components/responses/bad-request" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + }, + "503": { + "$ref": "#/components/responses/service-unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + }, + {} + ] + }, + "post": { + "operationId": "createCollection", + "summary": "创建 Collection", + "description": "创建一个新的 Collection。", + "tags": [ + "Collections" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCollectionRequest" + }, + "examples": { + "create-collection-request": { + "value": { + "title": "最佳 NLP 模型合集", + "owner": "iSolver", + "description": "精选的自然语言处理模型...", + "visibility": "public", + "theme": "Blue" + } + } + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/create-collection-success" + }, + "400": { + "$ref": "#/components/responses/bad-request" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + }, + "503": { + "$ref": "#/components/responses/service-unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/collections/{owner}/{slug}": { + "get": { + "operationId": "getCollection", + "summary": "获取 Collection 详情", + "description": "根据 {owner}/{slug} 获取 Collection 详情。visibility=public 时 Token 可选;visibility=private 时必填 Bearer Token。", + "tags": [ + "Collections" + ], + "parameters": [ + { + "$ref": "#/components/parameters/collection_owner_path" + }, + { + "$ref": "#/components/parameters/collection_slug_path" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/get-collection-success" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + }, + "503": { + "$ref": "#/components/responses/service-unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + }, + {} + ] + }, + "patch": { + "operationId": "updateCollection", + "summary": "更新 Collection", + "description": "更新 Collection 元数据。传入哪个字段即更新哪个字段,未传字段保持原值。open/close 语义映射为 visibility(public/private)。", + "tags": [ + "Collections" + ], + "parameters": [ + { + "$ref": "#/components/parameters/collection_owner_path" + }, + { + "$ref": "#/components/parameters/collection_slug_path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCollectionRequest" + }, + "examples": { + "update-collection-request": { + "value": { + "title": "最佳 NLP 模型合集(已更名)", + "visibility": "private" + } + } + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/update-collection-success" + }, + "400": { + "$ref": "#/components/responses/bad-request" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + }, + "503": { + "$ref": "#/components/responses/service-unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "delete": { + "operationId": "deleteCollection", + "summary": "删除 Collection", + "description": "删除指定 {owner}/{slug} 的 Collection。需具备 admin 权限的 Bearer Token。", + "tags": [ + "Collections" + ], + "parameters": [ + { + "$ref": "#/components/parameters/collection_owner_path" + }, + { + "$ref": "#/components/parameters/collection_slug_path" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/delete-collection-success" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + }, + "503": { + "$ref": "#/components/responses/service-unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/collections/{owner}/{slug}/items": { + "get": { + "operationId": "listCollectionItems", + "summary": "获取 Collection 条目列表", + "description": "分页获取指定 Collection 的条目列表,支持按资源类型过滤。visibility=public 时 Token 可选;private 时必填。", + "tags": [ + "Collections" + ], + "parameters": [ + { + "$ref": "#/components/parameters/collection_owner_path" + }, + { + "$ref": "#/components/parameters/collection_slug_path" + }, + { + "name": "item_type", + "in": "query", + "description": "按资源类型过滤:model / dataset / studio / paper / skill / mcp", + "required": false, + "schema": { + "type": "string", + "enum": [ + "model", + "dataset", + "studio", + "paper", + "skill", + "mcp" + ], + "examples": [ + "model" + ] + } + }, + { + "name": "page_number", + "in": "query", + "description": "页码(≥1)", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1, + "examples": [ + 1 + ] + } + }, + { + "name": "page_size", + "in": "query", + "description": "每页大小(1~50)", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "default": 10, + "examples": [ + 10 + ] + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/list-collection-items-success" + }, + "400": { + "$ref": "#/components/responses/bad-request" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + }, + "503": { + "$ref": "#/components/responses/service-unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + }, + {} + ] + }, + "post": { + "operationId": "addCollectionItems", + "summary": "添加 Collection 条目", + "description": "向 Collection 批量添加条目。传入 items 数组。\n\n异常处理:若部分条目不存在、已在 Collection 中或无权限,接口不整体回滚,返回 success=true,并在 data.failed_items 中列出失败项;\n全部失败时仍返回 success=true、added_count=0。仅请求参数格式错误返回 400。部分失败不返回 code 字段。\n", + "tags": [ + "Collections" + ], + "parameters": [ + { + "$ref": "#/components/parameters/collection_owner_path" + }, + { + "$ref": "#/components/parameters/collection_slug_path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddCollectionItemsRequest" + }, + "examples": { + "add-collection-items-request": { + "$ref": "#/components/examples/add-collection-items-request" + } + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/add-collection-items-success" + }, + "400": { + "$ref": "#/components/responses/bad-request" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + }, + "503": { + "$ref": "#/components/responses/service-unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "operationId": "updateCollectionItems", + "summary": "批量更新 Collection 条目", + "description": "批量更新 Collection 条目(P2)。通过每个条目的 item_type + item_object_id 定位,传入哪个字段就修改哪个字段。\n\n异常处理:若部分条目不在当前 Collection 或对应资源不存在,接口不整体回滚,返回 success=true,并在 data.failed_items 中列出失败项;\n全部失败时仍返回 success=true、updated_count=0。仅请求参数格式错误返回 400。部分失败不返回 code 字段。\n", + "tags": [ + "Collections" + ], + "parameters": [ + { + "$ref": "#/components/parameters/collection_owner_path" + }, + { + "$ref": "#/components/parameters/collection_slug_path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCollectionItemsRequest" + }, + "examples": { + "update-collection-items-request": { + "$ref": "#/components/examples/update-collection-items-request" + } + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/update-collection-items-success" + }, + "400": { + "$ref": "#/components/responses/bad-request" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + }, + "503": { + "$ref": "#/components/responses/service-unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/collections/{owner}/{slug}/items/{item_type}/{item_object_id}": { + "patch": { + "operationId": "updateCollectionItem", + "summary": "更新单个 Collection 条目", + "description": "通过 item_type + item_object_id 定位并更新单个条目。传入哪个字段即更新哪个字段。", + "tags": [ + "Collections" + ], + "parameters": [ + { + "$ref": "#/components/parameters/collection_owner_path" + }, + { + "$ref": "#/components/parameters/collection_slug_path" + }, + { + "$ref": "#/components/parameters/collection_item_type_path" + }, + { + "$ref": "#/components/parameters/collection_item_object_id_path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCollectionItemRequest" + }, + "examples": { + "update-collection-item-request": { + "$ref": "#/components/examples/update-collection-item-request" + } + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/update-collection-item-success" + }, + "400": { + "$ref": "#/components/responses/bad-request" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + }, + "503": { + "$ref": "#/components/responses/service-unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "delete": { + "operationId": "removeCollectionItem", + "summary": "移除 Collection 条目", + "description": "通过 item_type + item_object_id 定位并从 Collection 中移除单个条目。", + "tags": [ + "Collections" + ], + "parameters": [ + { + "$ref": "#/components/parameters/collection_owner_path" + }, + { + "$ref": "#/components/parameters/collection_slug_path" + }, + { + "$ref": "#/components/parameters/collection_item_type_path" + }, + { + "$ref": "#/components/parameters/collection_item_object_id_path" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/remove-collection-item-success" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + }, + "503": { + "$ref": "#/components/responses/service-unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/galleries": { + "post": { + "operationId": "createGallery", + "summary": "创建 Gallery", + "description": "创建一个新的灵感流/Gallery 内容。\n\n**权限要求**\n\n- 基线要求:已注册用户。\n- 若上传到组织名下,用户须为该组织的管理员或写权限成员。\n- 若上传到个人名下,需为和 token 身份指向一致的个人用户。\n\n**字段说明**\n\n- name 名称、labels 标签:需满足 CSI 内容安全审核要求,不合法返回 403。\n- path 自定义 URL 尾缀:系统会校验 owner/path 是否已存在,重复返回 400。\n- entry_file 入口文件:文件后缀须与 category 匹配(后缀不区分大小写,如 .IPYNB 等价于 .ipynb):notebook→.ipynb,website→.html,pdf→.pdf;file 类别不限后缀,不匹配返回 400。\n- Gallery ID:由服务端生成 UUID v4,不可自定义。\n- 封面颜色:系统随机分配封面颜色模板(purple / pink / blue / turquoise / green)。\n", + "tags": [ + "Galleries" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateGalleryRequest" + }, + "examples": { + "create-gallery-request": { + "$ref": "#/components/examples/create-gallery-request" + } + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/create-gallery-success" + }, + "400": { + "$ref": "#/components/responses/bad-request" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + } + }, + "components": { + "schemas": { + "User": { + "allOf": [ + { + "$ref": "#/components/schemas/UserPublic" + }, + { + "$ref": "#/components/schemas/UserPrivate" + } + ], + "description": "用户完整信息(包含私有信息)" + }, + "UserResponse": { + "type": "object", + "description": "用户信息响应", + "properties": { + "success": { + "type": "boolean", + "description": "成功响应时始终为 true", + "examples": [ + true + ] + }, + "data": { + "$ref": "#/components/schemas/User", + "description": "用户信息" + }, + "request_id": { + "type": "string", + "description": "请求ID", + "examples": [ + "4d3f4b7d-95be-4e6f-95eb-d75178375bd2" + ] + } + }, + "required": [ + "success", + "request_id" + ] + }, + "ErrorResponse": { + "type": "object", + "description": "错误响应格式", + "properties": { + "success": { + "type": "boolean", + "description": "错误响应时始终为 false", + "examples": [ + false + ] + }, + "code": { + "type": "string", + "description": "错误码", + "examples": [ + "InputParameterError" + ] + }, + "message": { + "type": "string", + "description": "错误消息", + "examples": [ + "Unauthorized access" + ] + }, + "request_id": { + "type": "string", + "description": "请求ID", + "examples": [ + "4d3f4b7d-95be-4e6f-95eb-d75178375bd2" + ] + } + }, + "required": [ + "success", + "code", + "message", + "request_id" + ] + }, + "Model": { + "type": "object", + "description": "模型摘要信息", + "properties": { + "id": { + "type": "string", + "description": "模型仓库名(repo_id,格式为 owner/repo_name)", + "examples": [ + "iic/QwenLong-L1.5-30B-A3B", + "ZhipuAI/AutoGLM-Phone-9B" + ] + }, + "display_name": { + "type": [ + "string", + "null" + ], + "description": "模型展示名称", + "examples": [ + "QwenLong-L1.5-30B-A3B", + "AutoGLM-Phone-9B" + ] + }, + "description": { + "type": [ + "string", + "null" + ], + "description": "模型简介", + "examples": [ + "" + ] + }, + "downloads": { + "type": "integer", + "description": "下载量", + "examples": [ + 1466, + 28525 + ] + }, + "likes": { + "type": "integer", + "description": "喜欢量", + "examples": [ + 25, + 265 + ] + }, + "license": { + "type": [ + "string", + "null" + ], + "description": "许可证", + "examples": [ + "apache-2.0", + "MIT License" + ] + }, + "tasks": { + "type": "array", + "description": "任务列表", + "items": { + "type": "string" + }, + "examples": [ + [ + "text-generation" + ], + [ + "image-captioning" + ] + ] + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "创建时间(ISO 8601 UTC)", + "examples": [ + "2025-12-14T12:26:15Z" + ] + }, + "last_modified": { + "type": "string", + "format": "date-time", + "description": "Git 文件或设置的最后修改时间(ISO 8601 UTC)", + "examples": [ + "2025-12-16T03:34:26Z" + ] + }, + "file_size": { + "type": "integer", + "description": "文件大小(字节)", + "examples": [ + 61081671123 + ] + }, + "params": { + "type": "integer", + "description": "参数量", + "examples": [ + 30532122624 + ] + }, + "tags": { + "type": "array", + "description": "标签列表", + "items": { + "type": "string" + }, + "examples": [ + [ + "license:apache-2.0", + "model_type:qwen3_moe", + "library:transformer", + "library:safetensors", + "library:pytorch", + "task:text-generation", + "deploy:swingdeploy" + ] + ] + }, + "private": { + "type": "boolean", + "description": "是否非公开模型", + "examples": [ + false + ] + }, + "gated": { + "type": "boolean", + "description": "是否申请制模型,通过申请后可访问", + "examples": [ + false + ] + } + }, + "required": [ + "id", + "downloads", + "likes", + "tasks", + "created_at", + "last_modified", + "file_size", + "params", + "private", + "gated" + ] + }, + "ModelList": { + "type": "object", + "description": "模型列表与分页信息", + "properties": { + "models": { + "type": "array", + "description": "结果模型列表", + "items": { + "$ref": "#/components/schemas/Model" + } + }, + "total_count": { + "type": "integer", + "description": "符合筛选条件的模型总数", + "examples": [ + 43127 + ] + }, + "page_number": { + "type": "integer", + "description": "当前页码", + "examples": [ + 1 + ] + }, + "page_size": { + "type": "integer", + "description": "每页大小", + "examples": [ + 10 + ] + } + }, + "required": [ + "models", + "total_count", + "page_number", + "page_size" + ] + }, + "ModelListResponse": { + "type": "object", + "description": "列出模型的标准响应包裹格式", + "properties": { + "success": { + "type": "boolean", + "description": "成功时始终为 true", + "examples": [ + true + ] + }, + "data": { + "$ref": "#/components/schemas/ModelList", + "description": "模型列表数据" + }, + "request_id": { + "type": "string", + "description": "请求 ID", + "examples": [ + "4d3f4b7d-95be-4e6f-95eb-d75178375bd3" + ] + } + }, + "required": [ + "success", + "data", + "request_id" + ] + }, + "ModelDetail": { + "allOf": [ + { + "$ref": "#/components/schemas/Model" + }, + { + "type": "object", + "description": "模型详情信息", + "properties": { + "readme": { + "type": [ + "string", + "null" + ], + "description": "README 内容", + "examples": [ + "hello" + ] + } + } + } + ] + }, + "ModelDetailResponse": { + "type": "object", + "description": "获取模型详情的标准响应包裹格式", + "properties": { + "success": { + "type": "boolean", + "description": "成功时始终为 true", + "examples": [ + true + ] + }, + "data": { + "$ref": "#/components/schemas/ModelDetail", + "description": "模型详情数据" + }, + "request_id": { + "type": "string", + "description": "请求 ID", + "examples": [ + "4d3f4b7d-95be-4e6f-95eb-d75178375bd3" + ] + } + }, + "required": [ + "success", + "data", + "request_id" + ] + }, + "Dataset": { + "type": "object", + "description": "数据集摘要信息", + "properties": { + "id": { + "type": "string", + "description": "数据集仓库名(repo_id,格式为 owner/repo_name)", + "examples": [ + "DAMO_NLP/jd", + "iic/nlp_domain_classification_chinese_testset" + ] + }, + "display_name": { + "type": [ + "string", + "null" + ], + "description": "数据集展示名称", + "examples": [ + "商品评论情感预测", + "中文文本领域分类测试集" + ] + }, + "description": { + "type": [ + "string", + "null" + ], + "description": "数据集简介", + "examples": [ + "", + "..." + ] + }, + "file_size": { + "type": "integer", + "description": "文件大小(字节)", + "examples": [ + 0 + ] + }, + "downloads": { + "type": "integer", + "description": "下载量", + "examples": [ + 53996, + 2401 + ] + }, + "likes": { + "type": "integer", + "description": "喜欢量", + "examples": [ + 102, + 23 + ] + }, + "license": { + "type": [ + "string", + "null" + ], + "description": "许可证", + "examples": [ + "Apache License 2.0", + "cc-by-4.0" + ] + }, + "tasks": { + "type": "array", + "description": "任务列表", + "items": { + "type": "string" + }, + "examples": [ + [ + "text-classification" + ], + [] + ] + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "创建时间(ISO 8601 UTC)", + "examples": [ + "2022-09-28T06:30:31Z", + "2025-11-25T14:30:22Z" + ] + }, + "last_modified": { + "type": "string", + "format": "date-time", + "description": "Git 文件或设置的最后修改时间(ISO 8601 UTC)", + "examples": [ + "2022-10-30T03:36:39Z", + "2026-01-01T00:31:42Z" + ] + }, + "tags": { + "type": "array", + "description": "标签列表", + "items": { + "type": "string" + }, + "examples": [ + [ + "license:Apache License 2.0", + "task:text-classification" + ], + [ + "license:cc-by-4.0", + "custom_tag:common-crawl" + ] + ] + }, + "private": { + "type": "boolean", + "description": "是否非公开数据集", + "examples": [ + false + ] + }, + "gated": { + "type": "boolean", + "description": "是否申请制数据集,通过申请后可访问", + "examples": [ + false + ] + }, + "login_required": { + "type": "boolean", + "description": "是否登录后才可下载", + "examples": [ + false + ] + } + }, + "required": [ + "id", + "downloads", + "likes", + "private", + "gated", + "login_required", + "created_at", + "last_modified" + ] + }, + "DatasetList": { + "type": "object", + "description": "数据集列表与分页信息", + "properties": { + "datasets": { + "type": "array", + "description": "结果数据集列表", + "items": { + "$ref": "#/components/schemas/Dataset" + } + }, + "total_count": { + "type": "integer", + "description": "符合筛选条件的数据集总数", + "examples": [ + 60 + ] + }, + "page_number": { + "type": "integer", + "description": "当前页码", + "examples": [ + 1 + ] + }, + "page_size": { + "type": "integer", + "description": "每页大小", + "examples": [ + 10 + ] + } + }, + "required": [ + "datasets", + "total_count", + "page_number", + "page_size" + ] + }, + "DatasetListResponse": { + "type": "object", + "description": "列出数据集的标准响应包裹格式", + "properties": { + "success": { + "type": "boolean", + "description": "成功时始终为 true", + "examples": [ + true + ] + }, + "data": { + "$ref": "#/components/schemas/DatasetList", + "description": "数据集列表数据" + }, + "request_id": { + "type": "string", + "description": "请求 ID", + "examples": [ + "4d3f4b7d-95be-4e6f-95eb-d75178375bd3" + ] + } + }, + "required": [ + "success", + "data", + "request_id" + ] + }, + "DatasetDetail": { + "allOf": [ + { + "$ref": "#/components/schemas/Dataset" + }, + { + "type": "object", + "description": "数据集详情信息", + "properties": { + "readme": { + "type": [ + "string", + "null" + ], + "description": "README 内容", + "examples": [ + "hello" + ] + } + } + } + ] + }, + "DatasetDetailResponse": { + "type": "object", + "description": "获取数据集详情的标准响应包裹格式", + "properties": { + "success": { + "type": "boolean", + "description": "成功时始终为 true", + "examples": [ + true + ] + }, + "data": { + "$ref": "#/components/schemas/DatasetDetail", + "description": "数据集详情数据" + }, + "request_id": { + "type": "string", + "description": "请求 ID", + "examples": [ + "4d3f4b7d-95be-4e6f-95eb-d75178375bd3" + ] + } + }, + "required": [ + "success", + "data", + "request_id" + ] + }, + "McpServerLocale": { + "type": "object", + "description": "MCP服务在对应语言环境下的内容", + "properties": { + "name": { + "type": "string", + "description": "MCP服务在对应语言环境下的服务名称", + "examples": [ + "Fetch网页内容抓取" + ] + }, + "description": { + "type": "string", + "description": "MCP服务在对应语言环境下的服务简介", + "examples": [ + "该服务器使大型语言模型能够检索和处理网页内容..." + ] + }, + "readme": { + "type": "string", + "description": "MCP服务介绍(仅详情接口返回)", + "examples": [ + "# Fetch MCP Server..." + ] + } + } + }, + "McpServerLocales": { + "type": "object", + "description": "不同语言环境下的内容展示", + "properties": { + "zh": { + "$ref": "#/components/schemas/McpServerLocale", + "description": "中文环境" + }, + "en": { + "$ref": "#/components/schemas/McpServerLocale", + "description": "英文环境" + } + } + }, + "McpOperationalUrl": { + "type": "object", + "description": "MCP服务托管链接信息", + "properties": { + "id": { + "type": "string", + "description": "MCP服务链接命名标识", + "examples": [ + "platform-pool" + ] + }, + "url": { + "type": "string", + "description": "MCP服务远程连接地址", + "examples": [ + "https://mcp.api-inference.modelscope.net/xxxx/mcp" + ] + }, + "transport_type": { + "type": "string", + "description": "MCP服务链接传输方式", + "examples": [ + "streamable_http", + "sse" + ] + }, + "auth_required": { + "type": "boolean", + "description": "远程 url 连接时是否需要提供魔搭访问令牌鉴权", + "examples": [ + false + ] + }, + "expiration": { + "type": "string", + "description": "MCP服务链接有效期", + "examples": [ + "2025-10-01 21:00:00" + ] + }, + "accessible": { + "type": "boolean", + "description": "是否有权限访问该MCP服务详情。当所有者设置为非公开或MCP服务被删除时,无权访问。", + "examples": [ + true + ] + } + } + }, + "McpServerSummary": { + "type": "object", + "description": "MCP服务摘要信息", + "properties": { + "id": { + "type": "string", + "description": "服务ID。由平台后台收录的MCP,ID 默认为@author/server_name,当被社区用户认领后将会变更为user_name/server_name;由社区用户自发提交的MCP,ID 为user_name/server_name。", + "examples": [ + "@modelcontextprotocol/fetch" + ] + }, + "name": { + "type": "string", + "description": "MCP服务在魔搭社区的名称", + "examples": [ + "Fetch网页内容抓取" + ] + }, + "chinese_name": { + "type": "string", + "description": "MCP服务在魔搭社区的中文名称", + "examples": [ + "Fetch网页内容抓取" + ] + }, + "description": { + "type": "string", + "description": "MCP服务简介", + "examples": [ + "该服务器使大型语言模型能够检索和处理网页内容..." + ] + }, + "logo_url": { + "type": "string", + "description": "logo图片URL链接", + "examples": [ + "https://resources.modelscope.cn/..." + ] + }, + "publisher": { + "type": "string", + "description": "发布时原始ID", + "examples": [ + "@modelcontextprotocol/fetch" + ] + }, + "categories": { + "type": "array", + "description": "该MCP服务所属分类", + "items": { + "type": "string" + }, + "examples": [ + [ + "browser-automation" + ] + ] + }, + "tags": { + "type": "array", + "description": "该MCP服务的标签列表", + "items": { + "type": "string" + }, + "examples": [ + [ + "browser-automation" + ] + ] + }, + "view_count": { + "type": "integer", + "description": "该MCP服务的累计访问量", + "examples": [ + 30667 + ] + }, + "locales": { + "$ref": "#/components/schemas/McpServerLocales" + } + }, + "required": [ + "id" + ] + }, + "McpServerDetail": { + "type": "object", + "description": "MCP服务详细信息", + "allOf": [ + { + "$ref": "#/components/schemas/McpServerSummary" + }, + { + "type": "object", + "properties": { + "author": { + "type": "string", + "description": "MCP服务开发者", + "examples": [ + "modelcontextprotocol" + ] + }, + "owner": { + "type": "string", + "description": "MCP服务归属的魔搭用户/组织ID", + "examples": [ + "" + ] + }, + "readme": { + "type": "string", + "description": "MCP服务介绍", + "examples": [ + "# Fetch MCP Server..." + ] + }, + "source_url": { + "type": "string", + "description": "MCP服务来源项目主页地址", + "examples": [ + "https://github.com/modelcontextprotocol/..." + ] + }, + "github_stars": { + "type": "integer", + "description": "该 MCP 服务对应 github 原始仓库的 stars 数量", + "examples": [ + 1234 + ] + }, + "is_hosted": { + "type": "boolean", + "description": "该MCP服务是否支持托管部署", + "examples": [ + true + ] + }, + "is_verified": { + "type": "boolean", + "description": "托管MCP服务是否经过平台验证测试", + "examples": [ + true + ] + }, + "env_schema": { + "type": "object", + "description": "该服务连接部署的环境变量配置", + "additionalProperties": true + }, + "server_config": { + "type": "array", + "description": "MCP服务配置信息", + "items": {} + }, + "operational_urls": { + "type": "array", + "description": "当get_operational_url=True且当前用户已连接该 MCP 服务时则返回,否则为空", + "items": { + "$ref": "#/components/schemas/McpOperationalUrl" + } + } + } + } + ] + }, + "McpServerOperational": { + "type": "object", + "description": "用户托管的MCP服务信息", + "allOf": [ + { + "$ref": "#/components/schemas/McpServerSummary" + }, + { + "type": "object", + "properties": { + "operational_urls": { + "type": "array", + "description": "MCP服务托管链接列表", + "items": { + "$ref": "#/components/schemas/McpOperationalUrl" + } + } + } + } + ] + }, + "McpServerListResponse": { + "type": "object", + "description": "MCP服务列表响应", + "properties": { + "success": { + "type": "boolean", + "description": "成功时始终为 true", + "examples": [ + true + ] + }, + "data": { + "$ref": "#/components/schemas/McpServerList", + "description": "MCP服务列表数据" + }, + "message": { + "type": "string", + "description": "响应消息", + "examples": [ + "success" + ] + }, + "request_id": { + "type": "string", + "description": "请求 ID", + "examples": [ + "c55709d2-4e09-4bd8-be84-d8518bc46363" + ] + } + }, + "required": [ + "success", + "request_id" + ] + }, + "McpServerOperationalListResponse": { + "type": "object", + "description": "用户托管MCP服务列表响应", + "properties": { + "success": { + "type": "boolean", + "description": "成功时始终为 true", + "examples": [ + true + ] + }, + "data": { + "$ref": "#/components/schemas/McpServerOperationalList", + "description": "用户托管MCP服务列表数据" + }, + "message": { + "type": "string", + "description": "响应消息", + "examples": [ + "success" + ] + }, + "request_id": { + "type": "string", + "description": "请求 ID", + "examples": [ + "c55709d2-4e09-4bd8-be84-d8518bc46363" + ] + } + }, + "required": [ + "success", + "request_id" + ] + }, + "McpServerDetailResponse": { + "type": "object", + "description": "MCP服务详情响应", + "properties": { + "success": { + "type": "boolean", + "description": "成功时始终为 true", + "examples": [ + true + ] + }, + "data": { + "$ref": "#/components/schemas/McpServerDetail", + "description": "MCP服务详情数据" + }, + "message": { + "type": "string", + "description": "响应消息", + "examples": [ + "success" + ] + }, + "request_id": { + "type": "string", + "description": "请求 ID", + "examples": [ + "c55709d2-4e09-4bd8-be84-d8518bc46363" + ] + } + }, + "required": [ + "success", + "request_id" + ] + }, + "McpDeployResponse": { + "type": "object", + "description": "MCP服务部署响应", + "properties": { + "success": { + "type": "boolean", + "description": "成功时始终为 true", + "examples": [ + true + ] + }, + "data": { + "$ref": "#/components/schemas/McpOperationalUrl", + "description": "部署后的MCP服务链接信息" + }, + "message": { + "type": "string", + "description": "响应消息", + "examples": [ + "success" + ] + }, + "request_id": { + "type": "string", + "description": "请求 ID", + "examples": [ + "c55709d2-4e09-4bd8-be84-d8518bc46363" + ] + } + }, + "required": [ + "success", + "request_id" + ] + }, + "McpUndeployResponse": { + "type": "object", + "description": "MCP服务解除部署响应", + "properties": { + "success": { + "type": "boolean", + "description": "成功时始终为 true", + "examples": [ + true + ] + }, + "message": { + "type": "string", + "description": "响应消息", + "examples": [ + "success" + ] + }, + "request_id": { + "type": "string", + "description": "请求 ID", + "examples": [ + "c55709d2-4e09-4bd8-be84-d8518bc46363" + ] + } + }, + "required": [ + "success", + "request_id" + ] + }, + "StudioHardware": { + "type": "string", + "description": "硬件配置,修改后需重新部署才能生效。\n可用值不固定,请通过 `GET /studios/hardware` 接口查询当前可用的硬件配置列表。\n免费资源常见格式为 `platform/...`、`xgpu/...`、`amd/...`;付费资源格式为 `paid/`,例如 `paid/ecs.gn7i-c8g1.2xlarge`。\n", + "examples": [ + "platform/2v-cpu-16g-mem", + "paid/ecs.gn7i-c8g1.2xlarge" + ] + }, + "StudioSDKType": { + "type": "string", + "description": "SDK 类型,修改后需重新部署才能生效", + "enum": [ + "gradio", + "streamlit", + "docker", + "static" + ] + }, + "StudioSDKVersion": { + "type": "string", + "description": "SDK 版本,仅对 Gradio 类型生效,默认最新版,建议选用最新版本,修改后需重新部署才能生效。\n可用版本会随平台更新,请通过 `GET /studios/sdk-versions?sdk_type=gradio` 查询当前可用的 Gradio 版本列表。\n", + "examples": [ + "6.2.0" + ] + }, + "StudioBaseImage": { + "type": "string", + "description": "基础镜像,仅 Docker 类型不支持,建议选用最新版本,修改后需重新部署才能生效。\n可用值不固定,请通过 `GET /studios/base-images` 接口查询当前可用的基础镜像列表。\n", + "examples": [ + "ubuntu22.04-py311-torch2.9.1-modelscope1.35.0" + ] + }, + "CreateStudioRequest": { + "type": "object", + "description": "创建 Studio 请求参数", + "properties": { + "repo_name": { + "type": "string", + "description": "仓库名称", + "maxLength": 64, + "examples": [ + "my-demo-app" + ] + }, + "owner": { + "type": "string", + "description": "所有者(用户名或组织名)", + "examples": [ + "username" + ] + }, + "display_name": { + "type": "string", + "description": "显示名称", + "maxLength": 128, + "examples": [ + "我的演示应用" + ] + }, + "license": { + "type": "string", + "description": "许可证,默认 apache-2.0", + "default": "apache-2.0", + "examples": [ + "apache-2.0" + ] + }, + "visibility": { + "$ref": "#/components/schemas/StudioVisibility" + }, + "private": { + "type": "boolean", + "description": "Deprecated: 请使用 visibility 字段", + "deprecated": true, + "default": false + }, + "description": { + "type": "string", + "description": "描述", + "maxLength": 2000, + "examples": [ + "这是一个演示应用" + ] + }, + "cover_image": { + "type": "string", + "description": "封面图 URL,为空时使用平台默认图" + }, + "sdk_type": { + "allOf": [ + { + "$ref": "#/components/schemas/StudioSDKType" + } + ], + "default": "gradio" + }, + "sdk_version": { + "$ref": "#/components/schemas/StudioSDKVersion" + }, + "base_image": { + "$ref": "#/components/schemas/StudioBaseImage" + }, + "hardware": { + "allOf": [ + { + "$ref": "#/components/schemas/StudioHardware" + } + ], + "default": "platform/2v-cpu-16g-mem", + "description": "硬件配置。未传时使用默认免费硬件配置" + } + }, + "required": [ + "repo_name", + "owner" + ] + }, + "CreateStudioResponse": { + "type": "object", + "description": "创建 Studio 响应", + "properties": { + "success": { + "type": "boolean", + "examples": [ + true + ] + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Studio ID (owner/repo_name)", + "examples": [ + "username/my-demo-app" + ] + }, + "repo_name": { + "type": "string", + "description": "仓库名称", + "examples": [ + "my-demo-app" + ] + }, + "display_name": { + "type": "string", + "description": "显示名称", + "examples": [ + "我的演示应用" + ] + }, + "owner": { + "type": "string", + "description": "所有者", + "examples": [ + "username" + ] + }, + "url": { + "type": "string", + "description": "Studio URL", + "examples": [ + "https://modelscope.cn/studios/username/my-demo-app" + ] + } + } + }, + "request_id": { + "type": "string" + } + }, + "required": [ + "success", + "request_id" + ] + }, + "StudioConfig": { + "type": "object", + "description": "Studio 运行时配置", + "properties": { + "hardware": { + "$ref": "#/components/schemas/StudioHardware" + }, + "base_image": { + "allOf": [ + { + "$ref": "#/components/schemas/StudioBaseImage" + } + ], + "description": "基础镜像(Docker 类型不返回)" + }, + "sdk_type": { + "$ref": "#/components/schemas/StudioSDKType" + }, + "sdk_version": { + "type": "string", + "description": "SDK 版本(仅 gradio 类型返回)" + } + } + }, + "StudioRuntime": { + "type": "object", + "description": "Studio 运行时信息", + "properties": { + "status": { + "type": "string", + "description": "运行状态", + "enum": [ + "Initialized", + "Building", + "BuildFailed", + "Deploying", + "DeployFailed", + "Running", + "Stopping", + "Stopped", + "Duplicating", + "Sleeping" + ] + }, + "active_config": { + "description": "当前实际运行中的配置", + "$ref": "#/components/schemas/StudioConfig" + }, + "created_at": { + "type": "string", + "description": "部署时间" + }, + "error_message": { + "type": "string", + "description": "失败信息(仅在错误状态时返回)" + } + } + }, + "GetStudioResponse": { + "type": "object", + "description": "获取 Studio 详情响应", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Studio ID (owner/repo_name)" + }, + "repo_name": { + "type": "string", + "description": "仓库名称" + }, + "display_name": { + "type": "string", + "description": "显示名称" + }, + "owner": { + "type": "string", + "description": "所有者" + }, + "description": { + "type": "string", + "description": "描述" + }, + "cover_image": { + "type": "string", + "description": "封面图 URL" + }, + "likes": { + "type": "integer", + "description": "喜欢数" + }, + "view_count": { + "type": "integer", + "description": "访问量" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "visibility": { + "$ref": "#/components/schemas/StudioVisibility" + }, + "private": { + "type": "boolean", + "description": "Deprecated: 请使用 visibility 字段", + "deprecated": true + }, + "last_modified": { + "type": "string", + "description": "最后修改时间" + }, + "sdk_type": { + "$ref": "#/components/schemas/StudioSDKType" + }, + "sdk_version": { + "$ref": "#/components/schemas/StudioSDKVersion" + }, + "hardware": { + "allOf": [ + { + "$ref": "#/components/schemas/StudioHardware" + } + ], + "description": "期望的硬件配置(数据库中保存的值)" + }, + "base_image": { + "allOf": [ + { + "$ref": "#/components/schemas/StudioBaseImage" + } + ], + "description": "期望的基础镜像(Docker 类型不返回)" + }, + "license": { + "type": "string", + "description": "许可证" + }, + "host": { + "type": "string", + "description": "访问地址" + }, + "mcp_support": { + "type": "boolean", + "description": "是否支持 MCP" + }, + "runtime": { + "$ref": "#/components/schemas/StudioRuntime" + } + } + }, + "request_id": { + "type": "string" + } + }, + "required": [ + "success", + "request_id" + ] + }, + "UpdateStudioSettingsRequest": { + "type": "object", + "description": "更新 Studio 设置请求参数(传入哪个字段修改哪个字段)", + "properties": { + "display_name": { + "type": "string", + "description": "显示名称", + "maxLength": 128 + }, + "license": { + "type": "string", + "description": "许可证" + }, + "visibility": { + "$ref": "#/components/schemas/StudioVisibility" + }, + "private": { + "type": "boolean", + "description": "Deprecated: 请使用 visibility 字段", + "deprecated": true + }, + "description": { + "type": "string", + "description": "描述", + "maxLength": 2000 + }, + "cover_image": { + "type": "string", + "description": "封面图 URL" + }, + "sdk_type": { + "$ref": "#/components/schemas/StudioSDKType" + }, + "sdk_version": { + "$ref": "#/components/schemas/StudioSDKVersion" + }, + "base_image": { + "$ref": "#/components/schemas/StudioBaseImage" + }, + "hardware": { + "allOf": [ + { + "$ref": "#/components/schemas/StudioHardware" + } + ], + "description": "硬件配置,修改后需重新部署才能生效" + } + } + }, + "AddStudioSecretRequest": { + "type": "object", + "description": "添加 Studio 密文变量请求参数", + "properties": { + "key": { + "type": "string", + "description": "密文变量名称", + "maxLength": 128, + "examples": [ + "API_KEY" + ] + }, + "value": { + "type": "string", + "description": "密文变量值(添加后不会公开展示)", + "maxLength": 4096, + "examples": [ + "sk-xxxxxxxxxxxx" + ] + } + }, + "required": [ + "key", + "value" + ] + }, + "UpdateStudioSecretRequest": { + "type": "object", + "description": "更新 Studio 密文变量请求参数", + "properties": { + "key": { + "type": "string", + "description": "密文变量名称", + "maxLength": 128, + "examples": [ + "API_KEY" + ] + }, + "value": { + "type": "string", + "description": "密文变量新值", + "maxLength": 4096, + "examples": [ + "sk-xxxxxxxxxxxx" + ] + } + }, + "required": [ + "key", + "value" + ] + }, + "StudioSecretItem": { + "type": "object", + "description": "密文变量列表项(仅返回 key,不返回 value)", + "properties": { + "key": { + "type": "string", + "description": "密文变量名称" + } + }, + "required": [ + "key" + ] + }, + "ListStudioSecretsResponse": { + "type": "object", + "description": "获取 Studio 密文变量列表响应", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "type": "object", + "properties": { + "secrets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StudioSecretItem" + } + } + } + }, + "request_id": { + "type": "string" + } + }, + "required": [ + "success", + "data", + "request_id" + ] + }, + "DeleteStudioSecretRequest": { + "type": "object", + "description": "删除 Studio 密文变量请求参数", + "properties": { + "key": { + "type": "string", + "description": "密文变量名称" + } + }, + "required": [ + "key" + ] + }, + "StudioRuntimeResponse": { + "type": "object", + "description": "Studio 运行时操作响应", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "$ref": "#/components/schemas/StudioRuntime" + }, + "request_id": { + "type": "string" + } + }, + "required": [ + "success", + "request_id" + ] + }, + "StudioLogsResponse": { + "type": "object", + "description": "获取 Studio 日志响应", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "type": "object", + "properties": { + "logs": { + "type": "array", + "items": { + "type": "string" + } + }, + "page_num": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total_count": { + "type": "integer" + }, + "total_page_num": { + "type": "integer" + } + } + }, + "request_id": { + "type": "string" + } + }, + "required": [ + "success", + "request_id" + ] + }, + "SuccessResponse": { + "type": "object", + "description": "通用成功响应", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + }, + "request_id": { + "type": "string" + } + }, + "required": [ + "success", + "request_id" + ] + }, + "StudioVisibility": { + "type": "string", + "description": "创空间可见性:\n- public: 代码和体验都公开\n- protected: 体验公开,代码仓库不可见\n- private: 都不公开\n", + "enum": [ + "public", + "protected", + "private" + ] + }, + "HardwareItem": { + "type": "object", + "description": "硬件配置项。免费资源仅返回基础标识字段;付费资源会返回 ECS 规格、资源明细和价格字段", + "properties": { + "name": { + "type": "string", + "description": "OpenAPI hardware 参数值。付费资源格式为 `paid/`", + "examples": [ + "platform/2v-cpu-16g-mem", + "paid/ecs.gn7i-c8g1.2xlarge" + ] + }, + "resource_type": { + "type": "string", + "description": "资源类型,free 表示平台免费资源,paid 表示使用用户自己的云账号付费部署", + "enum": [ + "free", + "paid" + ] + }, + "instance_type": { + "type": "string", + "description": "ECS 规格名,仅付费资源返回", + "examples": [ + "ecs.gn7i-c8g1.2xlarge" + ] + }, + "cpu": { + "type": "integer", + "description": "CPU 核数,仅付费资源返回" + }, + "gpu": { + "type": "integer", + "description": "GPU 数量,仅付费资源返回" + }, + "memory": { + "type": "integer", + "description": "内存(GB),仅付费资源返回" + }, + "gpu_type": { + "type": "string", + "description": "GPU 类型,仅付费资源返回" + }, + "gpu_memory": { + "type": "integer", + "description": "显存(GB),仅付费资源返回" + }, + "supported_sdk_types": { + "type": "array", + "items": { + "type": "string" + }, + "description": "支持的 SDK 类型" + }, + "has_stock": { + "type": "boolean", + "description": "是否有库存,仅付费资源返回" + }, + "stock": { + "type": "integer", + "description": "库存数量" + }, + "cost_after_discount": { + "type": "number", + "format": "float", + "description": "折后价格,仅付费资源返回" + }, + "original_cost": { + "type": "number", + "format": "float", + "description": "原价,仅付费资源返回" + } + } + }, + "ListHardwareResponse": { + "type": "object", + "description": "查询可用硬件配置响应", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "type": "object", + "properties": { + "hardware": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HardwareItem" + } + } + } + }, + "request_id": { + "type": "string" + } + }, + "required": [ + "success", + "request_id" + ] + }, + "SDKVersionItem": { + "type": "object", + "description": "SDK 版本项", + "properties": { + "sdk_type": { + "type": "string", + "description": "SDK 类型", + "examples": [ + "gradio" + ] + }, + "tag": { + "type": "string", + "description": "版本标签", + "examples": [ + "latest" + ] + }, + "version": { + "type": "string", + "description": "SDK 版本", + "examples": [ + "6.2.0" + ] + } + }, + "required": [ + "sdk_type", + "tag", + "version" + ] + }, + "ListSDKVersionsResponse": { + "type": "object", + "description": "查询可用 SDK 版本响应", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "type": "object", + "properties": { + "sdk_versions": { + "type": "array", + "description": "SDK 版本列表。仅 `sdk_type=gradio` 时返回 Gradio 版本列表;其他 SDK 类型返回空数组", + "items": { + "$ref": "#/components/schemas/SDKVersionItem" + } + }, + "total_count": { + "type": "integer", + "format": "int64", + "description": "SDK 版本总数" + } + } + }, + "request_id": { + "type": "string" + } + }, + "required": [ + "success", + "request_id" + ] + }, + "BaseImageItem": { + "type": "object", + "description": "基础镜像项", + "properties": { + "name": { + "type": "string", + "description": "镜像名称", + "examples": [ + "ubuntu22.04-py311-torch2.9.1-modelscope1.35.0" + ] + }, + "tag": { + "type": "string", + "description": "标签", + "examples": [ + "latest" + ] + } + } + }, + "ListBaseImagesResponse": { + "type": "object", + "description": "查询可用基础镜像响应", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "type": "object", + "properties": { + "base_images": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BaseImageItem" + } + } + } + }, + "request_id": { + "type": "string" + } + }, + "required": [ + "success", + "request_id" + ] + }, + "AddStudioVariableRequest": { + "type": "object", + "description": "添加 Studio 明文变量请求参数(key 和 value 都公开可见,敏感信息请使用密文变量接口)", + "properties": { + "key": { + "type": "string", + "description": "明文变量名称", + "maxLength": 128, + "examples": [ + "GRADIO_TEMP_DIR" + ] + }, + "value": { + "type": "string", + "description": "明文变量值", + "maxLength": 4096, + "examples": [ + "/tmp/gradio" + ] + } + }, + "required": [ + "key", + "value" + ] + }, + "UpdateStudioVariableRequest": { + "type": "object", + "description": "更新 Studio 明文变量请求参数", + "properties": { + "key": { + "type": "string", + "description": "明文变量名称", + "maxLength": 128, + "examples": [ + "GRADIO_TEMP_DIR" + ] + }, + "value": { + "type": "string", + "description": "明文变量新值", + "maxLength": 4096, + "examples": [ + "/new/path" + ] + } + }, + "required": [ + "key", + "value" + ] + }, + "DeleteStudioVariableRequest": { + "type": "object", + "description": "删除 Studio 明文变量请求参数", + "properties": { + "key": { + "type": "string", + "description": "明文变量名称" + } + }, + "required": [ + "key" + ] + }, + "StudioVariableItem": { + "type": "object", + "description": "明文变量列表项(返回 key 和 value)", + "properties": { + "key": { + "type": "string", + "description": "明文变量名称" + }, + "value": { + "type": "string", + "description": "明文变量值" + } + }, + "required": [ + "key", + "value" + ] + }, + "ListStudioVariablesResponse": { + "type": "object", + "description": "获取 Studio 明文变量列表响应", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "type": "object", + "properties": { + "variables": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StudioVariableItem" + } + } + } + }, + "request_id": { + "type": "string" + } + }, + "required": [ + "success", + "data", + "request_id" + ] + }, + "SkillLocale": { + "type": "object", + "description": "技能的单语言本地化信息", + "properties": { + "description": { + "type": "string", + "description": "技能描述的本地化翻译" + }, + "category": { + "type": "string", + "description": "技能分类的本地化翻译" + } + } + }, + "SkillLocales": { + "type": "object", + "description": "技能的多语言本地化信息", + "properties": { + "en": { + "$ref": "#/components/schemas/SkillLocale" + }, + "zh": { + "$ref": "#/components/schemas/SkillLocale" + } + } + }, + "SkillSummary": { + "type": "object", + "description": "技能摘要信息", + "properties": { + "_id": { + "type": "string", + "description": "技能内部唯一标识", + "examples": [ + "62+FGAr3b7f4Bblezwx8hQ==" + ] + }, + "id": { + "type": "string", + "description": "技能 ID,格式为 @author/skill_name 或 owner/skill_name", + "examples": [ + "@AMap-Web/amap-lbs-skill" + ] + }, + "display_name": { + "type": "string", + "description": "技能展示名称", + "examples": [ + "高德地图综合服务Skill" + ] + }, + "description": { + "type": "string", + "description": "技能描述" + }, + "owner": { + "type": "string", + "description": "技能所有者(用户名或组织名)", + "examples": [ + "guoyanlian01" + ] + }, + "license": { + "type": "string", + "description": "许可证", + "examples": [ + "MIT License" + ] + }, + "developer": { + "type": "string", + "description": "开发者", + "examples": [ + "AMap-Web" + ] + }, + "source_url": { + "type": "string", + "description": "源码地址" + }, + "category": { + "type": "string", + "description": "技能分类", + "examples": [ + "developer-tools" + ] + }, + "tags": { + "type": [ + "array", + "null" + ], + "description": "标签列表,格式为 `key:value`,key 包括 category / license / developer / custom_tag", + "items": { + "type": "string" + }, + "examples": [ + [ + "category:developer-tools", + "license:MIT License", + "developer:AMap-Web", + "custom_tag:api-design" + ] + ] + }, + "logo_url": { + "type": "string", + "description": "logo 图片 URL" + }, + "view_count": { + "type": "integer", + "description": "浏览量", + "examples": [ + 13460 + ] + }, + "downloads": { + "type": "integer", + "description": "下载量", + "examples": [ + 1203 + ] + }, + "locales": { + "$ref": "#/components/schemas/SkillLocales" + }, + "private": { + "type": "boolean", + "description": "是否为私有技能", + "examples": [ + false + ] + }, + "custom_tag": { + "type": [ + "array", + "null" + ], + "description": "自定义标签列表(无标签时为 null)", + "items": { + "type": "string" + }, + "examples": [ + [ + "api-design", + "general-tools" + ], + null + ] + } + } + }, + "SkillDetail": { + "type": "object", + "description": "技能详细信息,包含安装命令", + "allOf": [ + { + "$ref": "#/components/schemas/SkillSummary" + }, + { + "type": "object", + "properties": { + "install_command": { + "type": "array", + "description": "安装命令列表", + "items": { + "type": "string" + }, + "examples": [ + [ + "npx skills add https://modelscope.cn/skills/@AMap-Web/amap-lbs-skill", + "curl -fsSL https://modelscope.cn/skills/install.sh | bash -s -- @AMap-Web/amap-lbs-skill" + ] + ] + } + } + } + ] + }, + "SkillListResponse": { + "type": "object", + "description": "技能列表响应", + "properties": { + "success": { + "type": "boolean", + "examples": [ + true + ] + }, + "request_id": { + "type": "string", + "examples": [ + "c5bca202-aa4f-478b-b7e2-c568160583cb" + ] + }, + "data": { + "type": "object", + "properties": { + "skills": { + "type": "array", + "description": "技能列表", + "items": { + "$ref": "#/components/schemas/SkillSummary" + } + }, + "total": { + "type": "integer", + "format": "int64", + "description": "总记录数", + "examples": [ + 20 + ] + }, + "page_number": { + "type": "integer", + "description": "当前页码", + "examples": [ + 1 + ] + }, + "page_size": { + "type": "integer", + "description": "每页条数", + "examples": [ + 20 + ] + } + } + } + } + }, + "SkillDetailResponse": { + "type": "object", + "description": "技能详情响应", + "properties": { + "success": { + "type": "boolean", + "examples": [ + true + ] + }, + "request_id": { + "type": "string", + "examples": [ + "105c898f-fd6d-437d-aa0b-9006036782da" + ] + }, + "data": { + "$ref": "#/components/schemas/SkillDetail" + } + } + }, + "CreateSkillRequest": { + "type": "object", + "description": "创建 Skill 请求参数", + "properties": { + "skill_name": { + "type": "string", + "description": "英文名称,创建后不可更改,仅允许小写字母、数字和连字符", + "pattern": "^[a-z0-9-]+$", + "maxLength": 64, + "examples": [ + "my-awesome-skill" + ] + }, + "owner": { + "type": "string", + "description": "所有者(用户名或组织名)", + "examples": [ + "iSolver" + ] + }, + "display_name": { + "type": "string", + "description": "展示名称", + "maxLength": 128, + "examples": [ + "我的技能" + ] + }, + "source_url": { + "type": "string", + "format": "uri", + "description": "来源地址", + "examples": [ + "https://github.com/example/my-skill" + ] + }, + "private": { + "type": "boolean", + "description": "是否私有,默认 false(公开)", + "default": false, + "examples": [ + false + ] + }, + "description": { + "type": "string", + "description": "描述", + "maxLength": 2000, + "examples": [ + "这是一个示例技能" + ] + }, + "license": { + "type": "string", + "description": "开源协议证书,不填默认 Apache-2.0", + "default": "Apache-2.0", + "examples": [ + "Apache-2.0" + ] + }, + "category": { + "allOf": [ + { + "$ref": "#/components/schemas/SkillCategory" + } + ], + "description": "Skill 类型/分类", + "examples": [ + "developer-tools" + ] + }, + "tags": { + "type": "array", + "description": "自定义标签", + "items": { + "type": "string" + }, + "examples": [ + [ + "Skill Creation" + ] + ] + }, + "logo_url": { + "type": "string", + "format": "uri", + "description": "Skill 图标 URL", + "examples": [ + "https://example.com/logo.png" + ] + }, + "skill_file": { + "type": "string", + "description": "Skill 项目文件 ID(通过 POST /files/upload 上传 zip 包后获取)。\nzip 包要求:\n- 最大 5MB\n- zip 根目录下必须包含且仅包含 1 个 SKILL.md 文件\n- 可包含子目录及其他辅助文件(如 prompts/、examples/ 等)\n- SKILL.md 必须包含 YAML front-matter(name、version、description)\n", + "examples": [ + "8c378570-8991-431b-a82c-96f3d0b4f0f4" + ] + } + }, + "required": [ + "skill_name", + "owner", + "license", + "category", + "skill_file" + ] + }, + "CreateSkillResponse": { + "type": "object", + "description": "创建 Skill 响应", + "properties": { + "success": { + "type": "boolean", + "examples": [ + true + ] + }, + "request_id": { + "type": "string", + "examples": [ + "c55709d2-4e09-4bd8-be84-d8518bc46363" + ] + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Skill ID (owner/skill_name)", + "examples": [ + "iSolver/my-awesome-skill" + ] + }, + "name": { + "type": "string", + "description": "Skill 英文名称", + "examples": [ + "my-awesome-skill" + ] + }, + "display_name": { + "type": "string", + "description": "展示名称", + "examples": [ + "我的技能" + ] + }, + "owner": { + "type": "string", + "description": "所有者", + "examples": [ + "iSolver" + ] + }, + "url": { + "type": "string", + "description": "Skill 页面 URL", + "examples": [ + "https://modelscope.cn/skills/iSolver/my-awesome-skill" + ] + } + } + } + }, + "required": [ + "success", + "request_id" + ] + }, + "UpdateSkillSettingsRequest": { + "type": "object", + "description": "更新 Skill 设置请求参数(传入哪个字段修改哪个字段,不传的字段不修改)", + "properties": { + "display_name": { + "type": "string", + "description": "展示名称", + "maxLength": 128, + "examples": [ + "新的展示名称" + ] + }, + "source_url": { + "type": "string", + "format": "uri", + "description": "来源地址", + "examples": [ + "https://github.com/example/my-skill" + ] + }, + "private": { + "type": "boolean", + "description": "是否私有", + "examples": [ + true + ] + }, + "description": { + "type": "string", + "description": "描述", + "maxLength": 2000, + "examples": [ + "更新后的技能描述" + ] + }, + "license": { + "type": "string", + "description": "开源协议证书", + "examples": [ + "Apache-2.0" + ] + }, + "category": { + "allOf": [ + { + "$ref": "#/components/schemas/SkillCategory" + } + ], + "description": "Skill 类型/分类", + "examples": [ + "developer-tools" + ] + }, + "tags": { + "type": "array", + "description": "自定义标签(传入时整体覆盖原标签列表)", + "items": { + "type": "string" + }, + "examples": [ + [ + "Image/Video Gen", + "Media Processing" + ] + ] + }, + "logo_url": { + "type": "string", + "format": "uri", + "description": "Skill 图标 URL(传空字符串恢复为平台默认图标)", + "examples": [ + "https://example.com/new-logo.png" + ] + }, + "skill_file": { + "type": "string", + "description": "Skill 项目文件 ID(通过 POST /files/upload 上传 zip 包后获取,传入时整体覆盖原文件,不传则保留原文件不变)。\nzip 包要求:\n- 最大 5MB\n- zip 根目录下必须包含且仅包含 1 个 SKILL.md 文件\n- 可包含子目录及其他辅助文件(如 prompts/、examples/ 等)\n- SKILL.md 必须包含 YAML front-matter(name、version、description)\n", + "examples": [ + "new-file-id-xxx" + ] + } + } + }, + "SkillCategory": { + "type": "string", + "description": "Skill 类型/分类枚举", + "enum": [ + "skill-management", + "developer-tools", + "marketing-seo", + "frontend-development", + "ai-media", + "code-quality-testing", + "mobile-development", + "cloud-devops", + "other" + ], + "examples": [ + "developer-tools" + ] + }, + "GetBalanceResponse": { + "type": "object", + "description": "余额查询响应", + "properties": { + "success": { + "type": "boolean", + "examples": [ + true + ] + }, + "data": { + "type": "object", + "properties": { + "total_balance": { + "type": "number", + "description": "总额度(可用额度 + 预扣额度)", + "examples": [ + 1500 + ] + }, + "available_balance": { + "type": "number", + "description": "可用额度", + "examples": [ + 1300 + ] + }, + "frozen_amount": { + "type": "number", + "description": "预扣额度(进行中任务未返回结果时预扣减)", + "examples": [ + 200 + ] + } + } + }, + "request_id": { + "type": "string", + "examples": [ + "c55709d2-4e09-4bd8-be84-d8518bc46363" + ] + } + }, + "required": [ + "success", + "request_id" + ] + }, + "UploadFileRequest": { + "type": "object", + "description": "上传文件请求参数(multipart/form-data)", + "properties": { + "file": { + "type": "string", + "format": "binary", + "description": "上传的文件,最大 5MB" + }, + "filename": { + "type": [ + "string", + "null" + ], + "description": "自定义文件名(选填),支持多级目录路径(如 images/sub/logo.png);不提供则使用上传文件的原始文件名。不允许绝对路径或目录穿越(..)。", + "examples": [ + "images/sub/logo.png" + ] + } + }, + "required": [ + "file" + ] + }, + "UploadFileResponse": { + "type": "object", + "description": "上传文件响应", + "properties": { + "success": { + "type": "boolean", + "examples": [ + true + ] + }, + "request_id": { + "type": "string", + "examples": [ + "99ee2083-63e0-46dc-b393-623c22645078" + ] + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "文件 ID,可在其他接口中作为入参引用该文件", + "examples": [ + "8c378570-8991-431b-a82c-96f3d0b4f0f4" + ] + } + }, + "required": [ + "id" + ] + } + }, + "required": [ + "success", + "request_id", + "data" + ] + }, + "JWK": { + "type": "object", + "description": "JSON Web Key(仅用于 Ed25519 公钥)", + "required": [ + "kty", + "crv", + "x", + "kid" + ], + "properties": { + "kty": { + "type": "string", + "description": "密钥类型,固定 OKP", + "enum": [ + "OKP" + ], + "examples": [ + "OKP" + ] + }, + "crv": { + "type": "string", + "description": "椭圆曲线类型,固定 Ed25519", + "enum": [ + "Ed25519" + ], + "examples": [ + "Ed25519" + ] + }, + "x": { + "type": "string", + "description": "公钥 base64url 编码(无 padding)", + "examples": [ + "f7QtzyEJhqFL8Jk5OYOq7010Ry9PipDGQkTzl7XtiEs" + ] + }, + "kid": { + "type": "string", + "description": "密钥 ID", + "examples": [ + "agent-key-001" + ] + }, + "alg": { + "type": "string", + "description": "签名算法", + "examples": [ + "EdDSA" + ] + }, + "use": { + "type": "string", + "description": "用途", + "examples": [ + "sig" + ] + } + } + }, + "Principal": { + "type": "object", + "description": "Agent 归属主体", + "properties": { + "type": { + "type": "string", + "description": "主体类型", + "enum": [ + "user", + "org" + ], + "examples": [ + "user" + ] + }, + "id": { + "type": "string", + "description": "主体 ID", + "examples": [ + "alice" + ] + } + } + }, + "AgentIdentityStatus": { + "type": "string", + "description": "Agent 状态", + "enum": [ + "active", + "paused" + ], + "examples": [ + "active" + ] + }, + "CreateAgentIdentityRequest": { + "type": "object", + "description": "创建 Agent 身份请求", + "required": [ + "agent_name", + "public_key" + ], + "properties": { + "agent_name": { + "type": "string", + "description": "Agent 名称(最长 128 字符)", + "maxLength": 128, + "examples": [ + "my-agent" + ] + }, + "description": { + "type": "string", + "description": "Agent 描述(最长 2000 字符)", + "maxLength": 2000 + }, + "public_key": { + "$ref": "#/components/schemas/JWK" + }, + "key_alg_type": { + "type": "string", + "description": "密钥算法类型,固定 Ed25519", + "enum": [ + "Ed25519" + ], + "examples": [ + "Ed25519" + ] + }, + "token_expire_time": { + "type": "integer", + "description": "Token 过期时间(秒),可选值 300/600/1800/3600", + "enum": [ + 300, + 600, + 1800, + 3600 + ], + "examples": [ + 3600 + ] + } + } + }, + "UpdateAgentIdentityRequest": { + "type": "object", + "description": "更新 Agent 身份设置请求", + "properties": { + "agent_name": { + "type": "string", + "maxLength": 128 + }, + "description": { + "type": "string", + "maxLength": 2000 + }, + "token_expire_time": { + "type": "integer", + "enum": [ + 300, + 600, + 1800, + 3600 + ] + } + } + }, + "ResetKeyPairRequest": { + "type": "object", + "description": "重置密钥对请求(客户端本地生成新密钥后上传公钥)", + "required": [ + "public_key" + ], + "properties": { + "public_key": { + "$ref": "#/components/schemas/JWK" + }, + "key_alg_type": { + "type": "string", + "enum": [ + "Ed25519" + ], + "examples": [ + "Ed25519" + ] + } + } + }, + "PauseAgentRequest": { + "type": "object", + "description": "启用/停用 Agent 请求", + "required": [ + "paused" + ], + "properties": { + "paused": { + "type": "boolean", + "description": "true 表示停用,false 表示启用", + "examples": [ + true + ] + } + } + }, + "AgentIdentity": { + "type": "object", + "description": "Agent 身份详情", + "properties": { + "agent_id": { + "type": "string", + "description": "Agent 唯一 ID", + "examples": [ + "agent_id:modelscope:agent_1234567890ab" + ] + }, + "agent_name": { + "type": "string", + "examples": [ + "my-agent" + ] + }, + "description": { + "type": "string" + }, + "token_expire_time": { + "type": "integer", + "examples": [ + 3600 + ] + }, + "principal": { + "$ref": "#/components/schemas/Principal" + }, + "kid": { + "type": "string", + "description": "当前密钥 ID", + "examples": [ + "agent-key-001" + ] + }, + "public_key": { + "$ref": "#/components/schemas/JWK" + }, + "status": { + "$ref": "#/components/schemas/AgentIdentityStatus" + }, + "create_time": { + "type": "string", + "description": "创建时间 (ISO 8601)", + "examples": [ + "2026-06-03T10:00:00Z" + ] + }, + "update_time": { + "type": "string", + "description": "更新时间 (ISO 8601)", + "examples": [ + "2026-06-03T10:30:00Z" + ] + } + } + }, + "AgentIdentityListItem": { + "type": "object", + "description": "Agent 身份列表项", + "properties": { + "agent_id": { + "type": "string" + }, + "agent_name": { + "type": "string" + }, + "kid": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/AgentIdentityStatus" + }, + "token_expire_time": { + "type": "integer" + }, + "create_time": { + "type": "string" + } + } + }, + "ListAgentIdentitiesData": { + "type": "object", + "description": "Agent 身份列表数据", + "properties": { + "agent_identities": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AgentIdentityListItem" + } + }, + "total_count": { + "type": "integer", + "format": "int64" + }, + "page_number": { + "type": "integer" + }, + "page_size": { + "type": "integer" + } + } + }, + "ResetKeyPairData": { + "type": "object", + "description": "重置密钥对返回数据", + "properties": { + "agent_id": { + "type": "string" + }, + "kid": { + "type": "string" + }, + "public_key": { + "$ref": "#/components/schemas/JWK" + } + } + }, + "AgentIdentityResponse": { + "type": "object", + "description": "Agent 身份详情响应", + "properties": { + "success": { + "type": "boolean", + "examples": [ + true + ] + }, + "data": { + "$ref": "#/components/schemas/AgentIdentity" + }, + "message": { + "type": "string", + "examples": [ + "success" + ] + }, + "request_id": { + "type": "string" + } + } + }, + "ListAgentIdentitiesResponse": { + "type": "object", + "description": "Agent 身份列表响应", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "$ref": "#/components/schemas/ListAgentIdentitiesData" + }, + "message": { + "type": "string" + }, + "request_id": { + "type": "string" + } + } + }, + "ResetKeyPairResponse": { + "type": "object", + "description": "重置密钥对响应", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "$ref": "#/components/schemas/ResetKeyPairData" + }, + "message": { + "type": "string" + }, + "request_id": { + "type": "string" + } + } + }, + "DeleteAgentIdentityResponse": { + "type": "object", + "description": "删除 Agent 身份响应", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + }, + "request_id": { + "type": "string" + } + } + }, + "PauseAgentResponse": { + "type": "object", + "description": "启用/停用 Agent 响应", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + }, + "request_id": { + "type": "string" + } + } + }, + "TokenSignRequest": { + "type": "object", + "description": "Token 签发请求(Agent 用私钥签名 message=`agent_id|kid|audience|timestamp` 后提交)", + "required": [ + "agent_id", + "kid", + "audience", + "timestamp", + "signature" + ], + "properties": { + "agent_id": { + "type": "string", + "examples": [ + "agent_id:modelscope:agent_1234567890ab" + ] + }, + "kid": { + "type": "string", + "examples": [ + "agent-key-001" + ] + }, + "audience": { + "type": "string", + "description": "目标 Hub 应用的 client_id", + "examples": [ + "hub-app-abcdef" + ] + }, + "timestamp": { + "type": "integer", + "format": "int64", + "description": "签名时间戳(Unix 秒),需在服务器时间 ±5 分钟之内", + "examples": [ + 1717410000 + ] + }, + "signature": { + "type": "string", + "description": "Ed25519 签名 base64url 编码(无 padding)", + "examples": [ + "MEUCIQDxxx..." + ] + } + } + }, + "TokenSignData": { + "type": "object", + "description": "Token 签发返回数据", + "properties": { + "access_token": { + "type": "string", + "description": "签发的 JWT", + "examples": [ + "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsImtpZCI6ImlkcC1rZXktMDAxIn0..." + ] + }, + "token_type": { + "type": "string", + "enum": [ + "Bearer" + ], + "examples": [ + "Bearer" + ] + }, + "expire_at": { + "type": "integer", + "format": "int64", + "description": "Token 失效时间(Unix 时间戳,单位秒)", + "examples": [ + 1717413600 + ] + }, + "jti": { + "type": "string", + "description": "JWT ID", + "examples": [ + "jti-1234567890" + ] + } + } + }, + "TokenSignResponse": { + "type": "object", + "description": "Token 签发响应", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "$ref": "#/components/schemas/TokenSignData" + }, + "message": { + "type": "string" + }, + "request_id": { + "type": "string" + } + } + }, + "TokenRecordItem": { + "type": "object", + "description": "Token 签发记录项", + "properties": { + "token_id": { + "type": "string" + }, + "audience": { + "type": "string", + "description": "目标 Hub 的 client_id" + }, + "issued_at": { + "type": "string", + "description": "签发时间 (ISO 8601)" + }, + "expire_at": { + "type": "string", + "description": "过期时间 (ISO 8601)" + }, + "status": { + "type": "string", + "enum": [ + "active", + "expired", + "revoked" + ] + }, + "jwt": { + "type": "string", + "description": "JWT 字符串" + } + } + }, + "ListTokenRecordsData": { + "type": "object", + "description": "Token 签发记录列表数据", + "properties": { + "token_records": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TokenRecordItem" + } + }, + "total_count": { + "type": "integer", + "format": "int64" + }, + "page_number": { + "type": "integer" + }, + "page_size": { + "type": "integer" + } + } + }, + "ListTokenRecordsResponse": { + "type": "object", + "description": "Token 签发记录列表响应", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "$ref": "#/components/schemas/ListTokenRecordsData" + }, + "message": { + "type": "string" + }, + "request_id": { + "type": "string" + } + } + }, + "AgentIdConfiguration": { + "type": "object", + "description": "Agent IDP OIDC Discovery 配置", + "properties": { + "issuer": { + "type": "string", + "examples": [ + "https://modelscope.cn" + ] + }, + "token_endpoint": { + "type": "string", + "examples": [ + "https://modelscope.cn/openapi/v1/agent_id/token" + ] + }, + "jwks_uri": { + "type": "string", + "examples": [ + "https://modelscope.cn/openapi/v1/agent_id/.well-known/agentid-jwks" + ] + }, + "registration_endpoint": { + "type": "string", + "examples": [ + "https://modelscope.cn/openapi/v1/agent_ids" + ] + }, + "activity_endpoint": { + "type": "string" + }, + "id_token_signing_alg_values_supported": { + "type": "string", + "examples": [ + "EdDSA" + ] + } + } + }, + "JWKS": { + "type": "object", + "description": "JSON Web Key Set", + "properties": { + "keys": { + "type": "array", + "items": { + "$ref": "#/components/schemas/JWK" + } + } + } + }, + "Collection": { + "type": "object", + "description": "Collection 详情对象", + "properties": { + "slug": { + "type": "string", + "description": "Collection 唯一标识,格式 {owner}/{slug}。新建为 {owner}/{title_slug};历史为 {owner}/{title_slug}-{short_id}", + "examples": [ + "iSolver/best-nlp-models-6a3b2c" + ] + }, + "title": { + "type": "string", + "description": "Collection 标题", + "examples": [ + "最佳 NLP 模型合集" + ] + }, + "description": { + "type": [ + "string", + "null" + ], + "description": "Collection 描述(Markdown)", + "examples": [ + "精选的自然语言处理模型..." + ] + }, + "owner": { + "type": "string", + "description": "所有者(用户名或组织名)", + "examples": [ + "iSolver" + ] + }, + "visibility": { + "type": "string", + "description": "可见性:public(公开)/ private(私有)", + "enum": [ + "public", + "private" + ], + "examples": [ + "public" + ] + }, + "theme": { + "type": [ + "string", + "null" + ], + "description": "主题标签,枚举值:Blue/Pink/Purple/Cyan", + "examples": [ + "Blue" + ] + }, + "item_count": { + "type": "integer", + "description": "Collection 中条目数量", + "examples": [ + 12 + ] + }, + "likes": { + "type": "integer", + "description": "喜欢数", + "examples": [ + 128 + ] + }, + "view_count": { + "type": "integer", + "description": "浏览量", + "examples": [ + 1024 + ] + }, + "items": { + "type": "array", + "description": "Collection 中的条目列表", + "items": { + "$ref": "#/components/schemas/CollectionItem" + } + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "创建时间,ISO 8601 UTC", + "examples": [ + "2025-01-15T08:00:00Z" + ] + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "更新时间,ISO 8601 UTC", + "examples": [ + "2025-04-20T10:30:00Z" + ] + }, + "url": { + "type": "string", + "description": "Collection 页面 URL", + "examples": [ + "https://modelscope.cn/collections/iSolver/best-nlp-models-6a3b2c" + ] + }, + "install_command": { + "type": [ + "array", + "null" + ], + "description": "当 items 包含 skill 时,返回下载命令数组", + "items": { + "type": "string" + }, + "examples": [ + [ + "modelscope skill install @iSolver/my-skill" + ] + ] + } + }, + "required": [ + "slug", + "title", + "owner", + "visibility", + "item_count", + "likes", + "view_count", + "items", + "created_at", + "updated_at", + "url" + ] + }, + "CollectionItem": { + "type": "object", + "description": "Collection 条目对象。条目通过 item_type + item_object_id 唯一标识,不维护独立 item_id", + "properties": { + "item_type": { + "type": "string", + "description": "资源类型:model / dataset / studio / paper / skill / mcp", + "enum": [ + "model", + "dataset", + "studio", + "paper", + "skill", + "mcp" + ], + "examples": [ + "model" + ] + }, + "item_object_id": { + "type": "string", + "description": "资源标识,如 damo/nlp_bert_base 或论文 ID", + "examples": [ + "damo/nlp_bert_base" + ] + }, + "note": { + "type": [ + "string", + "null" + ], + "description": "用户对该条目的备注说明(Markdown)", + "examples": [ + "这个模型在中文 NLP 任务上表现优异" + ] + }, + "position": { + "type": "integer", + "description": "排序位置(从 1 开始)", + "examples": [ + 1 + ] + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "添加时间,ISO 8601 UTC", + "examples": [ + "2025-01-15T08:00:00Z" + ] + }, + "visibility": { + "type": "string", + "description": "可见性:public / protected / private。protected 对应“仅公开体验”语义", + "enum": [ + "public", + "protected", + "private" + ], + "examples": [ + "public" + ] + }, + "gated": { + "type": "boolean", + "description": "visibility 为 public 时,是否需先申请并经同意后才可访问", + "examples": [ + false + ] + }, + "protected_mode": { + "type": "integer", + "description": "protected(仅公开体验)模式标识;用于表达条目/资源的仅公开体验语义,具体取值含义以产品后端实现为准(如 0=无、2=仅公开体验)", + "examples": [ + 0 + ] + } + }, + "required": [ + "item_type", + "item_object_id", + "position", + "created_at", + "visibility", + "gated", + "protected_mode" + ] + }, + "FailedCollectionItem": { + "type": "object", + "description": "批量操作中失败的条目及原因", + "properties": { + "item_type": { + "type": "string", + "description": "资源类型", + "examples": [ + "model" + ] + }, + "item_object_id": { + "type": "string", + "description": "资源标识", + "examples": [ + "not/exist-model" + ] + }, + "reason": { + "type": "string", + "description": "失败原因错误码,如 ResourceNotFound、ItemAlreadyInCollection、ItemNotInCollection、PermissionDenied", + "examples": [ + "ResourceNotFound" + ] + } + }, + "required": [ + "item_type", + "item_object_id", + "reason" + ] + }, + "CreateCollectionRequest": { + "type": "object", + "description": "创建 Collection 请求", + "properties": { + "title": { + "type": "string", + "maxLength": 128, + "description": "Collection 标题(最长 128 字符)", + "examples": [ + "最佳 NLP 模型合集" + ] + }, + "owner": { + "type": "string", + "description": "所有者(用户名或组织名)", + "examples": [ + "iSolver" + ] + }, + "description": { + "type": [ + "string", + "null" + ], + "description": "Collection 描述(Markdown)", + "examples": [ + "精选的自然语言处理模型..." + ] + }, + "visibility": { + "type": "string", + "description": "可见性:public / private,默认 public", + "enum": [ + "public", + "private" + ], + "default": "public", + "examples": [ + "public" + ] + }, + "theme": { + "type": [ + "string", + "null" + ], + "description": "主题标签,枚举值:Blue/Pink/Purple/Cyan", + "examples": [ + "Blue" + ] + } + }, + "required": [ + "title", + "owner" + ] + }, + "UpdateCollectionRequest": { + "type": "object", + "description": "更新 Collection 请求。传入哪个字段即更新哪个字段,未传字段保持原值", + "properties": { + "title": { + "type": [ + "string", + "null" + ], + "maxLength": 128, + "description": "Collection 标题(最长 128 字符)", + "examples": [ + "最佳 NLP 模型合集(已更名)" + ] + }, + "description": { + "type": [ + "string", + "null" + ], + "description": "Collection 描述(Markdown)", + "examples": [ + "更新后的描述..." + ] + }, + "owner": { + "type": [ + "string", + "null" + ], + "description": "所有者", + "examples": [ + "iSolver" + ] + }, + "visibility": { + "type": [ + "string", + "null" + ], + "description": "可见性:public / private", + "enum": [ + "public", + "private", + null + ], + "examples": [ + "private" + ] + }, + "theme": { + "type": [ + "string", + "null" + ], + "description": "主题标签,枚举值:Blue/Pink/Purple/Cyan", + "examples": [ + "Purple" + ] + } + } + }, + "CollectionResponse": { + "type": "object", + "description": "获取 Collection 详情的标准响应", + "properties": { + "success": { + "type": "boolean", + "description": "成功时始终为 true", + "examples": [ + true + ] + }, + "request_id": { + "type": "string", + "description": "请求 ID", + "examples": [ + "4d3f4b7d-95be-4e6f-95eb-d75178375bd3" + ] + }, + "data": { + "$ref": "#/components/schemas/Collection" + } + }, + "required": [ + "success", + "request_id", + "data" + ] + }, + "CollectionListResponse": { + "type": "object", + "description": "获取 Collection 列表的标准响应", + "properties": { + "success": { + "type": "boolean", + "description": "成功时始终为 true", + "examples": [ + true + ] + }, + "request_id": { + "type": "string", + "description": "请求 ID", + "examples": [ + "4d3f4b7d-95be-4e6f-95eb-d75178375bd3" + ] + }, + "data": { + "type": "object", + "description": "列表数据", + "properties": { + "collection_list": { + "type": "array", + "description": "Collection 列表,返回完整 Collection 对象", + "items": { + "$ref": "#/components/schemas/Collection" + } + }, + "total_count": { + "type": "integer", + "description": "符合筛选条件的合集总数", + "examples": [ + 128 + ] + }, + "page_number": { + "type": "integer", + "description": "当前页码", + "examples": [ + 1 + ] + }, + "page_size": { + "type": "integer", + "description": "每页大小", + "examples": [ + 10 + ] + } + }, + "required": [ + "collection_list", + "total_count", + "page_number", + "page_size" + ] + } + }, + "required": [ + "success", + "request_id", + "data" + ] + }, + "CollectionItemListResponse": { + "type": "object", + "description": "获取 Collection 条目列表的标准响应", + "properties": { + "success": { + "type": "boolean", + "description": "成功时始终为 true", + "examples": [ + true + ] + }, + "request_id": { + "type": "string", + "description": "请求 ID", + "examples": [ + "4d3f4b7d-95be-4e6f-95eb-d75178375bd3" + ] + }, + "data": { + "type": "object", + "description": "条目列表数据", + "properties": { + "items": { + "type": "array", + "description": "条目列表", + "items": { + "$ref": "#/components/schemas/CollectionItem" + } + }, + "total_count": { + "type": "integer", + "description": "条目总数", + "examples": [ + 50 + ] + }, + "page_number": { + "type": "integer", + "description": "当前页码", + "examples": [ + 1 + ] + }, + "page_size": { + "type": "integer", + "description": "每页大小", + "examples": [ + 10 + ] + } + }, + "required": [ + "items", + "total_count", + "page_number", + "page_size" + ] + } + }, + "required": [ + "success", + "request_id", + "data" + ] + }, + "CollectionItemRequest": { + "type": "object", + "description": "待添加条目", + "properties": { + "item_type": { + "type": "string", + "description": "资源类型:model / dataset / studio / paper / skill / mcp", + "enum": [ + "model", + "dataset", + "studio", + "paper", + "skill", + "mcp" + ], + "examples": [ + "model" + ] + }, + "item_object_id": { + "type": "string", + "description": "资源标识,如 damo/nlp_bert_base_chinese", + "examples": [ + "damo/nlp_bert_base_chinese" + ] + }, + "note": { + "type": [ + "string", + "null" + ], + "description": "备注(Markdown)", + "examples": [ + "推荐用于中文文本分类" + ] + }, + "position": { + "type": [ + "integer", + "null" + ], + "description": "排序位置(从 1 开始)", + "examples": [ + 1 + ] + } + }, + "required": [ + "item_type", + "item_object_id" + ] + }, + "AddCollectionItemsRequest": { + "type": "object", + "description": "批量添加条目请求", + "properties": { + "items": { + "type": "array", + "description": "待添加条目列表(最少 1 个元素)", + "minItems": 1, + "items": { + "$ref": "#/components/schemas/CollectionItemRequest" + } + } + }, + "required": [ + "items" + ] + }, + "AddCollectionItemsResponse": { + "type": "object", + "description": "批量添加条目的标准响应", + "properties": { + "success": { + "type": "boolean", + "description": "成功时始终为 true(部分失败仍为 true,失败项见 failed_items)", + "examples": [ + true + ] + }, + "request_id": { + "type": "string", + "description": "请求 ID", + "examples": [ + "4d3f4b7d-95be-4e6f-95eb-d75178375bd3" + ] + }, + "data": { + "type": "object", + "description": "添加结果", + "properties": { + "added_count": { + "type": "integer", + "description": "成功添加的条目数量", + "examples": [ + 2 + ] + }, + "url": { + "type": "string", + "description": "Collection 页面 URL", + "examples": [ + "https://modelscope.cn/collections/iSolver/best-nlp-models-6a3b2c" + ] + }, + "failed_items": { + "type": "array", + "description": "添加失败的条目及原因", + "items": { + "$ref": "#/components/schemas/FailedCollectionItem" + } + } + }, + "required": [ + "added_count", + "url", + "failed_items" + ] + } + }, + "required": [ + "success", + "request_id", + "data" + ] + }, + "UpdateCollectionItemRequest": { + "type": "object", + "description": "更新单个条目请求。传入哪个字段即更新哪个字段", + "properties": { + "note": { + "type": [ + "string", + "null" + ], + "description": "备注(Markdown)", + "examples": [ + "更新后的备注" + ] + }, + "position": { + "type": [ + "integer", + "null" + ], + "description": "排序位置(从 1 开始)", + "examples": [ + 2 + ] + } + } + }, + "UpdateCollectionItemResponse": { + "type": "object", + "description": "更新单个条目的标准响应", + "properties": { + "success": { + "type": "boolean", + "description": "成功时始终为 true", + "examples": [ + true + ] + }, + "request_id": { + "type": "string", + "description": "请求 ID", + "examples": [ + "4d3f4b7d-95be-4e6f-95eb-d75178375bd3" + ] + }, + "data": { + "$ref": "#/components/schemas/CollectionItem" + } + }, + "required": [ + "success", + "request_id", + "data" + ] + }, + "CreateGalleryRequest": { + "type": "object", + "description": "创建 Gallery 请求", + "properties": { + "name": { + "type": "string", + "maxLength": 128, + "pattern": "^[0-9a-zA-Z_.-]+$", + "description": "Gallery 名称,最长 128 字符,仅允许字母、数字、下划线、连字符、点", + "examples": [ + "my-gallery" + ] + }, + "owner": { + "type": "string", + "maxLength": 128, + "description": "所属用户或组织名,最长 128 字符", + "examples": [ + "my-org" + ] + }, + "files": { + "type": "array", + "maxItems": 50, + "description": "文件 ID 列表(文件上传接口返回的 ID),最多 50 个,单个元素不能为空", + "items": { + "type": "string", + "minLength": 1 + }, + "examples": [ + [ + "8c378570-8991-431b-a82c-96f3d0b4f0f4" + ] + ] + }, + "entry_file": { + "type": "string", + "minLength": 1, + "description": "入口文件的相对路径,对应 files 中某个已上传文件;后缀须与 category 匹配(后缀不区分大小写):notebook→.ipynb,website→.html,pdf→.pdf(file 类别不限后缀)", + "examples": [ + "notebook/demo.ipynb" + ] + }, + "private": { + "type": "boolean", + "default": false, + "description": "是否私有,默认 false(公开)", + "examples": [ + false + ] + }, + "category": { + "type": "string", + "description": "类型(必传),可选值:notebook / website / pdf / file;除 file 外须与 entry_file 后缀匹配(后缀不区分大小写):notebook→.ipynb,website→.html,pdf→.pdf", + "enum": [ + "notebook", + "website", + "pdf", + "file" + ], + "examples": [ + "notebook" + ] + }, + "labels": { + "type": [ + "array", + "null" + ], + "maxItems": 10, + "description": "标签列表,最多 10 个,单个标签最长 20 字符", + "items": { + "type": "string", + "maxLength": 20 + }, + "examples": [ + [ + "demo", + "tutorial" + ] + ] + }, + "path": { + "type": [ + "string", + "null" + ], + "maxLength": 128, + "pattern": "^[0-9a-zA-Z_-]+$", + "description": "自定义 URL 尾缀(选填),仅允许字母、数字、下划线、连字符,最长 128 字符;不填则不生成,填写后灵感流对应 URL:https://modelscope.cn/gallery/{owner}/{path}", + "examples": [ + "my-custom-path" + ] + } + }, + "required": [ + "name", + "owner", + "files", + "entry_file", + "category" + ] + }, + "CreateGalleryResponse": { + "type": "object", + "description": "创建 Gallery 的标准响应", + "properties": { + "success": { + "type": "boolean", + "description": "成功时始终为 true", + "examples": [ + true + ] + }, + "request_id": { + "type": "string", + "description": "请求 ID", + "examples": [ + "req-xxxxxx" + ] + }, + "data": { + "type": "object", + "description": "创建结果", + "properties": { + "id": { + "type": "string", + "description": "Gallery 唯一标识(UUID)", + "examples": [ + "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + ] + }, + "url": { + "type": "string", + "description": "Gallery 访问链接", + "examples": [ + "https://modelscope.cn/gallery/my-org/a1b2c3d4-e5f6-7890-abcd-ef1234567890" + ] + } + }, + "required": [ + "id", + "url" + ] + } + }, + "required": [ + "success", + "request_id", + "data" + ] + }, + "BatchUpdateCollectionItem": { + "type": "object", + "description": "批量更新条目。通过 item_type + item_object_id 定位,传入哪个字段就修改哪个字段", + "properties": { + "item_type": { + "type": "string", + "description": "资源类型,用于定位", + "enum": [ + "model", + "dataset", + "studio", + "paper", + "skill", + "mcp" + ], + "examples": [ + "model" + ] + }, + "item_object_id": { + "type": "string", + "description": "资源标识,用于定位", + "examples": [ + "damo/nlp_bert_base_chinese" + ] + }, + "note": { + "type": [ + "string", + "null" + ], + "description": "备注(Markdown),不传则不修改", + "examples": [ + "更新后的备注" + ] + }, + "position": { + "type": [ + "integer", + "null" + ], + "description": "排序位置(从 1 开始),不传则不修改", + "examples": [ + 1 + ] + } + }, + "required": [ + "item_type", + "item_object_id" + ] + }, + "UpdateCollectionItemsRequest": { + "type": "object", + "description": "批量更新条目请求", + "properties": { + "items": { + "type": "array", + "description": "待更新条目列表(最少 1 个元素)", + "minItems": 1, + "items": { + "$ref": "#/components/schemas/BatchUpdateCollectionItem" + } + } + }, + "required": [ + "items" + ] + }, + "UpdateCollectionItemsResponse": { + "type": "object", + "description": "批量更新条目的标准响应", + "properties": { + "success": { + "type": "boolean", + "description": "成功时始终为 true(部分失败仍为 true,失败项见 failed_items)", + "examples": [ + true + ] + }, + "request_id": { + "type": "string", + "description": "请求 ID", + "examples": [ + "4d3f4b7d-95be-4e6f-95eb-d75178375bd3" + ] + }, + "data": { + "type": "object", + "description": "批量更新结果", + "properties": { + "updated_count": { + "type": "integer", + "description": "成功更新的条目数量", + "examples": [ + 2 + ] + }, + "failed_items": { + "type": "array", + "description": "更新失败的条目及原因", + "items": { + "$ref": "#/components/schemas/FailedCollectionItem" + } + } + }, + "required": [ + "updated_count", + "failed_items" + ] + } + }, + "required": [ + "success", + "request_id", + "data" + ] + }, + "UserPublic": { + "type": "object", + "description": "用户公开信息", + "properties": { + "username": { + "type": "string", + "description": "用户名", + "examples": [ + "tester" + ] + }, + "nickname": { + "type": "string", + "description": "昵称", + "examples": [ + "测试用户" + ] + }, + "description": { + "type": "string", + "description": "描述", + "examples": [ + "这是一个测试用户" + ] + }, + "avatar_url": { + "type": "string", + "format": "uri", + "description": "头像图片URL地址", + "examples": [ + "https://resouces.modelscope.cn/avatar/516aa51f-5fb7-49eb-930c-e90500ae13ca.png" + ] + } + }, + "required": [ + "username" + ] + }, + "UserPrivate": { + "type": "object", + "description": "用户私有信息(仅自己可见)", + "properties": { + "email": { + "type": "string", + "format": "email", + "description": "邮箱地址", + "examples": [ + "test@modelscope.cn" + ] + } + } + }, + "McpServerList": { + "type": "object", + "description": "MCP服务列表与分页信息", + "properties": { + "mcp_server_list": { + "type": "array", + "description": "MCP服务列表", + "items": { + "$ref": "#/components/schemas/McpServerSummary" + } + }, + "total_count": { + "type": "integer", + "description": "搜索符合条件的mcp server总个数", + "examples": [ + 100 + ] + } + }, + "required": [ + "mcp_server_list", + "total_count" + ] + }, + "McpServerOperationalList": { + "type": "object", + "description": "用户托管MCP服务列表", + "properties": { + "mcp_server_list": { + "type": "array", + "description": "用户托管MCP服务列表", + "items": { + "$ref": "#/components/schemas/McpServerOperational" + } + }, + "total_count": { + "type": "integer", + "description": "服务链接总数", + "examples": [ + 5 + ] + } + }, + "required": [ + "mcp_server_list", + "total_count" + ] + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Studio ID (owner/repo_name)" + }, + "repo_name": { + "type": "string", + "description": "仓库名称" + }, + "display_name": { + "type": "string", + "description": "显示名称" + }, + "owner": { + "type": "string", + "description": "所有者" + }, + "description": { + "type": "string", + "description": "描述" + }, + "cover_image": { + "type": "string", + "description": "封面图 URL" + }, + "likes": { + "type": "integer", + "description": "喜欢数" + }, + "view_count": { + "type": "integer", + "description": "访问量" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "visibility": { + "$ref": "#/components/schemas/StudioVisibility" + }, + "private": { + "type": "boolean", + "description": "Deprecated: 请使用 visibility 字段", + "deprecated": true + }, + "last_modified": { + "type": "string", + "description": "最后修改时间" + }, + "sdk_type": { + "$ref": "#/components/schemas/StudioSDKType" + }, + "sdk_version": { + "$ref": "#/components/schemas/StudioSDKVersion" + }, + "hardware": { + "allOf": [ + { + "$ref": "#/components/schemas/StudioHardware" + } + ], + "description": "期望的硬件配置(数据库中保存的值)" + }, + "base_image": { + "allOf": [ + { + "$ref": "#/components/schemas/StudioBaseImage" + } + ], + "description": "期望的基础镜像(Docker 类型不返回)" + }, + "license": { + "type": "string", + "description": "许可证" + }, + "host": { + "type": "string", + "description": "访问地址" + }, + "mcp_support": { + "type": "boolean", + "description": "是否支持 MCP" + }, + "runtime": { + "$ref": "#/components/schemas/StudioRuntime" + } + } + }, + "ListStudiosResponse": { + "type": "object", + "description": "获取创空间列表响应", + "properties": { + "studios": { + "type": "array", + "description": "创空间列表", + "items": { + "$ref": "#/components/schemas/data" + } + }, + "total_count": { + "type": "integer", + "description": "符合筛选条件的创空间总数", + "examples": [ + 120 + ] + }, + "page_number": { + "type": "integer", + "description": "当前页码", + "examples": [ + 1 + ] + }, + "page_size": { + "type": "integer", + "description": "每页大小", + "examples": [ + 10 + ] + } + }, + "required": [ + "studios", + "total_count", + "page_number", + "page_size" + ] + }, + "CollectionMutationResponse": { + "type": "object", + "description": "创建/更新 Collection 的标准响应", + "properties": { + "success": { + "type": "boolean", + "description": "成功时始终为 true", + "examples": [ + true + ] + }, + "request_id": { + "type": "string", + "description": "请求 ID", + "examples": [ + "4d3f4b7d-95be-4e6f-95eb-d75178375bd3" + ] + }, + "data": { + "type": "object", + "description": "变更结果", + "properties": { + "slug": { + "type": "string", + "description": "Collection slug(重命名时可能变化)", + "examples": [ + "iSolver/best-nlp-models-6a3b2c" + ] + }, + "title": { + "type": "string", + "description": "Collection 标题", + "examples": [ + "最佳 NLP 模型合集" + ] + }, + "url": { + "type": "string", + "description": "Collection 页面 URL", + "examples": [ + "https://modelscope.cn/collections/iSolver/best-nlp-models-6a3b2c" + ] + } + }, + "required": [ + "slug", + "title", + "url" + ] + } + }, + "required": [ + "success", + "request_id", + "data" + ] + }, + "EmptyDataResponse": { + "type": "object", + "description": "删除/移除类操作的标准响应,结果以 success 字段为准,data 为空对象", + "properties": { + "success": { + "type": "boolean", + "description": "成功时始终为 true", + "examples": [ + true + ] + }, + "request_id": { + "type": "string", + "description": "请求 ID", + "examples": [ + "4d3f4b7d-95be-4e6f-95eb-d75178375bd3" + ] + }, + "data": { + "type": "object", + "description": "空对象" + } + }, + "required": [ + "success", + "request_id", + "data" + ] + } + }, + "responses": { + "bad-request": { + "description": "请求参数错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "input-parameter-error": { + "$ref": "#/components/examples/input-parameter-error" + } + } + } + } + }, + "unauthorized": { + "description": "认证失败(token 无效、过期或未提供)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "invalid-authentication": { + "$ref": "#/components/examples/invalid-authentication" + } + } + } + } + }, + "forbidden": { + "description": "权限不足(token 有效但无权执行该操作,或配额超限)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "operation-not-allowed": { + "$ref": "#/components/examples/operation-not-allowed" + }, + "quota-limit-exceed": { + "$ref": "#/components/examples/quota-limit-exceed" + } + } + } + } + }, + "not-found": { + "description": "资源未找到", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "resource-not-found": { + "$ref": "#/components/examples/resource-not-found" + } + } + } + } + }, + "internal-server-error": { + "description": "内部服务错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "internal-server-error": { + "$ref": "#/components/examples/internal-server-error" + } + } + } + } + }, + "service-unavailable": { + "description": "服务不可用", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "service-unavailable": { + "$ref": "#/components/examples/service-unavailable" + } + } + } + } + }, + "get-current-user-success": { + "description": "成功获取当前用户信息", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserResponse" + }, + "examples": { + "user-all-response": { + "$ref": "#/components/examples/user-all-response" + } + } + } + } + }, + "list-models-success": { + "description": "成功获取模型列表", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelListResponse" + }, + "examples": { + "list-models-response": { + "$ref": "#/components/examples/list-models-response" + } + } + } + } + }, + "get-model-success": { + "description": "成功获取模型详情", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelDetailResponse" + }, + "examples": { + "get-model-response": { + "$ref": "#/components/examples/get-model-response" + } + } + } + } + }, + "list-datasets-success": { + "description": "成功获取数据集列表", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DatasetListResponse" + }, + "examples": { + "list-datasets-response": { + "$ref": "#/components/examples/list-datasets-response" + } + } + } + } + }, + "get-dataset-success": { + "description": "成功获取数据集详情", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DatasetDetailResponse" + }, + "examples": { + "get-dataset-response": { + "$ref": "#/components/examples/get-dataset-response" + } + } + } + } + }, + "list-mcp-servers-success": { + "description": "成功获取MCP服务列表", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/McpServerListResponse" + }, + "examples": { + "list-mcp-servers-response": { + "$ref": "#/components/examples/list-mcp-servers-response" + } + } + } + } + }, + "list-operational-mcp-servers-success": { + "description": "成功获取用户托管MCP服务列表", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/McpServerOperationalListResponse" + }, + "examples": { + "list-operational-mcp-servers-response": { + "$ref": "#/components/examples/list-operational-mcp-servers-response" + } + } + } + } + }, + "get-mcp-server-success": { + "description": "成功获取MCP服务详情", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/McpServerDetailResponse" + }, + "examples": { + "get-mcp-server-response": { + "$ref": "#/components/examples/get-mcp-server-response" + } + } + } + } + }, + "deploy-mcp-server-success": { + "description": "成功部署MCP服务", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/McpDeployResponse" + }, + "examples": { + "deploy-mcp-server-response": { + "$ref": "#/components/examples/deploy-mcp-server-response" + } + } + } + } + }, + "undeploy-mcp-server-success": { + "description": "成功解除MCP服务部署", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/McpUndeployResponse" + }, + "examples": { + "undeploy-mcp-server-response": { + "$ref": "#/components/examples/undeploy-mcp-server-response" + } + } + } + } + }, + "create-studio-success": { + "description": "成功创建 Studio", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateStudioResponse" + }, + "examples": { + "create-studio-response": { + "$ref": "#/components/examples/create-studio-response" + } + } + } + } + }, + "get-studio-success": { + "description": "成功获取 Studio 详情", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetStudioResponse" + }, + "examples": { + "get-studio-response": { + "$ref": "#/components/examples/get-studio-response" + } + } + } + } + }, + "update-studio-settings-success": { + "description": "成功更新 Studio 设置", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetStudioResponse" + }, + "examples": { + "update-studio-settings-response": { + "$ref": "#/components/examples/get-studio-response" + } + } + } + } + }, + "studio-operation-success": { + "description": "Studio 操作成功", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "studio-runtime-success": { + "description": "Studio 运行时操作成功", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StudioRuntimeResponse" + }, + "examples": { + "studio-runtime-response": { + "$ref": "#/components/examples/studio-runtime-response" + } + } + } + } + }, + "get-studio-logs-success": { + "description": "成功获取 Studio 日志", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StudioLogsResponse" + }, + "examples": { + "get-studio-logs-response": { + "$ref": "#/components/examples/get-studio-logs-response" + } + } + } + } + }, + "list-hardware-success": { + "description": "成功获取可用硬件配置列表", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListHardwareResponse" + } + } + } + }, + "list-sdk-versions-success": { + "description": "成功获取可用 SDK 版本列表", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListSDKVersionsResponse" + }, + "examples": { + "list-sdk-versions-response": { + "$ref": "#/components/examples/list-sdk-versions-response" + } + } + } + } + }, + "list-base-images-success": { + "description": "成功获取可用基础镜像列表", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListBaseImagesResponse" + } + } + } + }, + "list-studio-variables-success": { + "description": "成功获取明文变量列表", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListStudioVariablesResponse" + } + } + } + }, + "list-skills-success": { + "description": "成功获取技能列表", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SkillListResponse" + }, + "examples": { + "list-skills-response": { + "$ref": "#/components/examples/list-skills-response" + } + } + } + } + }, + "get-skill-success": { + "description": "成功获取技能详情", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SkillDetailResponse" + }, + "examples": { + "get-skill-response": { + "$ref": "#/components/examples/get-skill-response" + } + } + } + } + }, + "create-skill-success": { + "description": "成功创建技能", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSkillResponse" + }, + "examples": { + "create-skill-response": { + "$ref": "#/components/examples/create-skill-response" + } + } + } + } + }, + "update-skill-settings-success": { + "description": "成功更新技能设置", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + }, + "examples": { + "update-skill-settings-response": { + "$ref": "#/components/examples/update-skill-settings-response" + } + } + } + } + }, + "skill-bad-request": { + "description": "请求参数错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "skill-input-parameter-error": { + "$ref": "#/components/examples/skill-input-parameter-error" + } + } + } + } + }, + "skill-unauthorized": { + "description": "未通过认证", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "skill-invalid-authentication": { + "$ref": "#/components/examples/skill-invalid-authentication" + } + } + } + } + }, + "skill-not-found": { + "description": "技能未找到", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "skill-not-found": { + "$ref": "#/components/examples/skill-not-found" + } + } + } + } + }, + "skill-conflict": { + "description": "资源冲突(如 Skill 已存在)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "skill-duplicate-entity": { + "$ref": "#/components/examples/skill-duplicate-entity" + } + } + } + } + }, + "skill-internal-server-error": { + "description": "内部服务错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "skill-internal-server-error": { + "$ref": "#/components/examples/skill-internal-server-error" + } + } + } + } + }, + "skill-service-unavailable": { + "description": "服务不可用", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "skill-service-unavailable": { + "$ref": "#/components/examples/skill-service-unavailable" + } + } + } + } + }, + "upload-file-success": { + "description": "成功上传文件", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UploadFileResponse" + }, + "examples": { + "upload-file-response": { + "$ref": "#/components/examples/upload-file-response" + } + } + } + } + }, + "create-agent-identity-success": { + "description": "成功创建 Agent 身份", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentIdentityResponse" + }, + "examples": { + "create-agent-identity-response": { + "$ref": "#/components/examples/create-agent-identity-response" + } + } + } + } + }, + "get-agent-identity-success": { + "description": "成功获取 Agent 身份详情", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentIdentityResponse" + }, + "examples": { + "get-agent-identity-response": { + "$ref": "#/components/examples/get-agent-identity-response" + } + } + } + } + }, + "update-agent-identity-success": { + "description": "成功更新 Agent 身份设置", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentIdentityResponse" + }, + "examples": { + "update-agent-identity-response": { + "$ref": "#/components/examples/update-agent-identity-response" + } + } + } + } + }, + "list-agent-identities-success": { + "description": "成功获取 Agent 身份列表", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListAgentIdentitiesResponse" + }, + "examples": { + "list-agent-identities-response": { + "$ref": "#/components/examples/list-agent-identities-response" + } + } + } + } + }, + "reset-key-pair-success": { + "description": "成功重置 Agent 密钥对", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetKeyPairResponse" + }, + "examples": { + "reset-key-pair-response": { + "$ref": "#/components/examples/reset-key-pair-response" + } + } + } + } + }, + "pause-agent-success": { + "description": "成功启用/停用 Agent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PauseAgentResponse" + }, + "examples": { + "pause-agent-response": { + "$ref": "#/components/examples/pause-agent-response" + } + } + } + } + }, + "delete-agent-identity-success": { + "description": "成功删除 Agent 身份", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteAgentIdentityResponse" + }, + "examples": { + "delete-agent-identity-response": { + "$ref": "#/components/examples/delete-agent-identity-response" + } + } + } + } + }, + "issue-token-success": { + "description": "成功签发 Token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenSignResponse" + }, + "examples": { + "issue-token-response": { + "$ref": "#/components/examples/issue-token-response" + } + } + } + } + }, + "list-token-records-success": { + "description": "成功获取 Token 签发记录列表", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListTokenRecordsResponse" + }, + "examples": { + "list-token-records-response": { + "$ref": "#/components/examples/list-token-records-response" + } + } + } + } + }, + "agentid-configuration-success": { + "description": "成功获取 Agent IDP OIDC 配置", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentIdConfiguration" + }, + "examples": { + "agentid-configuration-response": { + "$ref": "#/components/examples/agentid-configuration-response" + } + } + } + } + }, + "agentid-jwks-success": { + "description": "成功获取 JWKS", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JWKS" + }, + "examples": { + "agentid-jwks-response": { + "$ref": "#/components/examples/agentid-jwks-response" + } + } + } + } + }, + "get-collection-success": { + "description": "成功获取合集详情", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionResponse" + }, + "examples": { + "get-collection-response": { + "$ref": "#/components/examples/get-collection-response" + } + } + } + } + }, + "create-collection-success": { + "description": "成功创建合集", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionMutationResponse" + }, + "examples": { + "create-collection-response": { + "$ref": "#/components/examples/create-collection-response" + } + } + } + } + }, + "update-collection-success": { + "description": "成功更新合集", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionMutationResponse" + }, + "examples": { + "update-collection-response": { + "$ref": "#/components/examples/update-collection-response" + } + } + } + } + }, + "delete-collection-success": { + "description": "成功删除合集", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmptyDataResponse" + }, + "examples": { + "delete-collection-response": { + "$ref": "#/components/examples/delete-collection-response" + } + } + } + } + }, + "list-collections-success": { + "description": "成功获取合集列表", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionListResponse" + }, + "examples": { + "list-collections-response": { + "$ref": "#/components/examples/list-collections-response" + } + } + } + } + }, + "list-collection-items-success": { + "description": "成功获取合集条目列表", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionItemListResponse" + }, + "examples": { + "list-collection-items-response": { + "$ref": "#/components/examples/list-collection-items-response" + } + } + } + } + }, + "add-collection-items-success": { + "description": "成功添加合集条目(部分失败仍返回 success=true,见 failed_items)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddCollectionItemsResponse" + }, + "examples": { + "add-collection-items-response": { + "$ref": "#/components/examples/add-collection-items-response" + } + } + } + } + }, + "update-collection-item-success": { + "description": "成功更新单个合集条目", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCollectionItemResponse" + }, + "examples": { + "update-collection-item-response": { + "$ref": "#/components/examples/update-collection-item-response" + } + } + } + } + }, + "remove-collection-item-success": { + "description": "成功移除合集条目", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmptyDataResponse" + }, + "examples": { + "remove-collection-item-response": { + "$ref": "#/components/examples/remove-collection-item-response" + } + } + } + } + }, + "update-collection-items-success": { + "description": "成功批量更新合集条目(部分失败仍返回 success=true,见 failed_items)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCollectionItemsResponse" + }, + "examples": { + "update-collection-items-response": { + "$ref": "#/components/examples/update-collection-items-response" + } + } + } + } + }, + "create-gallery-success": { + "description": "成功创建 Gallery", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateGalleryResponse" + }, + "examples": { + "create-gallery-response": { + "$ref": "#/components/examples/create-gallery-response" + } + } + } + } + }, + "list-studios-success": { + "description": "成功获取创空间列表", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListStudiosResponse" + } + } + } + }, + "conflict": { + "description": "资源冲突", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "duplicate-entity": { + "$ref": "#/components/examples/duplicate-entity" + } + } + } + } + }, + "too-many-requests": { + "description": "请求过于频繁", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "rate-limit-exceed": { + "$ref": "#/components/examples/rate-limit-exceed" + } + } + } + } + }, + "get-balance-success": { + "description": "余额查询成功", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetBalanceResponse" + }, + "examples": { + "get-balance-response": { + "$ref": "#/components/examples/get-balance-response" + } + } + } + } + } + }, + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "description": "ModelScope API Token" + } + }, + "parameters": { + "owner": { + "name": "owner", + "in": "path", + "description": "仓库 owner(组织或个人)", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "examples": [ + "OpenDataLab", + "Qwen", + "iic" + ] + } + }, + "repo_name": { + "name": "repo_name", + "in": "path", + "description": "仓库 repo_name 名称", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "examples": [ + "AICC", + "Qwen-Image-Edit-2511" + ] + } + }, + "mcp_server_id": { + "name": "id", + "in": "path", + "description": "服务ID。由平台后台收录的MCP,ID 默认为@author/server_name,当被社区用户认领后将会变更为user_name/server_name;由社区用户自发提交的MCP,ID 为user_name/server_name。", + "required": true, + "schema": { + "type": "string", + "examples": [ + "@modelcontextprotocol/fetch", + "username/server-name" + ] + } + }, + "studio_owner": { + "name": "owner", + "in": "path", + "description": "所有者(用户名或组织名)", + "required": true, + "schema": { + "type": "string", + "examples": [ + "username", + "organization" + ] + } + }, + "studio_repo_name": { + "name": "repo_name", + "in": "path", + "description": "仓库名称", + "required": true, + "schema": { + "type": "string", + "examples": [ + "my-demo-app" + ] + } + }, + "studio_log_type": { + "name": "log_type", + "in": "path", + "description": "日志类型:build(构建日志)或 run(运行日志)", + "required": true, + "schema": { + "type": "string", + "enum": [ + "build", + "run" + ], + "examples": [ + "run" + ] + } + }, + "skill_owner_path": { + "name": "owner", + "in": "path", + "description": "技能作者/所有者,支持 `@author` 格式(官方认证)或 `owner`(用户名或组织名),其中 `@` 无需 URL 编码", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "examples": { + "author": { + "value": "@iSolver", + "summary": "官方认证作者" + }, + "user": { + "value": "iSolver", + "summary": "用户名或组织名" + } + } + }, + "skill_name_path": { + "name": "skill_name", + "in": "path", + "description": "技能英文名称,仅允许小写字母、数字和连字符", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9-]+$", + "examples": [ + "my-awesome-skill" + ] + } + }, + "collection_owner_path": { + "name": "owner", + "in": "path", + "description": "Collection 所有者(用户名或组织名)。具体长度与格式约束以当前产品页面实现为准。", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "examples": [ + "iSolver" + ] + } + }, + "collection_slug_path": { + "name": "slug", + "in": "path", + "description": "Collection slug 尾段,由 owner 和 title 字段按一定规则自动生成;创建接口会返回该字段。", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "examples": [ + "best-nlp-models-6a3b2c" + ] + } + }, + "collection_item_type_path": { + "name": "item_type", + "in": "path", + "description": "资源类型:model / dataset / studio / paper / skill / mcp", + "required": true, + "schema": { + "type": "string", + "enum": [ + "model", + "dataset", + "studio", + "paper", + "skill", + "mcp" + ], + "examples": [ + "model" + ] + } + }, + "collection_item_object_id_path": { + "name": "item_object_id", + "in": "path", + "description": "资源标识,如 damo/nlp_bert_base。可包含 `/`(作为 items/{item_type}/ 之后的整段剩余路径),无需 URL 编码。", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "examples": [ + "damo/nlp_bert_base" + ] + } + } + }, + "examples": { + "user-all-response": { + "value": { + "success": true, + "data": { + "username": "tester", + "nickname": "测试用户", + "description": "这是一个测试用户", + "email": "test@modelscope.cn", + "avatar_url": "https://resouces.modelscope.cn/avatar/516aa51f-5fb7-49eb-930c-e90500ae13ca.png" + }, + "request_id": "4d3f4b7d-95be-4e6f-95eb-d75178375bd2" + } + }, + "list-models-response": { + "value": { + "success": true, + "request_id": "4d3f4b7d-95be-4e6f-95eb-d75178375bd3", + "data": { + "models": [ + { + "id": "iic/QwenLong-L1.5-30B-A3B", + "display_name": "QwenLong-L1.5-30B-A3B", + "description": "", + "downloads": 1466, + "likes": 25, + "license": "apache-2.0", + "tasks": [ + "text-generation" + ], + "created_at": "2025-12-14T12:26:15Z", + "last_modified": "2025-12-16T03:34:26Z", + "file_size": 61081671123, + "params": 30532122624, + "tags": [ + "license:apache-2.0", + "model_type:qwen3_moe", + "library:transformer", + "library:safetensors", + "library:pytorch", + "task:text-generation", + "deploy:swingdeploy" + ], + "private": false, + "gated": false + }, + { + "id": "ZhipuAI/AutoGLM-Phone-9B", + "display_name": "AutoGLM-Phone-9B", + "description": "", + "downloads": 28525, + "likes": 265, + "license": "MIT License", + "tasks": [ + "image-captioning" + ], + "created_at": "2025-12-08T08:00:28Z", + "last_modified": "2025-12-09T15:22:57Z", + "file_size": 20605693975, + "params": 5368163840, + "tags": [ + "license:MIT License", + "model_type:glm4v", + "library:safetensors", + "library:pytorch", + "task:image-captioning", + "deploy:swingdeploy" + ], + "private": false, + "gated": false + } + ], + "total_count": 43127, + "page_number": 1, + "page_size": 2 + } + } + }, + "get-model-response": { + "value": { + "success": true, + "request_id": "4d3f4b7d-95be-4e6f-95eb-d75178375bd3", + "data": { + "id": "Qwen/Qwen-Image-Edit-2511", + "display_name": "Qwen-Image-Edit-2511", + "description": "", + "downloads": 20175, + "likes": 149, + "license": "apache-2.0", + "tasks": [ + "image-to-image" + ], + "created_at": "2025-12-18T01:32:15Z", + "last_modified": "2025-12-23T14:09:52Z", + "file_size": 57720465119, + "params": 0, + "tags": [ + "license:apache-2.0", + "library:pytorch", + "library:safetensors", + "library:diffusers", + "task:image-to-image" + ], + "private": false, + "gated": false, + "readme": "hello" + } + } + }, + "list-datasets-response": { + "value": { + "success": true, + "request_id": "4d3f4b7d-95be-4e6f-95eb-d75178375bd3", + "data": { + "datasets": [ + { + "id": "DAMO_NLP/jd", + "display_name": "商品评论情感预测", + "description": "该数据集包含从2011年1月1日到2014年3月31日(3年多)某电商网站的消费者购买行为,用户评分,评论和产品元数据,涵盖15个一级产品类别,987个二级产品类别,近2个百万用户,超过10万种产品和超过6,000万条评论。该数据集中的每个文本评论都包含三个子评论:正面评论,负面评论和整体评论。", + "file_size": 0, + "downloads": 53996, + "likes": 102, + "license": "Apache License 2.0", + "tasks": [ + "text-classification" + ], + "created_at": "2022-09-28T06:30:31Z", + "last_modified": "2022-10-30T03:36:39Z", + "tags": [ + "license:Apache License 2.0", + "task:text-classification" + ], + "private": false, + "gated": false, + "login_required": false + }, + { + "id": "iic/nlp_domain_classification_chinese_testset", + "display_name": "中文文本领域分类测试集", + "description": "中文文本领域分类测试集,涉及国民经济行业标准的18个行业,部分收集自公开数据", + "file_size": 0, + "downloads": 2401, + "likes": 23, + "license": "Apache License 2.0", + "tasks": [ + "text-classification" + ], + "created_at": "2023-02-06T09:21:21Z", + "last_modified": "2024-09-02T15:16:35Z", + "tags": [ + "license:Apache License 2.0", + "task:text-classification", + "custom_tag:文本领域", + "custom_tag:领域", + "custom_tag:文本分类" + ], + "private": false, + "gated": false, + "login_required": false + } + ], + "total_count": 60, + "page_number": 1, + "page_size": 2 + } + } + }, + "get-dataset-response": { + "value": { + "success": true, + "request_id": "4d3f4b7d-95be-4e6f-95eb-d75178375bd3", + "data": { + "id": "OpenDataLab/AICC", + "display_name": "AICC", + "description": "", + "file_size": 0, + "downloads": 12119, + "likes": 7, + "license": "cc-by-4.0", + "tasks": [], + "created_at": "2025-11-25T14:30:22Z", + "last_modified": "2026-01-01T00:31:42Z", + "tags": [ + "license:cc-by-4.0", + "custom_tag:common-crawl", + "custom_tag:code", + "custom_tag:markdown", + "custom_tag:math", + "custom_tag:html-parsing" + ], + "private": false, + "gated": false, + "login_required": false, + "readme": "hello" + } + } + }, + "list-mcp-servers-response": { + "value": { + "success": true, + "data": { + "mcp_server_list": [ + { + "id": "@modelcontextprotocol/fetch", + "name": "Fetch网页内容抓取", + "chinese_name": "Fetch网页内容抓取", + "description": "该服务器使大型语言模型能够检索和处理网页内容,支持多种格式的内容提取和转换。", + "logo_url": "https://resources.modelscope.cn/mcp-cover-img/fetch.png", + "publisher": "@modelcontextprotocol/fetch", + "categories": [ + "browser-automation" + ], + "tags": [ + "browser-automation", + "web-scraping" + ], + "view_count": 30667, + "locales": { + "zh": { + "name": "Fetch网页内容抓取", + "description": "该服务器使大型语言模型能够检索和处理网页内容..." + }, + "en": { + "name": "Fetch", + "description": "A server that enables LLMs to retrieve and process web content..." + } + } + }, + { + "id": "@anthropic/claude-mcp", + "name": "Claude MCP", + "chinese_name": "Claude MCP服务", + "description": "Anthropic 官方 MCP 服务,提供 Claude 模型的标准化访问接口。", + "logo_url": "https://resources.modelscope.cn/mcp-cover-img/claude.png", + "publisher": "@anthropic/claude-mcp", + "categories": [ + "ai-assistant" + ], + "tags": [ + "ai-assistant", + "claude" + ], + "view_count": 15234, + "locales": { + "zh": { + "name": "Claude MCP服务", + "description": "Anthropic 官方 MCP 服务..." + }, + "en": { + "name": "Claude MCP", + "description": "Official Anthropic MCP service..." + } + } + } + ], + "total_count": 100 + }, + "message": "success", + "request_id": "c55709d2-4e09-4bd8-be84-d8518bc46363" + } + }, + "list-operational-mcp-servers-response": { + "value": { + "success": true, + "data": { + "mcp_server_list": [ + { + "id": "@modelcontextprotocol/fetch", + "name": "Fetch网页内容抓取", + "chinese_name": "Fetch网页内容抓取", + "description": "该服务器使大型语言模型能够检索和处理网页内容...", + "logo_url": "https://resources.modelscope.cn/mcp-cover-img/fetch.png", + "publisher": "@modelcontextprotocol/fetch", + "categories": [ + "browser-automation" + ], + "tags": [ + "browser-automation" + ], + "view_count": 30667, + "operational_urls": [ + { + "id": "platform-pool", + "url": "https://mcp.api-inference.modelscope.net/abc123/mcp", + "transport_type": "streamable_http", + "auth_required": false, + "expiration": "2025-10-01 21:00:00", + "accessible": true + } + ] + } + ], + "total_count": 1 + }, + "message": "success", + "request_id": "c55709d2-4e09-4bd8-be84-d8518bc46363" + } + }, + "get-mcp-server-response": { + "value": { + "success": true, + "data": { + "id": "@modelcontextprotocol/fetch", + "name": "Fetch网页内容抓取", + "chinese_name": "Fetch网页内容抓取", + "description": "该服务器使大型语言模型能够检索和处理网页内容,支持多种格式的内容提取和转换。", + "logo_url": "https://resources.modelscope.cn/mcp-cover-img/fetch.png", + "publisher": "@modelcontextprotocol/fetch", + "author": "modelcontextprotocol", + "owner": "", + "readme": "# Fetch MCP Server\n\nA Model Context Protocol server that provides web content fetching capabilities.\n\n## Features\n\n- Fetch web pages and extract content\n- Support for multiple output formats\n- Automatic content cleaning and formatting", + "source_url": "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch", + "github_stars": 1234, + "is_hosted": true, + "is_verified": true, + "categories": [ + "browser-automation" + ], + "tags": [ + "browser-automation", + "web-scraping" + ], + "view_count": 30667, + "env_schema": {}, + "server_config": [], + "operational_urls": [], + "locales": { + "zh": { + "name": "Fetch网页内容抓取", + "description": "该服务器使大型语言模型能够检索和处理网页内容...", + "readme": "# Fetch MCP Server\n\n提供网页内容抓取能力的 MCP 服务。" + }, + "en": { + "name": "Fetch", + "description": "A server that enables LLMs to retrieve and process web content...", + "readme": "# Fetch MCP Server\n\nA Model Context Protocol server that provides web content fetching capabilities." + } + } + }, + "message": "success", + "request_id": "c55709d2-4e09-4bd8-be84-d8518bc46363" + } + }, + "deploy-mcp-server-response": { + "value": { + "success": true, + "data": { + "id": "platform-pool", + "url": "https://mcp.api-inference.modelscope.net/abc123def456/mcp", + "transport_type": "streamable_http", + "auth_required": false, + "expiration": "2025-10-01 21:00:00" + }, + "message": "success", + "request_id": "c55709d2-4e09-4bd8-be84-d8518bc46363" + } + }, + "undeploy-mcp-server-response": { + "value": { + "success": true, + "message": "success", + "request_id": "c55709d2-4e09-4bd8-be84-d8518bc46363" + } + }, + "create-studio-response": { + "value": { + "success": true, + "data": { + "id": "username/my-demo-app", + "repo_name": "my-demo-app", + "display_name": "我的演示应用", + "owner": "username", + "url": "https://modelscope.cn/studios/username/my-demo-app" + }, + "request_id": "c55709d2-4e09-4bd8-be84-d8518bc46363" + } + }, + "get-studio-response": { + "value": { + "success": true, + "data": { + "id": "username/my-demo-app", + "repo_name": "my-demo-app", + "display_name": "我的演示应用", + "owner": "username", + "description": "一个演示创空间应用", + "likes": 100, + "view_count": 5000, + "tags": [ + "nlp", + "chatbot" + ], + "visibility": "public", + "private": false, + "last_modified": "2025-01-15T10:30:00Z", + "sdk_type": "gradio", + "sdk_version": "6.2.0", + "hardware": "platform/2v-cpu-16g-mem", + "base_image": "ubuntu22.04-py311-torch2.9.1-modelscope1.35.0", + "license": "apache-2.0", + "host": "https://username-my-demo-app.ms.show", + "mcp_support": false, + "runtime": { + "status": "Running", + "active_config": { + "hardware": "platform/2v-cpu-16g-mem", + "base_image": "ubuntu22.04-py311-torch2.9.1-modelscope1.35.0", + "sdk_type": "gradio", + "sdk_version": "6.2.0" + }, + "created_at": "2025-01-15T10:30:00Z" + } + }, + "request_id": "c55709d2-4e09-4bd8-be84-d8518bc46363" + } + }, + "studio-runtime-response": { + "value": { + "success": true, + "data": { + "status": "Running", + "active_config": { + "hardware": "platform/2v-cpu-16g-mem", + "base_image": "ubuntu22.04-py311-torch2.9.1-modelscope1.35.0", + "sdk_type": "gradio", + "sdk_version": "6.2.0" + }, + "created_at": "2025-01-15T10:30:00Z" + }, + "request_id": "c55709d2-4e09-4bd8-be84-d8518bc46363" + } + }, + "get-studio-logs-response": { + "value": { + "success": true, + "data": { + "logs": [ + "2025-01-15 10:30:00 INFO Starting application...", + "2025-01-15 10:30:01 INFO Application started successfully" + ], + "page_num": 1, + "page_size": 100, + "total_count": 500, + "total_page_num": 5 + }, + "request_id": "c55709d2-4e09-4bd8-be84-d8518bc46363" + } + }, + "list-sdk-versions-response": { + "value": { + "success": true, + "data": { + "sdk_versions": [ + { + "sdk_type": "gradio", + "tag": "latest", + "version": "6.2.0" + }, + { + "sdk_type": "gradio", + "tag": "stable", + "version": "5.49.1" + } + ], + "total_count": 2 + }, + "request_id": "c55709d2-4e09-4bd8-be84-d8518bc46363" + } + }, + "list-skills-response": { + "value": { + "success": true, + "request_id": "c5bca202-aa4f-478b-b7e2-c568160583cb", + "data": { + "skills": [ + { + "_id": "CIK3H+DYKlSAMlHnX60AUg==", + "id": "@AMap-Web/amap-lbs-skill", + "display_name": "高德地图综合服务Skill", + "description": "高德地图综合服务,支持POI搜索、路径规划、旅游规划、周边搜索和热力图数据可视化", + "owner": "", + "license": "MIT License", + "developer": "AMap-Web", + "source_url": "https://github.com/AMap-Web/amap-lbs-skill", + "category": "developer-tools", + "tags": [ + "category:developer-tools", + "license:MIT License", + "developer:AMap-Web", + "custom_tag:api-design", + "custom_tag:general-tools" + ], + "logo_url": "", + "view_count": 6144, + "downloads": 791, + "locales": { + "en": { + "description": "Amap comprehensive services, supporting POI search, route planning, travel planning, nearby search, and heatmap data visualization", + "category": "DevTools" + }, + "zh": { + "description": "高德地图综合服务,支持POI搜索、路径规划、旅游规划、周边搜索和热力图数据可视化", + "category": "开发工具" + } + }, + "private": false, + "custom_tag": [ + "api-design", + "general-tools" + ] + }, + { + "_id": "0ry9hAiAiJ+2pklbvQjepg==", + "id": "@steipete/summarize", + "display_name": "summarize", + "description": "使用summarize CLI(适用于网页、PDF、图片、音频、YouTube)来总结URL或文件。", + "owner": "steipete", + "license": "", + "developer": "steipete", + "source_url": "https://clawhub.ai/steipete/summarize", + "category": "ai-media", + "tags": [ + "category:ai-media", + "developer:steipete", + "custom_tag:image-video-gen", + "custom_tag:media-processing" + ], + "logo_url": "", + "view_count": 4696, + "downloads": 690, + "locales": { + "en": { + "description": "Summarize URLs or files with the summarize CLI (web, PDFs, images, audio, YouTube).", + "category": "MediaAI" + }, + "zh": { + "description": "使用summarize CLI(适用于网页、PDF、图片、音频、YouTube)来总结URL或文件。", + "category": "媒体处理" + } + }, + "private": false, + "custom_tag": [ + "image-video-gen", + "media-processing" + ] + } + ], + "total": 20, + "page_number": 1, + "page_size": 20 + } + } + }, + "get-skill-response": { + "value": { + "success": true, + "request_id": "105c898f-fd6d-437d-aa0b-9006036782da", + "data": { + "_id": "CIK3H+DYKlSAMlHnX60AUg==", + "id": "@AMap-Web/amap-lbs-skill", + "display_name": "高德地图综合服务Skill", + "description": "高德地图综合服务,支持POI搜索、路径规划、旅游规划、周边搜索和热力图数据可视化", + "owner": "", + "license": "MIT License", + "developer": "AMap-Web", + "source_url": "https://github.com/AMap-Web/amap-lbs-skill", + "category": "developer-tools", + "tags": [ + "category:developer-tools", + "license:MIT License", + "developer:AMap-Web", + "custom_tag:api-design", + "custom_tag:general-tools" + ], + "logo_url": "", + "view_count": 6144, + "downloads": 791, + "locales": { + "en": { + "description": "Amap comprehensive services, supporting POI search, route planning, travel planning, nearby search, and heatmap data visualization", + "category": "DevTools" + }, + "zh": { + "description": "高德地图综合服务,支持POI搜索、路径规划、旅游规划、周边搜索和热力图数据可视化", + "category": "开发工具" + } + }, + "private": false, + "custom_tag": [ + "api-design", + "general-tools" + ], + "install_command": [ + "npx skills add https://modelscope.cn/skills/@AMap-Web/amap-lbs-skill", + "curl -fsSL https://modelscope.cn/skills/install.sh | bash -s -- @AMap-Web/amap-lbs-skill" + ] + } + } + }, + "create-skill-response": { + "value": { + "success": true, + "request_id": "c55709d2-4e09-4bd8-be84-d8518bc46363", + "data": { + "id": "iSolver/my-awesome-skill", + "name": "my-awesome-skill", + "display_name": "我的技能", + "owner": "iSolver", + "url": "https://modelscope.cn/skills/iSolver/my-awesome-skill" + } + } + }, + "update-skill-settings-response": { + "value": { + "success": true, + "message": "success", + "request_id": "c55709d2-4e09-4bd8-be84-d8518bc46363" + } + }, + "upload-file-response": { + "value": { + "success": true, + "request_id": "99ee2083-63e0-46dc-b393-623c22645078", + "data": { + "id": "8c378570-8991-431b-a82c-96f3d0b4f0f4" + } + } + }, + "create-agent-identity-response": { + "value": { + "success": true, + "data": { + "agent_id": "agent_id:modelscope:agent_1234567890ab", + "agent_name": "my-agent", + "description": "用于演示的测试 Agent", + "token_expire_time": 3600, + "principal": { + "type": "user", + "id": "alice" + }, + "kid": "agent-key-001", + "public_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "f7QtzyEJhqFL8Jk5OYOq7010Ry9PipDGQkTzl7XtiEs", + "kid": "agent-key-001", + "alg": "EdDSA", + "use": "sig" + }, + "status": "active", + "create_time": "2026-06-08T10:00:00Z" + }, + "message": "success", + "request_id": "c55709d2-4e09-4bd8-be84-d8518bc46363" + } + }, + "get-agent-identity-response": { + "value": { + "success": true, + "data": { + "agent_id": "agent_id:modelscope:agent_1234567890ab", + "agent_name": "my-agent", + "description": "用于演示的测试 Agent", + "token_expire_time": 3600, + "principal": { + "type": "user", + "id": "alice" + }, + "kid": "agent-key-001", + "public_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "f7QtzyEJhqFL8Jk5OYOq7010Ry9PipDGQkTzl7XtiEs", + "kid": "agent-key-001", + "alg": "EdDSA", + "use": "sig" + }, + "status": "active", + "create_time": "2026-06-08T10:00:00Z", + "update_time": "2026-06-08T10:30:00Z" + }, + "message": "success", + "request_id": "c55709d2-4e09-4bd8-be84-d8518bc46363" + } + }, + "update-agent-identity-response": { + "value": { + "success": true, + "data": { + "agent_id": "agent_id:modelscope:agent_1234567890ab", + "agent_name": "my-agent-renamed", + "description": "更新后的描述", + "token_expire_time": 1800, + "principal": { + "type": "user", + "id": "alice" + }, + "kid": "agent-key-001", + "public_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "f7QtzyEJhqFL8Jk5OYOq7010Ry9PipDGQkTzl7XtiEs", + "kid": "agent-key-001", + "alg": "EdDSA", + "use": "sig" + }, + "status": "active", + "create_time": "2026-06-08T10:00:00Z", + "update_time": "2026-06-08T11:00:00Z" + }, + "message": "success", + "request_id": "c55709d2-4e09-4bd8-be84-d8518bc46363" + } + }, + "list-agent-identities-response": { + "value": { + "success": true, + "data": { + "agent_identities": [ + { + "agent_id": "agent_id:modelscope:agent_1234567890ab", + "agent_name": "my-agent", + "kid": "agent-key-001", + "status": "active", + "token_expire_time": 3600, + "create_time": "2026-06-08T10:00:00Z" + }, + { + "agent_id": "agent-cdef01234567", + "agent_name": "build-bot", + "kid": "agent-key-002", + "status": "paused", + "token_expire_time": 1800, + "create_time": "2026-06-07T15:20:00Z" + } + ], + "total_count": 2, + "page_number": 1, + "page_size": 20 + }, + "message": "success", + "request_id": "c55709d2-4e09-4bd8-be84-d8518bc46363" + } + }, + "reset-key-pair-response": { + "value": { + "success": true, + "data": { + "agent_id": "agent_id:modelscope:agent_1234567890ab", + "kid": "agent-key-002", + "public_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "UnZsPxBV8ZyK5zhQlpnT1nf5GjxJQbpHQMwoS0jZeNc", + "kid": "agent-key-002", + "alg": "EdDSA", + "use": "sig" + } + }, + "message": "success", + "request_id": "c55709d2-4e09-4bd8-be84-d8518bc46363" + } + }, + "pause-agent-response": { + "value": { + "success": true, + "message": "success", + "request_id": "c55709d2-4e09-4bd8-be84-d8518bc46363" + } + }, + "delete-agent-identity-response": { + "value": { + "success": true, + "message": "success", + "request_id": "c55709d2-4e09-4bd8-be84-d8518bc46363" + } + }, + "issue-token-response": { + "value": { + "success": true, + "data": { + "access_token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsImtpZCI6ImlkcC1rZXktMDAxIn0.eyJpc3MiOiJodHRwczovL21vZGVsc2NvcGUuY24iLCJzdWIiOiJhZ2VudC0xMjM0NTY3ODkwYWIiLCJhdWQiOiJodWItYXBwLWFiY2RlZjEyMzQ1NiIsImV4cCI6MTcxNzQxMzYwMCwiaWF0IjoxNzE3NDEwMDAwLCJqdGkiOiJqdGktMTIzNDU2Nzg5MCJ9.dummy_signature_base64url", + "token_type": "Bearer", + "expire_at": 1717413600, + "jti": "jti-1234567890" + }, + "message": "success", + "request_id": "c55709d2-4e09-4bd8-be84-d8518bc46363" + } + }, + "list-token-records-response": { + "value": { + "success": true, + "data": { + "token_records": [ + { + "token_id": "jti-1234567890", + "audience": "hub-app-abcdef123456", + "issued_at": "2026-06-08T11:30:00Z", + "expire_at": "2026-06-08T12:30:00Z", + "status": "active", + "jwt": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsImtpZCI6ImlkcC1rZXktMDAxIn0.eyJpc3MiOiJodHRwczovL21vZGVsc2NvcGUuY24iLCJzdWIiOiJhZ2VudC0xMjM0NTY3ODkwYWIiLCJhdWQiOiJodWItYXBwLWFiY2RlZjEyMzQ1NiIsImV4cCI6MTcxNzQxMzYwMCwiaWF0IjoxNzE3NDEwMDAwLCJqdGkiOiJqdGktMTIzNDU2Nzg5MCJ9.dummy_signature_base64url" + }, + { + "token_id": "jti-2345678901", + "audience": "hub-app-fedcba654321", + "issued_at": "2026-06-07T09:15:00Z", + "expire_at": "2026-06-07T10:15:00Z", + "status": "expired", + "jwt": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsImtpZCI6ImlkcC1rZXktMDAxIn0.eyJpc3MiOiJodHRwczovL21vZGVsc2NvcGUuY24iLCJzdWIiOiJhZ2VudC0xMjM0NTY3ODkwYWIiLCJhdWQiOiJodWItYXBwLWZlZGNiYTY1NDMyMSIsImV4cCI6MTcxNzMyMjUwMCwiaWF0IjoxNzE3MzE4OTAwLCJqdGkiOiJqdGktMjM0NTY3ODkwMSJ9.dummy_signature_base64url" + } + ], + "total_count": 2, + "page_number": 1, + "page_size": 20 + }, + "message": "success", + "request_id": "c55709d2-4e09-4bd8-be84-d8518bc46363" + } + }, + "agentid-configuration-response": { + "value": { + "issuer": "https://modelscope.cn", + "token_endpoint": "https://modelscope.cn/openapi/v1/agent_id/token", + "jwks_uri": "https://modelscope.cn/openapi/v1/agent_id/.well-known/agentid-jwks", + "registration_endpoint": "https://modelscope.cn/openapi/v1/agent_ids", + "activity_endpoint": "https://modelscope.cn/openapi/v1/agent_ids/{agent_id}/jwt_id_tokens", + "id_token_signing_alg_values_supported": "EdDSA" + } + }, + "agentid-jwks-response": { + "value": { + "keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "UnZsPxBV8ZyK5zhQlpnT1nf5GjxJQbpHQMwoS0jZeNc", + "kid": "idp-key-001", + "alg": "EdDSA", + "use": "sig" + }, + { + "kty": "OKP", + "crv": "Ed25519", + "x": "WoxPa7_oZOn1HtdPNn-3Btu7UKmtMVRUyQMlYMM0SnQ", + "kid": "idp-key-002", + "alg": "EdDSA", + "use": "sig" + } + ] + } + }, + "get-collection-response": { + "value": { + "success": true, + "request_id": "4d3f4b7d-95be-4e6f-95eb-d75178375bd3", + "data": { + "slug": "iSolver/best-nlp-models-6a3b2c", + "title": "最佳 NLP 模型合集", + "description": "精选的自然语言处理模型...", + "owner": "iSolver", + "visibility": "public", + "theme": "Blue", + "item_count": 2, + "likes": 128, + "view_count": 1024, + "items": [ + { + "item_type": "model", + "item_object_id": "damo/nlp_bert_base", + "note": "这个模型在中文 NLP 任务上表现优异", + "position": 1, + "created_at": "2025-01-15T08:00:00Z", + "visibility": "public", + "gated": false, + "protected_mode": 2 + }, + { + "item_type": "dataset", + "item_object_id": "damo/nlp_corpus", + "note": null, + "position": 2, + "created_at": "2025-01-16T09:00:00Z", + "visibility": "public", + "gated": false, + "protected_mode": 2 + } + ], + "created_at": "2025-01-15T08:00:00Z", + "updated_at": "2025-04-20T10:30:00Z", + "url": "https://modelscope.cn/collections/iSolver/best-nlp-models-6a3b2c" + } + } + }, + "create-collection-response": { + "value": { + "success": true, + "request_id": "4d3f4b7d-95be-4e6f-95eb-d75178375bd3", + "data": { + "slug": "iSolver/best-nlp-models-6a3b2c", + "title": "最佳 NLP 模型合集", + "url": "https://modelscope.cn/collections/iSolver/best-nlp-models-6a3b2c" + } + } + }, + "update-collection-response": { + "value": { + "success": true, + "request_id": "4d3f4b7d-95be-4e6f-95eb-d75178375bd3", + "data": { + "slug": "iSolver/best-nlp-models-renamed-6a3b2c", + "title": "最佳 NLP 模型合集(已更名)", + "url": "https://modelscope.cn/collections/iSolver/best-nlp-models-renamed-6a3b2c" + } + } + }, + "delete-collection-response": { + "value": { + "success": true, + "request_id": "4d3f4b7d-95be-4e6f-95eb-d75178375bd3", + "data": {} + } + }, + "list-collections-response": { + "value": { + "success": true, + "request_id": "4d3f4b7d-95be-4e6f-95eb-d75178375bd3", + "data": { + "collection_list": [ + { + "slug": "iSolver/best-nlp-models-6a3b2c", + "title": "最佳 NLP 模型合集", + "description": "精选的自然语言处理模型...", + "owner": "iSolver", + "visibility": "public", + "theme": "Blue", + "item_count": 12, + "likes": 128, + "view_count": 1024, + "items": [], + "created_at": "2025-01-15T08:00:00Z", + "updated_at": "2025-04-20T10:30:00Z", + "url": "https://modelscope.cn/collections/iSolver/best-nlp-models-6a3b2c" + } + ], + "total_count": 128, + "page_number": 1, + "page_size": 10 + } + } + }, + "list-collection-items-response": { + "value": { + "success": true, + "request_id": "4d3f4b7d-95be-4e6f-95eb-d75178375bd3", + "data": { + "items": [ + { + "item_type": "model", + "item_object_id": "damo/nlp_bert_base", + "note": "这个模型在中文 NLP 任务上表现优异", + "position": 1, + "created_at": "2025-01-15T08:00:00Z", + "visibility": "public", + "gated": false, + "protected_mode": 2 + } + ], + "total_count": 50, + "page_number": 1, + "page_size": 10 + } + } + }, + "add-collection-items-request": { + "value": { + "items": [ + { + "item_type": "model", + "item_object_id": "damo/nlp_bert_base_chinese", + "note": "推荐用于中文文本分类", + "position": 1 + }, + { + "item_type": "dataset", + "item_object_id": "damo/nlp_corpus" + } + ] + } + }, + "add-collection-items-response": { + "value": { + "success": true, + "request_id": "4d3f4b7d-95be-4e6f-95eb-d75178375bd3", + "data": { + "added_count": 2, + "url": "https://modelscope.cn/collections/iSolver/best-nlp-models-6a3b2c", + "failed_items": [ + { + "item_type": "model", + "item_object_id": "not/exist-model", + "reason": "ResourceNotFound" + } + ] + } + } + }, + "update-collection-item-request": { + "value": { + "note": "更新后的备注", + "position": 2 + } + }, + "update-collection-item-response": { + "value": { + "success": true, + "request_id": "4d3f4b7d-95be-4e6f-95eb-d75178375bd3", + "data": { + "item_type": "model", + "item_object_id": "damo/nlp_bert_base", + "note": "更新后的备注", + "position": 2, + "created_at": "2025-01-15T08:00:00Z", + "visibility": "public", + "gated": false, + "protected_mode": 2 + } + } + }, + "remove-collection-item-response": { + "value": { + "success": true, + "request_id": "4d3f4b7d-95be-4e6f-95eb-d75178375bd3", + "data": {} + } + }, + "update-collection-items-request": { + "value": { + "items": [ + { + "item_type": "model", + "item_object_id": "damo/nlp_bert_base", + "position": 1 + }, + { + "item_type": "dataset", + "item_object_id": "damo/nlp_corpus", + "note": "更新备注", + "position": 2 + } + ] + } + }, + "update-collection-items-response": { + "value": { + "success": true, + "request_id": "4d3f4b7d-95be-4e6f-95eb-d75178375bd3", + "data": { + "updated_count": 2, + "failed_items": [ + { + "item_type": "model", + "item_object_id": "not/exist-model", + "reason": "ItemNotInCollection" + } + ] + } + } + }, + "create-gallery-request": { + "value": { + "name": "my-gallery", + "owner": "my-org", + "files": [ + "8c378570-8991-431b-a82c-96f3d0b4f0f4", + "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + ], + "entry_file": "notebook/demo.ipynb", + "private": false, + "category": "notebook", + "labels": [ + "demo", + "tutorial" + ], + "path": "my-custom-path" + } + }, + "create-gallery-response": { + "value": { + "success": true, + "request_id": "req-xxxxxx", + "data": { + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "url": "https://modelscope.cn/gallery/my-org/a1b2c3d4-e5f6-7890-abcd-ef1234567890" + } + } + }, + "invalid-authentication": { + "value": { + "success": false, + "code": "InvalidAuthentication", + "message": "Invalid authentication, please check your Authorization header", + "request_id": "4d3f4b7d-95be-4e6f-95eb-d75178375bd3" + } + }, + "internal-server-error": { + "value": { + "success": false, + "code": "InternalServerError", + "message": "Internal server error: database connection failed", + "request_id": "4d3f4b7d-95be-4e6f-95eb-d75178375bd8" + } + }, + "service-unavailable": { + "value": { + "success": false, + "code": "ServiceUnavailable", + "message": "Service unavailable: model inference service is down", + "request_id": "4d3f4b7d-95be-4e6f-95eb-d75178375bd9" + } + }, + "resource-not-found": { + "value": { + "success": false, + "code": "ResourceNotFound", + "message": "Resource not found: model Qwen/Qwen3-72B", + "request_id": "4d3f4b7d-95be-4e6f-95eb-d75178375bd5" + } + }, + "input-parameter-error": { + "value": { + "success": false, + "code": "InputParameterError", + "message": "Input parameter 'model_id' is invalid: cannot be empty", + "request_id": "4d3f4b7d-95be-4e6f-95eb-d75178375bd2" + } + }, + "operation-not-allowed": { + "value": { + "success": false, + "code": "OperationNotAllowed", + "message": "Operation not allowed: you don't have permission to access this studio", + "request_id": "4d3f4b7d-95be-4e6f-95eb-d75178375be0" + } + }, + "quota-limit-exceed": { + "value": { + "success": false, + "code": "QuotaLimitExceed", + "message": "Quota limit exceeded: API calls per minute", + "request_id": "4d3f4b7d-95be-4e6f-95eb-d75178375bd4" + } + }, + "duplicate-entity": { + "value": { + "success": false, + "code": "DuplicateEntity", + "message": "Duplicate entity: model already exists", + "request_id": "4d3f4b7d-95be-4e6f-95eb-d75178375bd6" + } + }, + "skill-input-parameter-error": { + "value": { + "success": false, + "code": "InputParameterError", + "message": "Input parameter 'page_size' is invalid: must be between 1 and 100", + "request_id": "4d3f4b7d-95be-4e6f-95eb-d75178375be1" + } + }, + "skill-invalid-authentication": { + "value": { + "success": false, + "code": "InvalidAuthentication", + "message": "Invalid authentication, please check your Authorization header", + "request_id": "4d3f4b7d-95be-4e6f-95eb-d75178375be5" + } + }, + "skill-internal-server-error": { + "value": { + "success": false, + "code": "InternalServerError", + "message": "Internal server error: failed to query skill marketplace", + "request_id": "4d3f4b7d-95be-4e6f-95eb-d75178375be3" + } + }, + "skill-service-unavailable": { + "value": { + "success": false, + "code": "ServiceUnavailable", + "message": "Service unavailable: skill marketplace is temporarily unavailable", + "request_id": "4d3f4b7d-95be-4e6f-95eb-d75178375be4" + } + }, + "skill-duplicate-entity": { + "value": { + "success": false, + "code": "DuplicateEntity", + "message": "Duplicate entity: skill iSolver/my-awesome-skill already exists", + "request_id": "4d3f4b7d-95be-4e6f-95eb-d75178375be6" + } + }, + "skill-not-found": { + "value": { + "success": false, + "code": "ResourceNotFound", + "message": "Resource not found: skill @Alipay/alipay-payment-integration", + "request_id": "4d3f4b7d-95be-4e6f-95eb-d75178375be2" + } + }, + "rate-limit-exceed": { + "value": { + "success": false, + "code": "RateLimitExceed", + "message": "Rate limit exceeded: 100 requests per minute", + "request_id": "4d3f4b7d-95be-4e6f-95eb-d75178375bd7" + } + }, + "get-balance-response": { + "value": { + "success": true, + "data": { + "total_balance": 1000, + "available_balance": 998.5, + "frozen_amount": 1.5 + }, + "request_id": "c55709d2-4e09-4bd8-be84-d8518bc46363" + } + } + } + } +} diff --git a/tests/integration/test_openapi.py b/tests/integration/test_openapi.py index a4c0c5c..b4cbca8 100644 --- a/tests/integration/test_openapi.py +++ b/tests/integration/test_openapi.py @@ -9,10 +9,21 @@ from __future__ import annotations +import contextlib +import os +import uuid + import pytest from modelscope_hub._openapi import OpenAPIClient +from modelscope_hub.api import HubApi from modelscope_hub.config import HubConfig +from modelscope_hub.errors import ( + AlreadyExistsError, + AuthenticationError, + InvalidParameter, + PermissionDeniedError, +) @pytest.fixture @@ -138,6 +149,169 @@ def test_get_studio_public(self, openapi): except Exception: pytest.skip("Public studio not available or requires auth") + def test_list_studios(self, openapi): + result = openapi.list_studios(page_size=5) + assert isinstance(result, dict) + studios = result.get("studios") or [] + assert isinstance(studios, list) + assert len(studios) <= 5 + + def test_list_studios_with_search(self, openapi): + result = openapi.list_studios(search="chat", page_size=3) + assert isinstance(result, dict) + + def test_list_studios_by_owner(self, openapi, test_owner): + result = openapi.list_studios(owner=test_owner, page_size=5) + for studio in result.get("studios") or []: + assert (studio.get("owner") or "").lower() == test_owner.lower() + + def test_list_studios_rejects_unknown_sort(self, openapi): + with pytest.raises(InvalidParameter): + openapi.list_studios(sort="downloads") + + +@pytest.mark.remote +class TestOpenAPIStudioResources: + """Resource-discovery endpoints backing --hardware / --base-image / --sdk-version.""" + + def test_list_hardware(self, openapi): + result = openapi.list_studio_hardware() + hardware = (result or {}).get("hardware") + assert isinstance(hardware, list) + if hardware: + assert "name" in hardware[0] + + def test_list_hardware_filtered_by_sdk_type(self, openapi): + result = openapi.list_studio_hardware(sdk_type="gradio") + assert isinstance((result or {}).get("hardware"), list) + + def test_list_base_images(self, openapi): + result = openapi.list_studio_base_images() + images = (result or {}).get("base_images") + assert isinstance(images, list) + if images: + assert "name" in images[0] + + def test_gradio_publishes_sdk_versions(self, openapi): + result = openapi.list_studio_sdk_versions(sdk_type="gradio") + versions = (result or {}).get("sdk_versions") + assert isinstance(versions, list) + + def test_non_gradio_sdk_has_no_versions(self, openapi): + """The specification states only gradio publishes a version list.""" + result = openapi.list_studio_sdk_versions(sdk_type="docker") + assert not ((result or {}).get("sdk_versions") or []) + + +@pytest.mark.remote +class TestOpenAPIStudioVariables: + """Full add -> list -> update -> delete cycle against a real Studio. + + Requires MODELSCOPE_TEST_STUDIO (``owner/repo_name``) in tests/.env, because + the endpoints operate on an existing space. The key is randomised so parallel + runs cannot collide, and cleanup runs even when an assertion fails. + """ + + @pytest.fixture + def studio_id(self) -> str: + studio = os.environ.get("MODELSCOPE_TEST_STUDIO") + if not studio: + pytest.skip("Set MODELSCOPE_TEST_STUDIO=owner/repo_name to exercise Studio variables") + return studio + + @pytest.fixture + def variable_key(self, openapi, studio_id): + owner, repo_name = studio_id.split("/", 1) + key = f"MSHUB_TEST_{uuid.uuid4().hex[:8].upper()}" + yield key + with contextlib.suppress(Exception): + openapi.delete_studio_variable(owner, repo_name, key) + + def test_variable_lifecycle(self, openapi, studio_id, variable_key): + owner, repo_name = studio_id.split("/", 1) + + openapi.add_studio_variable(owner, repo_name, variable_key, "first") + listed = openapi.list_studio_variables(owner, repo_name) + entries = {v["key"]: v.get("value") for v in (listed or {}).get("variables") or []} + assert entries.get(variable_key) == "first" + + openapi.update_studio_variable(owner, repo_name, variable_key, "second") + listed = openapi.list_studio_variables(owner, repo_name) + entries = {v["key"]: v.get("value") for v in (listed or {}).get("variables") or []} + assert entries.get(variable_key) == "second" + + openapi.delete_studio_variable(owner, repo_name, variable_key) + listed = openapi.list_studio_variables(owner, repo_name) + remaining = {v["key"] for v in (listed or {}).get("variables") or []} + assert variable_key not in remaining + + def test_adding_a_duplicate_reports_already_exists(self, openapi, studio_id, variable_key): + """The endpoint answers 409, which must surface as AlreadyExistsError.""" + owner, repo_name = studio_id.split("/", 1) + openapi.add_studio_variable(owner, repo_name, variable_key, "first") + with pytest.raises(AlreadyExistsError): + openapi.add_studio_variable(owner, repo_name, variable_key, "again") + + +@pytest.mark.remote +class TestOpenAPIMcp: + """MCP discovery and the caller's own hosted servers.""" + + def test_list_mcp_servers(self, openapi): + result = openapi.list_mcp_servers(page_size=5) + assert isinstance(result, dict) + assert isinstance(result.get("mcp_server_list"), list) + + def test_list_mcp_servers_rejects_excessive_offset(self, openapi): + with pytest.raises(InvalidParameter): + openapi.list_mcp_servers(page_number=11, page_size=10) + + def test_list_operational_mcp_servers(self, openapi): + result = openapi.list_operational_mcp_servers() + assert isinstance(result, dict) + assert isinstance(result.get("mcp_server_list"), list) + + +@pytest.mark.remote +class TestOpenAPIReadOnlyToken: + """Behaviour under a read-scoped token. + + Set MODELSCOPE_TEST_READONLY_TOKEN in tests/.env to a token issued with read + permission only. This is the only way to verify the permission-tier handling + end to end: the spec models no scopes, so it can only be observed at runtime. + """ + + @pytest.fixture + def readonly_config(self, test_endpoint): + token = os.environ.get("MODELSCOPE_TEST_READONLY_TOKEN") + if not token: + pytest.skip("Set MODELSCOPE_TEST_READONLY_TOKEN to exercise read-scoped behaviour") + return HubConfig(token=token, endpoint=test_endpoint) + + def test_read_operations_succeed(self, readonly_config): + client = OpenAPIClient(readonly_config) + try: + result = client.list_studios(page_size=3) + assert isinstance(result, dict) + finally: + client.close() + + def test_login_succeeds_with_reduced_capability(self, readonly_config, tmp_path, monkeypatch): + monkeypatch.setenv("MODELSCOPE_HOME", str(tmp_path)) + api = HubApi(config=HubConfig(config_dir=tmp_path, endpoint=readonly_config.endpoint)) + user = api.login(readonly_config.token) + assert user.username + + def test_write_operation_names_the_missing_permission(self, readonly_config, test_owner): + client = OpenAPIClient(readonly_config) + try: + with pytest.raises((PermissionDeniedError, AuthenticationError)) as excinfo: + client.create_studio({"owner": test_owner, "repo_name": f"mshub-perm-{uuid.uuid4().hex[:8]}"}) + finally: + client.close() + if isinstance(excinfo.value, PermissionDeniedError): + assert "write" in excinfo.value.suggestion + @pytest.mark.remote class TestOpenAPIPagination: diff --git a/tests/test_compat_studio.py b/tests/test_compat_studio.py new file mode 100644 index 0000000..5a0d2a2 --- /dev/null +++ b/tests/test_compat_studio.py @@ -0,0 +1,300 @@ +"""Regression tests for the Studio methods on the legacy compat surface. + +The old ``modelscope.hub.api.HubApi`` signature let callers append ``token=`` / +``endpoint=`` to any method, and the umbrella SDK's Studio CLI does exactly +that. Forwarding those kwargs wholesale caused two concrete defects that these +tests pin down: + +* ``update_studio_settings`` forwards its kwargs as the settings payload, so the + caller's API token was serialised into the ``PATCH`` request body. +* ``get_studio_logs`` forwards into a keyword-only signature, so passing + ``token=`` raised ``TypeError`` and the call could never succeed. + +Network-free: the OpenAPI client is mocked, so assertions run against the exact +arguments that would have gone on the wire. +""" + +from __future__ import annotations + +from unittest import mock + +import pytest + +from modelscope_hub.api import HubApi +from modelscope_hub.compat import LegacyHubApi +from modelscope_hub.compat.hub_api import _split_control_kwargs + + +def _compat(token: str = "ms-ambient-token") -> tuple[LegacyHubApi, mock.MagicMock]: + """Build a compat wrapper whose OpenAPI transport is mocked out.""" + api = HubApi(token=token) + openapi = mock.MagicMock() + api._openapi = openapi + legacy = LegacyHubApi.__new__(LegacyHubApi) + legacy._api = api + legacy._endpoint = None + return legacy, openapi + + +# --------------------------------------------------------------------------- +# The helper itself +# --------------------------------------------------------------------------- +class TestSplitControlKwargs: + def test_control_args_are_separated(self): + control, passthrough = _split_control_kwargs({"token": "ms-x", "endpoint": "https://e", "display_name": "X"}) + assert control == {"token": "ms-x", "endpoint": "https://e"} + assert passthrough == {"display_name": "X"} + + def test_all_control_keys_recognised(self): + control, passthrough = _split_control_kwargs( + { + "token": "t", + "endpoint": "e", + "cookies": "c", + "headers": {}, + "timeout": 1, + "max_retries": 2, + } + ) + assert passthrough == {} + assert set(control) == {"token", "endpoint", "cookies", "headers", "timeout", "max_retries"} + + def test_business_only_kwargs_pass_through_untouched(self): + control, passthrough = _split_control_kwargs({"key": "K", "value": "V"}) + assert control == {} + assert passthrough == {"key": "K", "value": "V"} + + +# --------------------------------------------------------------------------- +# Defect 1: the token must never reach a request body +# --------------------------------------------------------------------------- +class TestSettingsDoesNotLeakCredentials: + def test_token_and_endpoint_absent_from_patch_body(self): + legacy, openapi = _compat() + legacy.update_studio_settings( + "owner/demo", + token="ms-ambient-token", + endpoint="https://modelscope.cn", + display_name="Demo", + ) + settings = openapi.update_studio_settings.call_args[0][2] + assert "token" not in settings + assert "endpoint" not in settings + assert settings == {"display_name": "Demo"} + + def test_every_control_key_is_stripped(self): + legacy, openapi = _compat() + legacy.update_studio_settings( + "owner/demo", + token="ms-ambient-token", + endpoint="https://modelscope.cn", + cookies="session=1", + headers={"X": "1"}, + timeout=5, + max_retries=1, + description="d", + ) + assert openapi.update_studio_settings.call_args[0][2] == {"description": "d"} + + def test_business_fields_still_reach_the_wire(self): + legacy, openapi = _compat() + legacy.update_studio_settings( + "owner/demo", + token="ms-ambient-token", + display_name="Demo", + description="d", + license="apache-2.0", + private=True, + ) + assert openapi.update_studio_settings.call_args[0][2] == { + "display_name": "Demo", + "description": "d", + "license": "apache-2.0", + "private": True, + } + + +# --------------------------------------------------------------------------- +# Defect 2: ``studio logs`` used to raise TypeError outright +# --------------------------------------------------------------------------- +class TestLogsAcceptsControlKwargs: + def test_token_kwarg_does_not_raise_type_error(self): + legacy, openapi = _compat() + legacy.get_studio_logs( + "owner/demo", + token="ms-ambient-token", + endpoint="https://modelscope.cn", + log_type="run", + page_size=5, + ) + args, kwargs = openapi.get_studio_logs.call_args + assert args == ("owner", "demo", "run") + assert kwargs["page_size"] == 5 + + def test_log_options_are_forwarded(self): + legacy, openapi = _compat() + legacy.get_studio_logs( + "owner/demo", + token="ms-ambient-token", + log_type="build", + page_num=3, + page_size=50, + keyword="ERROR", + start_timestamp=1, + end_timestamp=2, + ) + args, kwargs = openapi.get_studio_logs.call_args + assert args == ("owner", "demo", "build") + assert kwargs == { + "page_num": 3, + "page_size": 50, + "keyword": "ERROR", + "start_timestamp": 1, + "end_timestamp": 2, + } + + +# --------------------------------------------------------------------------- +# Defect 3: a per-call token used to be silently ignored +# --------------------------------------------------------------------------- +class TestPerCallTokenOverride: + @pytest.mark.parametrize( + ("method", "args"), + [ + ("deploy_studio", ("owner/demo",)), + ("stop_studio", ("owner/demo",)), + ("list_studio_secrets", ("owner/demo",)), + ("add_studio_secret", ("owner/demo", "K", "V")), + ("update_studio_secret", ("owner/demo", "K", "V")), + ("delete_studio_secret", ("owner/demo", "K")), + ("update_studio_settings", ("owner/demo",)), + ], + ) + def test_distinct_token_builds_a_dedicated_client(self, method, args): + legacy, _ = _compat(token="ms-ambient-token") + with mock.patch("modelscope_hub.compat.hub_api.HubApi") as hub_cls: + getattr(legacy, method)(*args, token="ms-per-call") + hub_cls.assert_called_once() + assert hub_cls.call_args.kwargs["token"] == "ms-per-call" + + def test_matching_token_reuses_the_ambient_client(self): + legacy, openapi = _compat(token="ms-ambient-token") + with mock.patch("modelscope_hub.compat.hub_api.HubApi") as hub_cls: + legacy.stop_studio("owner/demo", token="ms-ambient-token") + hub_cls.assert_not_called() + openapi.stop_studio.assert_called_once_with("owner", "demo") + + def test_no_token_reuses_the_ambient_client(self): + legacy, openapi = _compat() + with mock.patch("modelscope_hub.compat.hub_api.HubApi") as hub_cls: + legacy.deploy_studio("owner/demo") + hub_cls.assert_not_called() + openapi.deploy_studio.assert_called_once() + + def test_distinct_endpoint_builds_a_dedicated_client(self): + legacy, _ = _compat() + with mock.patch("modelscope_hub.compat.hub_api.HubApi") as hub_cls: + legacy.stop_studio("owner/demo", endpoint="https://modelscope.ai") + hub_cls.assert_called_once() + assert hub_cls.call_args.kwargs["endpoint"] == "https://modelscope.ai" + + +# --------------------------------------------------------------------------- +# Untouched delegation behaviour +# --------------------------------------------------------------------------- +class TestStudioDelegation: + def test_deploy_forwards_payload(self): + legacy, openapi = _compat() + legacy.deploy_studio("owner/demo", payload={"a": 1}, token="ms-ambient-token") + assert openapi.deploy_studio.call_args[0] == ("owner", "demo", {"a": 1}) + + def test_secret_writes_return_none(self): + legacy, _ = _compat() + assert legacy.add_studio_secret("owner/demo", "K", "V") is None + assert legacy.update_studio_secret("owner/demo", "K", "V") is None + assert legacy.delete_studio_secret("owner/demo", "K") is None + + +# --------------------------------------------------------------------------- +# Newly delegated Studio operations +# --------------------------------------------------------------------------- +class TestNewStudioDelegation: + def test_list_studios_returns_a_paginated_dict(self): + legacy, openapi = _compat() + openapi.list_studios.return_value = { + "studios": [{"id": "owner/demo"}], + "total_count": 1, + "page_number": 1, + "page_size": 10, + } + result = legacy.list_studios(token="ms-ambient-token") + assert result["total_count"] == 1 + assert result["studios"][0]["id"] == "owner/demo" + + def test_list_studios_forwards_filters(self): + legacy, openapi = _compat() + openapi.list_studios.return_value = {"studios": []} + legacy.list_studios(owner="alice", page_size=20, token="ms-ambient-token") + kwargs = openapi.list_studios.call_args.kwargs + assert kwargs["owner"] == "alice" + assert kwargs["page_size"] == 20 + + def test_list_variables_unwraps_the_payload(self): + legacy, openapi = _compat() + openapi.list_studio_variables.return_value = {"variables": [{"key": "K", "value": "V"}]} + assert legacy.list_studio_variables("owner/demo") == [{"key": "K", "value": "V"}] + assert openapi.list_studio_variables.call_args[0] == ("owner", "demo") + + @pytest.mark.parametrize( + ("method", "args", "target"), + [ + ("add_studio_variable", ("owner/demo", "K", "V"), "add_studio_variable"), + ("update_studio_variable", ("owner/demo", "K", "V"), "update_studio_variable"), + ("delete_studio_variable", ("owner/demo", "K"), "delete_studio_variable"), + ], + ) + def test_variable_writes_return_none_and_reach_the_endpoint(self, method, args, target): + legacy, openapi = _compat() + assert getattr(legacy, method)(*args) is None + assert getattr(openapi, target).call_args[0][:2] == ("owner", "demo") + + def test_hardware_unwraps_and_forwards_options(self): + legacy, openapi = _compat() + openapi.list_studio_hardware.return_value = {"hardware": [{"name": "cpu"}]} + assert legacy.list_studio_hardware(sdk_type="gradio") == [{"name": "cpu"}] + assert openapi.list_studio_hardware.call_args.kwargs["sdk_type"] == "gradio" + + def test_base_images_unwraps_the_payload(self): + legacy, openapi = _compat() + openapi.list_studio_base_images.return_value = {"base_images": [{"name": "ubuntu"}]} + assert legacy.list_studio_base_images() == [{"name": "ubuntu"}] + + def test_sdk_versions_unwraps_and_forwards_options(self): + legacy, openapi = _compat() + openapi.list_studio_sdk_versions.return_value = {"sdk_versions": [{"version": "4.44.1"}]} + assert legacy.list_studio_sdk_versions(sdk_type="gradio") == [{"version": "4.44.1"}] + assert openapi.list_studio_sdk_versions.call_args.kwargs["sdk_type"] == "gradio" + + @pytest.mark.parametrize( + ("method", "args"), + [ + ("list_studios", ()), + ("list_studio_variables", ("owner/demo",)), + ("add_studio_variable", ("owner/demo", "K", "V")), + ("update_studio_variable", ("owner/demo", "K", "V")), + ("delete_studio_variable", ("owner/demo", "K")), + ("list_studio_hardware", ()), + ("list_studio_base_images", ()), + ("list_studio_sdk_versions", ()), + ], + ) + def test_control_kwargs_never_become_business_arguments(self, method, args): + """Every new shim must strip token/endpoint like the existing ones do.""" + legacy, openapi = _compat() + openapi.list_studios.return_value = {"studios": []} + with mock.patch("modelscope_hub.compat.hub_api.HubApi") as hub_cls: + getattr(legacy, method)(*args, token="ms-per-call", endpoint="https://modelscope.ai") + hub_cls.assert_called_once() + for call in hub_cls.return_value.mock_calls: + assert "token" not in call.kwargs + assert "endpoint" not in call.kwargs diff --git a/tests/test_errors_openapi_codes.py b/tests/test_errors_openapi_codes.py new file mode 100644 index 0000000..fdfd14e --- /dev/null +++ b/tests/test_errors_openapi_codes.py @@ -0,0 +1,165 @@ +"""Classification tests for the OpenAPI error envelope. + +The ``/openapi/v1`` surface reports failures as ``{"success": false, "code": +"", "message": ..., "request_id": ...}``. The SDK only understood the +legacy *numeric* business code, so every one of these string codes fell through +to the bare HTTP status -- collapsing distinctions the status cannot express: +403 covers both "insufficient token permission" and "quota exhausted", and a 409 +duplicate had no mapping at all. +""" + +from __future__ import annotations + +import json + +import pytest +import requests + +from modelscope_hub._openapi import _RETRYABLE_EXC +from modelscope_hub.errors import ( + AlreadyExistsError, + APIError, + AuthenticationError, + InvalidParameter, + NotExistError, + PermissionDeniedError, + QuotaExceededError, + RateLimitError, + ServerError, + raise_for_status, +) + +_URL = "https://modelscope.cn/openapi/v1/studios" + + +def _openapi_error(status: int, code: str, message: str = "failed") -> requests.Response: + """Build the exact envelope the OpenAPI surface returns on failure.""" + resp = requests.Response() + resp.status_code = status + resp._content = json.dumps( + { + "success": False, + "code": code, + "message": message, + "request_id": "4d3f4b7d-95be-4e6f-95eb-d75178375bd2", + } + ).encode() + resp.headers["Content-Type"] = "application/json" + resp.url = _URL + return resp + + +def _plain_error(status: int) -> requests.Response: + resp = requests.Response() + resp.status_code = status + resp._content = b'{"success": false, "message": "no code"}' + resp.headers["Content-Type"] = "application/json" + resp.url = _URL + return resp + + +# --------------------------------------------------------------------------- +# String code -> exception +# --------------------------------------------------------------------------- +@pytest.mark.parametrize( + ("status", "code", "expected"), + [ + (400, "InputParameterError", InvalidParameter), + (401, "InvalidAuthentication", AuthenticationError), + (403, "OperationNotAllowed", PermissionDeniedError), + (404, "ResourceNotFound", NotExistError), + (409, "DuplicateEntity", AlreadyExistsError), + (403, "QuotaLimitExceed", QuotaExceededError), + (429, "RateLimitExceed", RateLimitError), + ], +) +def test_string_code_selects_the_exception(status, code, expected): + with pytest.raises(expected) as excinfo: + raise_for_status(_openapi_error(status, code)) + # AlreadyExistsError subclasses InvalidParameter, so assert the exact type. + assert type(excinfo.value) is expected + + +def test_quota_and_permission_share_403_but_not_the_exception(): + """403 is overloaded upstream; the code is the only thing that separates them.""" + with pytest.raises(PermissionDeniedError): + raise_for_status(_openapi_error(403, "OperationNotAllowed")) + with pytest.raises(QuotaExceededError): + raise_for_status(_openapi_error(403, "QuotaLimitExceed")) + + +def test_message_request_id_and_status_survive_classification(): + with pytest.raises(QuotaExceededError) as excinfo: + raise_for_status(_openapi_error(403, "QuotaLimitExceed", "magicube balance exhausted")) + exc = excinfo.value + assert exc.status_code == 403 + assert exc.request_id == "4d3f4b7d-95be-4e6f-95eb-d75178375bd2" + assert "magicube balance exhausted" in exc.message + + +@pytest.mark.parametrize("code", ["ServiceUnavailable", "InternalServerError"]) +@pytest.mark.parametrize("status", [500, 503]) +def test_server_side_codes_stay_retryable(status, code): + """A 5xx must never be reclassified: doing so would drop ``retryable``.""" + with pytest.raises(ServerError) as excinfo: + raise_for_status(_openapi_error(status, code)) + assert type(excinfo.value) is ServerError + assert excinfo.value.retryable is True + + +def test_unknown_string_code_falls_back_to_the_status(): + with pytest.raises(NotExistError): + raise_for_status(_openapi_error(404, "SomeCodeWeHaveNeverSeen")) + + +# --------------------------------------------------------------------------- +# Status-code table gaps this work closed +# --------------------------------------------------------------------------- +def test_409_maps_to_already_exists_even_without_a_code(): + """createStudio / addStudioSecret / addStudioVariable all answer bare 409.""" + with pytest.raises(AlreadyExistsError) as excinfo: + raise_for_status(_plain_error(409)) + assert type(excinfo.value) is AlreadyExistsError + + +def test_413_maps_to_invalid_parameter(): + """POST /files/upload answers 413 when the body exceeds 5 MiB.""" + with pytest.raises(InvalidParameter): + raise_for_status(_plain_error(413)) + + +# --------------------------------------------------------------------------- +# Retry policy +# --------------------------------------------------------------------------- +def test_quota_exceeded_is_not_retryable(): + """Reusing RateLimitError here would make the transport retry a hard failure.""" + assert QuotaExceededError.retryable is False + assert not issubclass(QuotaExceededError, RateLimitError) + assert QuotaExceededError not in _RETRYABLE_EXC + assert not issubclass(QuotaExceededError, _RETRYABLE_EXC) + + +def test_rate_limit_exceeded_stays_retryable(): + assert RateLimitError.retryable is True + assert issubclass(RateLimitError, _RETRYABLE_EXC) + + +def test_quota_error_is_an_api_error(): + assert issubclass(QuotaExceededError, APIError) + assert QuotaExceededError.error_code == "E3027" + + +def test_quota_error_is_exported_from_the_package_root(): + import modelscope_hub + + assert modelscope_hub.QuotaExceededError is QuotaExceededError + assert "QuotaExceededError" in modelscope_hub.__all__ + + +def test_retry_after_still_honoured_for_a_string_rate_limit_code(): + """RateLimitError carries retry_after regardless of how it was selected.""" + resp = _openapi_error(429, "RateLimitExceed") + resp.headers["Retry-After"] = "7" + with pytest.raises(RateLimitError) as excinfo: + raise_for_status(resp) + assert excinfo.value.retry_after == 7 diff --git a/tests/test_login_scoped_token.py b/tests/test_login_scoped_token.py new file mode 100644 index 0000000..e4db1f5 --- /dev/null +++ b/tests/test_login_scoped_token.py @@ -0,0 +1,203 @@ +"""Login behaviour for permission-tiered API tokens. + +Tokens are issued as read / write / admin. The legacy ``POST /api/v1/login`` +endpoint exists to mint *git* credentials, which a read-only token is not +entitled to -- yet such a token authenticates fine and is all a caller needs to +browse and download. Reporting that refusal as "invalid token" sends the user +after the wrong remedy (re-issuing a token that was never the problem). + +The transport is stubbed with ``responses`` so the real config, HubApi, +LegacyClient and error-translation layers all take part. +""" + +from __future__ import annotations + +from unittest import mock + +import pytest +import requests +import responses + +from modelscope_hub.api import HubApi +from modelscope_hub.config import HubConfig +from modelscope_hub.constants import DEFAULT_ENDPOINT, DEFAULT_INTL_ENDPOINT, USER_INFO_FILE_NAME +from modelscope_hub.errors import AuthenticationError, NetworkError + +CN_LOGIN = f"{DEFAULT_ENDPOINT}/api/v1/login" +AI_LOGIN = f"{DEFAULT_INTL_ENDPOINT}/api/v1/login" +CN_USERS_ME = f"{DEFAULT_ENDPOINT}/openapi/v1/users/me" + +READONLY_TOKEN = "ms-readonly-token" + +# What the legacy login endpoint answers for a token it will not mint git +# credentials for. +LOGIN_REFUSED_BODY = { + "Code": 10010103009, + "Message": "登录失败,AccessToken错误,请从用户中心获取AccessToken或刷新", + "RequestId": "8a039827-3f7c-4378-9b7c-3f8341b73649", + "Success": False, +} + +USERS_ME_BODY = { + "success": True, + "data": {"username": "alice", "email": "alice@example.com", "nickname": "Alice"}, + "request_id": "4d3f4b7d-95be-4e6f-95eb-d75178375bd2", +} + + +@pytest.fixture(autouse=True) +def isolated_home(tmp_path, monkeypatch): + """Redirect credential storage and drop ambient endpoint/token overrides.""" + for name in ("MODELSCOPE_ENDPOINT", "MODELSCOPE_API_TOKEN", "MODELSCOPE_DOMAIN"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("MODELSCOPE_HOME", str(tmp_path)) + return tmp_path + + +def _api(home) -> HubApi: + return HubApi(config=HubConfig(config_dir=home), endpoint=DEFAULT_ENDPOINT) + + +# --------------------------------------------------------------------------- +# The token is valid, only its tier is limited +# --------------------------------------------------------------------------- +@responses.activate +def test_scoped_token_logs_in_successfully(isolated_home): + responses.add(responses.POST, CN_LOGIN, json=LOGIN_REFUSED_BODY, status=400) + responses.add(responses.GET, CN_USERS_ME, json=USERS_ME_BODY, status=200) + + user = _api(isolated_home).login(READONLY_TOKEN) + + assert user.username == "alice" + assert user.email == "alice@example.com" + + +@responses.activate +def test_scoped_login_persists_the_token(isolated_home): + responses.add(responses.POST, CN_LOGIN, json=LOGIN_REFUSED_BODY, status=400) + responses.add(responses.GET, CN_USERS_ME, json=USERS_ME_BODY, status=200) + + api = _api(isolated_home) + api.login(READONLY_TOKEN) + + assert api._config.token == READONLY_TOKEN + assert HubConfig(config_dir=isolated_home).load_token() == READONLY_TOKEN + + +@responses.activate +def test_scoped_login_records_the_user_but_no_git_token(isolated_home): + """Cookies and the git token are unavailable at this tier, so they stay unset.""" + responses.add(responses.POST, CN_LOGIN, json=LOGIN_REFUSED_BODY, status=400) + responses.add(responses.GET, CN_USERS_ME, json=USERS_ME_BODY, status=200) + + _api(isolated_home).login(READONLY_TOKEN) + + config = HubConfig(config_dir=isolated_home) + user_file = config.credentials_dir / USER_INFO_FILE_NAME + assert user_file.read_text(encoding="utf-8") == "alice:alice@example.com" + assert not config.load_git_token() + + +@responses.activate +def test_scoped_login_warns_about_write_operations(isolated_home): + responses.add(responses.POST, CN_LOGIN, json=LOGIN_REFUSED_BODY, status=400) + responses.add(responses.GET, CN_USERS_ME, json=USERS_ME_BODY, status=200) + + # The SDK logger deliberately does not propagate, so caplog cannot see it. + with mock.patch("modelscope_hub.api.logger.warning") as warn: + _api(isolated_home).login(READONLY_TOKEN) + + warn.assert_called_once() + rendered = warn.call_args[0][0] % warn.call_args[0][1:] + assert "reduced permissions" in rendered + assert "write" in rendered + assert "alice" in rendered + + +# --------------------------------------------------------------------------- +# A genuinely bad token must still fail, exactly as before +# --------------------------------------------------------------------------- +@responses.activate +def test_invalid_token_still_raises(isolated_home): + responses.add(responses.POST, CN_LOGIN, json=LOGIN_REFUSED_BODY, status=400) + responses.add( + responses.GET, + CN_USERS_ME, + json={"success": False, "code": "InvalidAuthentication", "message": "bad token"}, + status=401, + ) + + with pytest.raises(AuthenticationError) as excinfo: + _api(isolated_home).login("ms-bad-token") + + assert LOGIN_REFUSED_BODY["Message"] in excinfo.value.message + assert excinfo.value.request_id == LOGIN_REFUSED_BODY["RequestId"] + + +@responses.activate +def test_invalid_token_leaves_the_stored_credential_alone(isolated_home): + config = HubConfig(config_dir=isolated_home) + config.save_token("ms-previously-working") + + responses.add(responses.POST, CN_LOGIN, json=LOGIN_REFUSED_BODY, status=400) + responses.add(responses.GET, CN_USERS_ME, json={"success": False}, status=401) + + api = _api(isolated_home) + with pytest.raises(AuthenticationError): + api.login("ms-bad-token") + + assert HubConfig(config_dir=isolated_home).load_token() == "ms-previously-working" + assert api._config.token == "ms-previously-working" + + +@responses.activate +def test_probe_returning_no_username_is_not_treated_as_success(isolated_home): + """A 200 with an empty profile cannot confirm the token; the refusal stands.""" + responses.add(responses.POST, CN_LOGIN, json=LOGIN_REFUSED_BODY, status=400) + responses.add(responses.GET, CN_USERS_ME, json={"success": True, "data": {}}, status=200) + + with pytest.raises(AuthenticationError): + _api(isolated_home).login("ms-odd-token") + + +@responses.activate +def test_probe_transport_failure_does_not_mask_the_refusal(isolated_home): + """The probe is advisory: if it cannot run, the original error is surfaced.""" + responses.add(responses.POST, CN_LOGIN, json=LOGIN_REFUSED_BODY, status=400) + responses.add(responses.GET, CN_USERS_ME, body=requests.ConnectionError("reset")) + + with pytest.raises(AuthenticationError): + _api(isolated_home).login("ms-bad-token") + + +# --------------------------------------------------------------------------- +# Non-authentication failures are untouched by this path +# --------------------------------------------------------------------------- +@responses.activate +def test_network_failure_never_reaches_the_probe(isolated_home): + """A transport error is not a credential verdict, so no probe is attempted.""" + responses.add(responses.POST, CN_LOGIN, body=requests.ConnectionError("connection reset")) + + with pytest.raises(NetworkError): + _api(isolated_home).login("ms-any-token") + + assert all(CN_USERS_ME not in call.request.url for call in responses.calls) + + +@responses.activate +def test_successful_login_never_reaches_the_probe(isolated_home): + responses.add( + responses.POST, + CN_LOGIN, + json={ + "Code": 200, + "Data": {"AccessToken": "git-token", "Email": "a@b.c", "Username": "alice"}, + "Success": True, + }, + status=200, + ) + responses.add(responses.GET, CN_USERS_ME, json=USERS_ME_BODY, status=200) + + api = _api(isolated_home) + assert api.login("ms-write-token").username == "alice" + assert HubConfig(config_dir=isolated_home).load_git_token() == "git-token" diff --git a/tests/test_openapi_coverage.py b/tests/test_openapi_coverage.py new file mode 100644 index 0000000..ce2cead --- /dev/null +++ b/tests/test_openapi_coverage.py @@ -0,0 +1,128 @@ +"""Guard against the OpenAPI specification drifting away from the client. + +Every gap this work closed had the same cause: the service published new +operations and nothing in the suite noticed. `tests/data/openapi.json` is a +vendored copy of the live document, and these tests assert that the tags the SDK +claims to cover are covered *completely*. + +When the spec is refreshed and a covered tag gained an operation, the first test +fails and names it. Tags still to be implemented are listed in `_DEFERRED_TAGS`, +which doubles as the remaining to-do list. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from modelscope_hub._openapi import OPERATION_REGISTRY, OpenAPIClient +from modelscope_hub.constants import TokenScope + +_SPEC_PATH = Path(__file__).parent / "data" / "openapi.json" +_HTTP_METHODS = frozenset({"get", "post", "put", "delete", "patch", "head", "options"}) + +# Tags the SDK implements end to end. +_COVERED_TAGS = frozenset({"MCP", "Studios"}) + +# Tags not implemented yet. Remove an entry as its tag lands; until then the +# guard would otherwise fail on operations nobody has promised. +_DEFERRED_TAGS = frozenset( + { + "User", + "Models", + "Datasets", + "Skills", + "Files", + "Collections", + "Agent-IDP", + "Galleries", + "Magicube", + } +) + + +@pytest.fixture(scope="module") +def spec() -> dict: + return json.loads(_SPEC_PATH.read_text(encoding="utf-8")) + + +def _operations(spec: dict) -> list[tuple[str, str, str, str]]: + """Flatten the spec into ``(tag, operation_id, method, path)`` rows.""" + rows: list[tuple[str, str, str, str]] = [] + for path, item in spec["paths"].items(): + for method, operation in item.items(): + if method not in _HTTP_METHODS: + continue + tags = operation.get("tags") or [""] + rows.append((tags[0], operation["operationId"], method.upper(), path)) + return rows + + +# --------------------------------------------------------------------------- +# Coverage +# --------------------------------------------------------------------------- +def test_every_covered_operation_is_registered(spec): + expected = {op_id for tag, op_id, _, _ in _operations(spec) if tag in _COVERED_TAGS} + missing = sorted(expected - set(OPERATION_REGISTRY)) + assert not missing, ( + f"{len(missing)} operation(s) in the covered tags {sorted(_COVERED_TAGS)} are not in " + f"OPERATION_REGISTRY: {missing}. Implement them on OpenAPIClient and register them, " + f"or move the tag to _DEFERRED_TAGS." + ) + + +def test_registry_has_no_unknown_operations(spec): + known = {op_id for _, op_id, _, _ in _operations(spec)} + unknown = sorted(set(OPERATION_REGISTRY) - known) + assert not unknown, f"OPERATION_REGISTRY references operationIds absent from the spec: {unknown}" + + +def test_registry_does_not_claim_deferred_tags(spec): + deferred = {op_id for tag, op_id, _, _ in _operations(spec) if tag in _DEFERRED_TAGS} + overlap = sorted(deferred & set(OPERATION_REGISTRY)) + assert not overlap, ( + f"These operations are registered but their tag is still listed as deferred: {overlap}. " + f"Remove the tag from _DEFERRED_TAGS once it is fully covered." + ) + + +def test_every_tag_is_accounted_for(spec): + """A brand-new tag must be an explicit decision, not an oversight.""" + tags = {tag for tag, _, _, _ in _operations(spec)} + unclassified = sorted(tags - _COVERED_TAGS - _DEFERRED_TAGS) + assert not unclassified, f"Unclassified tag(s) {unclassified}: add each to _COVERED_TAGS or _DEFERRED_TAGS." + + +# --------------------------------------------------------------------------- +# Registry integrity +# --------------------------------------------------------------------------- +@pytest.mark.parametrize(("operation_id", "entry"), sorted(OPERATION_REGISTRY.items())) +def test_registered_method_exists_and_is_callable(operation_id, entry): + method_name, scope = entry + method = getattr(OpenAPIClient, method_name, None) + assert method is not None, f"{operation_id} maps to OpenAPIClient.{method_name}, which does not exist" + assert callable(method) + assert isinstance(scope, TokenScope) + + +def test_registry_maps_each_operation_to_a_distinct_method(): + methods = [method for method, _ in OPERATION_REGISTRY.values()] + duplicates = sorted({m for m in methods if methods.count(m) > 1}) + assert not duplicates, f"One method serves several operations: {duplicates}" + + +# --------------------------------------------------------------------------- +# The vendored spec itself +# --------------------------------------------------------------------------- +def test_spec_is_the_expected_document(spec): + assert spec["openapi"].startswith("3.1") + assert spec["info"]["title"] == "ModelScope OpenAPI" + assert spec["servers"][0]["url"].endswith("/openapi/v1") + + +def test_covered_tags_account_for_every_registered_entry(spec): + """Registry size must equal the covered operation count -- no silent drift.""" + covered = [op_id for tag, op_id, _, _ in _operations(spec) if tag in _COVERED_TAGS] + assert len(OPERATION_REGISTRY) == len(covered)