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)