From 8350a31fba7da4ce23e96093238f0b2e2ad8d52d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Tue, 1 Sep 2026 18:56:12 +0800 Subject: [PATCH 1/4] [Feature] Full OpenAPI coverage for MCP and Studios, with token permission tiers Audited the live OpenAPI document (1.1.0+master.20260813) against the client: of the 23 operations under the MCP and Studios tags, 8 were unimplemented and several implemented ones had drifted from the spec. Closes the gap and adds a guard so the next drift fails the suite instead of going unnoticed. Feature - Studios: list_studios, list_studio_hardware / _base_images / _sdk_versions, and the four plaintext-variable operations; exposed as `ms-hub studio list`, `studio variable`, `studio hardware|base-images|sdk-versions`, and `ms-hub list --repo-type studio`. - MCP: HubApi.list_operational_mcp_servers() plus `ms-hub mcp list --hosted`, which also unblocks the umbrella SDK delegating its own MCPApi here. - Studio visibility gains `protected` (app public, code hidden), previously inexpressible because visibility was folded into a `private` bool. - RepoInfo now carries the Studio runtime fields (sdk_type, sdk_version, base_image, hardware, mcp_support, runtime) instead of discarding them. - Token permission tiers: TokenScope annotations, a read-scoped token can log in (warning that writes need a higher tier), and a rejected write names the tier it requires. Fix - The compat layer forwarded `token` / `endpoint` into request bodies, so `HubApi().update_studio_settings(..., token=...)` serialised the caller's API token into the PATCH body. All Studio shims now split control kwargs from business fields. - `HubApi().get_studio_logs(..., token=...)` raised TypeError and could never succeed; a per-call `token=` was silently ignored on every Studio shim. - `cover_image` was renamed to `coverImage` on the way out and dropped by the server, so Studio cover images never applied. - 403 now separates insufficient permission from an exhausted quota (new QuotaExceededError, deliberately not retryable); 409 maps to AlreadyExistsError; the OpenAPI string error codes are recognised at all. - `get_studio` demanded a token for spaces the spec says are public. - `studio logs` read a `total` field the response never had, so its pagination footer never printed. Enhance - MCP discovery sends PUT first -- the only verb the spec defines -- and remembers which verb a deployment serves, so the loser is tried at most once. - Listing a Studio owner asks for every status: the endpoint keeps filtering to running spaces even with an owner set, so "list my spaces" returned nothing. Documentation - README: new Studio/MCP command surface, a Token permission levels section, and the SDK method reference. Testing - Vendored the spec under tests/data/ and check it against OPERATION_REGISTRY. - 723 unit tests (881 in mock mode) and 28 integration tests against the live service. --- README.md | 149 +- src/modelscope_hub/__init__.py | 6 +- src/modelscope_hub/_openapi.py | 492 +- src/modelscope_hub/api.py | 332 +- src/modelscope_hub/cli/mcp.py | 31 + src/modelscope_hub/cli/repo.py | 14 + src/modelscope_hub/cli/studio.py | 326 +- src/modelscope_hub/compat/hub_api.py | 99 +- src/modelscope_hub/constants.py | 51 + src/modelscope_hub/errors.py | 63 + src/modelscope_hub/types.py | 29 +- src/modelscope_hub/version.py | 2 +- tests/.env.example | 9 + tests/cli/test_mcp.py | 41 + tests/cli/test_openapi.py | 140 +- tests/cli/test_openapi_studio.py | 360 + tests/cli/test_repo.py | 20 +- tests/cli/test_studio.py | 269 + tests/data/README.md | 29 + tests/data/openapi.json | 10183 +++++++++++++++++++++++++ tests/integration/test_openapi.py | 174 + tests/test_compat_studio.py | 300 + tests/test_errors_openapi_codes.py | 165 + tests/test_login_scoped_token.py | 203 + tests/test_openapi_coverage.py | 128 + 25 files changed, 13476 insertions(+), 139 deletions(-) create mode 100644 tests/cli/test_openapi_studio.py create mode 100644 tests/data/README.md create mode 100644 tests/data/openapi.json create mode 100644 tests/test_compat_studio.py create mode 100644 tests/test_errors_openapi_codes.py create mode 100644 tests/test_login_scoped_token.py create mode 100644 tests/test_openapi_coverage.py diff --git a/README.md b/README.md index 7c27b19..103c228 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,19 @@ The official Python SDK & CLI for [ModelScope Hub](https://modelscope.cn) — do ## News +**v0.4.0** (2026-09-01) +- **Feature**: full OpenAPI coverage for MCP and Studios — `ms-hub studio list`, `ms-hub list --repo-type studio`, `ms-hub studio variable` (plaintext environment variables), and `ms-hub studio hardware` / `base-images` / `sdk-versions` so valid `--hardware` / `--base-image` / `--sdk-version` values no longer have to be guessed; `ms-hub mcp list --hosted` and `HubApi.list_operational_mcp_servers()` +- **Feature**: Studio `--visibility protected` (app public, code repository hidden), previously inexpressible; `RepoInfo` now carries the Studio runtime fields (`sdk_type`, `sdk_version`, `base_image`, `hardware`, `mcp_support`, `runtime`) instead of discarding them +- **Feature**: permission-tiered token support — a read-only token can now `login` (with a warning that write operations need a higher tier), and a rejected write reports which tier it requires +- **Fix**: the compat layer no longer forwards `token` / `endpoint` into request bodies — `HubApi().update_studio_settings(..., token=...)` was serialising your **API token into the `PATCH` body** +- **Fix**: `HubApi().get_studio_logs(..., token=...)` raised `TypeError` and could never succeed; a per-call `token=` on the Studio compat methods was silently ignored +- **Fix**: `cover_image` was renamed to `coverImage` on the way out and silently dropped by the server, so Studio cover images never applied (`ms-hub studio settings --cover-image`, `create_repo(cover_image=...)`) +- **Fix**: HTTP 403 now distinguishes insufficient permission (`PermissionDeniedError`) from an exhausted quota (new `QuotaExceededError`, never retried); 409 maps to `AlreadyExistsError`; the OpenAPI string error codes are recognised at last +- **Fix**: `ms-hub info --repo-type studio` works anonymously for public spaces; `ms-hub studio logs` prints its pagination footer again (it read a field the response never had) +- **Enhance**: MCP discovery sends `PUT` first — the only verb the spec defines — instead of spending a wasted round trip on a `GET` 404 every call; whichever verb a deployment serves is remembered, so the loser is attempted at most once +- **Enhance**: `ms-hub studio list --owner` / `ms-hub list --repo-type studio --owner` now ask for every status, because the endpoint keeps filtering to running spaces even with an owner set and "list my spaces" therefore answered with nothing +- **Quality**: the OpenAPI specification is vendored under `tests/data/` and checked against an operation registry, so a newly published MCP/Studios endpoint now fails the suite by name + **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`) @@ -264,6 +277,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 +443,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 +458,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 +497,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 +522,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 | + +`ms-hub secret` accepts `--repo-type` (default: `studio`, currently the only supported type). -All subcommands accept `--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 +589,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 +600,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 +710,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 +726,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 +748,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) From 49af7449e46ce07dfe50f044d409d1795dab7345 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Tue, 1 Sep 2026 20:33:16 +0800 Subject: [PATCH 2/4] [Docs] Add the missing 0.3.x release notes and condense the News section The News list had no entry for 0.3.0 or 0.3.1, so it jumped straight from 0.4.0 to 0.2.0 and left the release that took over all four console scripts undocumented. - Adds v0.3.0 (unified console scripts, repo/files truncation mitigation, get_current_username, agent visibility) and v0.3.1 (upload env var rename with deprecation shims, logout wiring, download lock paths, UserInfo and legacy compat field mapping), both reconstructed from the commits in each range. - Condenses v0.4.0 from eleven bullets to nine by merging entries that share a root cause: the three compat kwargs defects are one leak, and the two MCP/ Studio request-shape improvements are one behaviour change. - Folds v0.2.0 and everything older into the "Older releases" block, so only the three current releases are visible by default. --- README.md | 36 ++++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 103c228..1c1fa39 100644 --- a/README.md +++ b/README.md @@ -34,17 +34,28 @@ The official Python SDK & CLI for [ModelScope Hub](https://modelscope.cn) — do ## News **v0.4.0** (2026-09-01) -- **Feature**: full OpenAPI coverage for MCP and Studios — `ms-hub studio list`, `ms-hub list --repo-type studio`, `ms-hub studio variable` (plaintext environment variables), and `ms-hub studio hardware` / `base-images` / `sdk-versions` so valid `--hardware` / `--base-image` / `--sdk-version` values no longer have to be guessed; `ms-hub mcp list --hosted` and `HubApi.list_operational_mcp_servers()` -- **Feature**: Studio `--visibility protected` (app public, code repository hidden), previously inexpressible; `RepoInfo` now carries the Studio runtime fields (`sdk_type`, `sdk_version`, `base_image`, `hardware`, `mcp_support`, `runtime`) instead of discarding them -- **Feature**: permission-tiered token support — a read-only token can now `login` (with a warning that write operations need a higher tier), and a rejected write reports which tier it requires -- **Fix**: the compat layer no longer forwards `token` / `endpoint` into request bodies — `HubApi().update_studio_settings(..., token=...)` was serialising your **API token into the `PATCH` body** -- **Fix**: `HubApi().get_studio_logs(..., token=...)` raised `TypeError` and could never succeed; a per-call `token=` on the Studio compat methods was silently ignored -- **Fix**: `cover_image` was renamed to `coverImage` on the way out and silently dropped by the server, so Studio cover images never applied (`ms-hub studio settings --cover-image`, `create_repo(cover_image=...)`) -- **Fix**: HTTP 403 now distinguishes insufficient permission (`PermissionDeniedError`) from an exhausted quota (new `QuotaExceededError`, never retried); 409 maps to `AlreadyExistsError`; the OpenAPI string error codes are recognised at last -- **Fix**: `ms-hub info --repo-type studio` works anonymously for public spaces; `ms-hub studio logs` prints its pagination footer again (it read a field the response never had) -- **Enhance**: MCP discovery sends `PUT` first — the only verb the spec defines — instead of spending a wasted round trip on a `GET` 404 every call; whichever verb a deployment serves is remembered, so the loser is attempted at most once -- **Enhance**: `ms-hub studio list --owner` / `ms-hub list --repo-type studio --owner` now ask for every status, because the endpoint keeps filtering to running spaces even with an owner set and "list my spaces" therefore answered with nothing -- **Quality**: the OpenAPI specification is vendored under `tests/data/` and checked against an operation registry, so a newly published MCP/Studios endpoint now fails the suite by name +- **Feature**: complete OpenAPI coverage for MCP and Studios — `ms-hub studio list` / `ms-hub list --repo-type studio`, `ms-hub studio variable` (plaintext env vars), `ms-hub studio hardware` / `base-images` / `sdk-versions` so valid `--hardware` / `--base-image` / `--sdk-version` values no longer have to be guessed, and `ms-hub mcp list --hosted` +- **Feature**: Studio `--visibility protected` (app public, code hidden), previously inexpressible; `RepoInfo` now carries the Studio runtime fields (`sdk_type`, `hardware`, `runtime`, …) instead of discarding them +- **Feature**: permission-tiered tokens — a read-only token can `login` (warning that writes need a higher tier), and a rejected write names the tier it requires +- **Fix**: the compat layer forwarded `token` / `endpoint` as business fields, so `update_studio_settings(..., token=...)` serialised your **API token into the `PATCH` body**, `get_studio_logs(..., token=...)` raised `TypeError`, and a per-call `token=` was silently ignored +- **Fix**: `cover_image` was renamed to `coverImage` on the way out and dropped by the server, so Studio cover images never applied +- **Fix**: HTTP 403 now separates insufficient permission (`PermissionDeniedError`) from an exhausted quota (new `QuotaExceededError`, never retried); 409 maps to `AlreadyExistsError`; the OpenAPI string error codes are recognised at last +- **Fix**: `ms-hub info --repo-type studio` works anonymously for public spaces; `ms-hub studio logs` prints its pagination footer again +- **Enhance**: MCP discovery sends `PUT` first (the only verb the spec defines) and remembers which verb a deployment serves; listing a Studio owner asks for every status, since the endpoint otherwise answers "list my spaces" with nothing +- **Quality**: the OpenAPI spec is vendored under `tests/data/` and checked against an operation registry, so a newly published MCP/Studios endpoint fails the suite by name + +**v0.3.1** (2026-09-01) +- **Refactor**: upload environment variables renamed to carry their units and semantics (`UPLOAD_BLOB_CONNECT_TIMEOUT_SECONDS`, `UPLOAD_BLOB_MAX_ATTEMPTS`, `UPLOAD_BLOB_PROGRESS_THRESHOLD_BYTES`, …); the previous names keep working and emit a deprecation warning +- **Fix**: `ms-hub logout` is wired up (the API supported it but the CLI never exposed it); download lock filenames hash the remote path, so files sharing a basename no longer contend for one lock +- **Fix**: `whoami` and `UserInfo` map the pre-production field names to their canonical ones; `get_model_files` and other legacy compat paths align with the current server responses + +**v0.3.0** (2026-08-19) +- **Feature**: this package now installs all four console scripts — `modelscope`, `ms`, `modelscope-hub`, `ms-hub` — so exactly one distribution owns them and neither package can strand the other's CLI on upgrade; the umbrella SDK contributes its commands as plugins instead +- **Fix**: the server truncates `repo/files` at a fixed entry count with no marker and ignores pagination, silently hiding files in large repos; listings that land on the cap are now re-enumerated per directory +- **Feature**: `HubApi.get_current_username()`; agent visibility reads the API's new boolean `private` field while still accepting the legacy `visibility` string + +
+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 @@ -62,9 +73,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 From db79f8b97f05ececcc1d1cae2ff40b3cb625a106 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Tue, 1 Sep 2026 21:05:01 +0800 Subject: [PATCH 3/4] [Docs] Merge like-category entries and tighten the News bullets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - v0.4.0: fold the second Feature bullet into the OpenAPI-coverage one, join the cover_image leak into the compat-kwarg fix, and merge the two small studio fixes into the error-handling bullet — nine bullets become six, one per category except Feature. - v0.3.0/v0.3.1: trim wordy phrasing and group Feature items first. --- README.md | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 1c1fa39..69351fe 100644 --- a/README.md +++ b/README.md @@ -34,25 +34,22 @@ The official Python SDK & CLI for [ModelScope Hub](https://modelscope.cn) — do ## News **v0.4.0** (2026-09-01) -- **Feature**: complete OpenAPI coverage for MCP and Studios — `ms-hub studio list` / `ms-hub list --repo-type studio`, `ms-hub studio variable` (plaintext env vars), `ms-hub studio hardware` / `base-images` / `sdk-versions` so valid `--hardware` / `--base-image` / `--sdk-version` values no longer have to be guessed, and `ms-hub mcp list --hosted` -- **Feature**: Studio `--visibility protected` (app public, code hidden), previously inexpressible; `RepoInfo` now carries the Studio runtime fields (`sdk_type`, `hardware`, `runtime`, …) instead of discarding them +- **Feature**: complete OpenAPI coverage for MCP and Studios — `studio list` / `list --repo-type studio`, `studio variable` (plaintext env vars), `studio hardware` / `base-images` / `sdk-versions` so valid option values no longer have to be guessed, `mcp list --hosted`; `--visibility protected` (app public, code hidden); `RepoInfo` carries the Studio runtime fields instead of discarding them - **Feature**: permission-tiered tokens — a read-only token can `login` (warning that writes need a higher tier), and a rejected write names the tier it requires -- **Fix**: the compat layer forwarded `token` / `endpoint` as business fields, so `update_studio_settings(..., token=...)` serialised your **API token into the `PATCH` body**, `get_studio_logs(..., token=...)` raised `TypeError`, and a per-call `token=` was silently ignored -- **Fix**: `cover_image` was renamed to `coverImage` on the way out and dropped by the server, so Studio cover images never applied -- **Fix**: HTTP 403 now separates insufficient permission (`PermissionDeniedError`) from an exhausted quota (new `QuotaExceededError`, never retried); 409 maps to `AlreadyExistsError`; the OpenAPI string error codes are recognised at last -- **Fix**: `ms-hub info --repo-type studio` works anonymously for public spaces; `ms-hub studio logs` prints its pagination footer again -- **Enhance**: MCP discovery sends `PUT` first (the only verb the spec defines) and remembers which verb a deployment serves; listing a Studio owner asks for every status, since the endpoint otherwise answers "list my spaces" with nothing +- **Fix**: the compat layer forwarded `token` / `endpoint` as business fields (your **API token was serialised into the `PATCH` body**, and per-call `token=` raised or was silently ignored); `cover_image` reached the server as `coverImage` and was dropped, so cover images never applied +- **Fix**: HTTP 403 now separates insufficient permission (`PermissionDeniedError`) from an exhausted quota (`QuotaExceededError`, never retried); 409 maps to `AlreadyExistsError`; the OpenAPI string error codes are recognised; `info --repo-type studio` works anonymously; `studio logs` prints its pagination footer again +- **Enhance**: MCP discovery sends `PUT` first and remembers which verb a deployment serves; listing a Studio owner asks for every status instead of answering with nothing - **Quality**: the OpenAPI spec is vendored under `tests/data/` and checked against an operation registry, so a newly published MCP/Studios endpoint fails the suite by name **v0.3.1** (2026-09-01) -- **Refactor**: upload environment variables renamed to carry their units and semantics (`UPLOAD_BLOB_CONNECT_TIMEOUT_SECONDS`, `UPLOAD_BLOB_MAX_ATTEMPTS`, `UPLOAD_BLOB_PROGRESS_THRESHOLD_BYTES`, …); the previous names keep working and emit a deprecation warning +- **Refactor**: upload environment variables renamed to carry their units and semantics (`UPLOAD_BLOB_CONNECT_TIMEOUT_SECONDS`, `UPLOAD_BLOB_MAX_ATTEMPTS`, …); the previous names keep working with a deprecation warning - **Fix**: `ms-hub logout` is wired up (the API supported it but the CLI never exposed it); download lock filenames hash the remote path, so files sharing a basename no longer contend for one lock -- **Fix**: `whoami` and `UserInfo` map the pre-production field names to their canonical ones; `get_model_files` and other legacy compat paths align with the current server responses +- **Fix**: `whoami` and legacy compat paths (`get_model_files`, …) map the pre-production field names to their canonical ones and align with the current server responses **v0.3.0** (2026-08-19) -- **Feature**: this package now installs all four console scripts — `modelscope`, `ms`, `modelscope-hub`, `ms-hub` — so exactly one distribution owns them and neither package can strand the other's CLI on upgrade; the umbrella SDK contributes its commands as plugins instead -- **Fix**: the server truncates `repo/files` at a fixed entry count with no marker and ignores pagination, silently hiding files in large repos; listings that land on the cap are now re-enumerated per directory +- **Feature**: this package now installs all four console scripts — `modelscope`, `ms`, `modelscope-hub`, `ms-hub` — so one distribution owns them and neither package can strand the other's CLI on upgrade; the umbrella SDK contributes its commands as plugins - **Feature**: `HubApi.get_current_username()`; agent visibility reads the API's new boolean `private` field while still accepting the legacy `visibility` string +- **Fix**: the server truncates `repo/files` at a fixed entry count with no marker and ignores pagination, silently hiding files in large repos; listings that land on the cap are now re-enumerated per directory
Older releases From 82b458cbc936dae0a8af857a3939c8a9abb0ab9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8F=AD=E6=89=AC?= Date: Tue, 1 Sep 2026 21:37:13 +0800 Subject: [PATCH 4/4] [Docs] Consolidate News entries by category --- README.md | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 69351fe..d91c85d 100644 --- a/README.md +++ b/README.md @@ -34,22 +34,18 @@ The official Python SDK & CLI for [ModelScope Hub](https://modelscope.cn) — do ## News **v0.4.0** (2026-09-01) -- **Feature**: complete OpenAPI coverage for MCP and Studios — `studio list` / `list --repo-type studio`, `studio variable` (plaintext env vars), `studio hardware` / `base-images` / `sdk-versions` so valid option values no longer have to be guessed, `mcp list --hosted`; `--visibility protected` (app public, code hidden); `RepoInfo` carries the Studio runtime fields instead of discarding them -- **Feature**: permission-tiered tokens — a read-only token can `login` (warning that writes need a higher tier), and a rejected write names the tier it requires -- **Fix**: the compat layer forwarded `token` / `endpoint` as business fields (your **API token was serialised into the `PATCH` body**, and per-call `token=` raised or was silently ignored); `cover_image` reached the server as `coverImage` and was dropped, so cover images never applied -- **Fix**: HTTP 403 now separates insufficient permission (`PermissionDeniedError`) from an exhausted quota (`QuotaExceededError`, never retried); 409 maps to `AlreadyExistsError`; the OpenAPI string error codes are recognised; `info --repo-type studio` works anonymously; `studio logs` prints its pagination footer again -- **Enhance**: MCP discovery sends `PUT` first and remembers which verb a deployment serves; listing a Studio owner asks for every status instead of answering with nothing -- **Quality**: the OpenAPI spec is vendored under `tests/data/` and checked against an operation registry, so a newly published MCP/Studios endpoint fails the suite by name +- **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 renamed to carry their units and semantics (`UPLOAD_BLOB_CONNECT_TIMEOUT_SECONDS`, `UPLOAD_BLOB_MAX_ATTEMPTS`, …); the previous names keep working with a deprecation warning -- **Fix**: `ms-hub logout` is wired up (the API supported it but the CLI never exposed it); download lock filenames hash the remote path, so files sharing a basename no longer contend for one lock -- **Fix**: `whoami` and legacy compat paths (`get_model_files`, …) map the pre-production field names to their canonical ones and align with the current server responses +- **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 now installs all four console scripts — `modelscope`, `ms`, `modelscope-hub`, `ms-hub` — so one distribution owns them and neither package can strand the other's CLI on upgrade; the umbrella SDK contributes its commands as plugins -- **Feature**: `HubApi.get_current_username()`; agent visibility reads the API's new boolean `private` field while still accepting the legacy `visibility` string -- **Fix**: the server truncates `repo/files` at a fixed entry count with no marker and ignores pagination, silently hiding files in large repos; listings that land on the cap are now re-enumerated per directory +- **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