diff --git a/README.md b/README.md index 7b0922f..c50fcd6 100644 --- a/README.md +++ b/README.md @@ -505,6 +505,11 @@ ms-hub delete my-org/my-model --repo-type model --yes | `--base-image` | no | Studio base Docker image | | `--cover-image` | no | Studio cover image URL | | `--hardware` | no | Studio hardware spec | +| `--oauth-enabled` / `--no-oauth-enabled` | no | Studio only: enable the OAuth app configuration (see [Studio OAuth app](#studio-oauth-app-configuration)) | +| `--oauth-scope` | no | Studio only, required when enabled: space-separated scopes — `openid`, `profile`, `manage-mcp-deployment`, `list-operational-mcp`, `read-repos`, `api-inference` | +| `--oauth-token-expire-time` | no | Studio only, required when enabled: `1`=15min, `2`=1h, `3`=8h, `4`=1d, `5`=1w, `6`=30d | +| `--oauth-callback-url` | no | Studio only, required when enabled: callback URL, repeat the flag for multiple entries | +| `--oauth-homepage-url` | no | Studio only: OAuth app homepage URL | @@ -542,13 +547,14 @@ is publicly visible. ```bash # Secrets — values are write-only -ms-hub secret add my-org/demo API_KEY sk-xxx +ms-hub secret add my-org/demo API_KEY sk-xxx --description "used by the app" ms-hub secret list my-org/demo ms-hub secret update my-org/demo API_KEY sk-new +ms-hub secret update my-org/demo API_KEY sk-new --description "" # clear the description 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 add my-org/demo MODEL_NAME Qwen2.5-7B --description "model id" 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 @@ -559,9 +565,9 @@ ms-hub studio variable delete my-org/demo MODEL_NAME --yes | Subcommand | Arguments | Description | |------------|-----------|-------------| -| `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 | +| `add` | `repo_id key value [--description D]` | Add a new entry (description max 512 characters) | +| `list` | `repo_id` | List entries (`secret` shows keys and descriptions; `variable` also shows values) | +| `update` | `repo_id key value [--description D]` | Update a value; `--description` omitted keeps the stored description, `--description ""` clears it | | `delete` | `repo_id key [--yes]` | Delete an entry | `ms-hub secret` accepts `--repo-type` (default: `studio`, currently the only supported type). @@ -603,7 +609,7 @@ ms-hub studio settings my-org/demo --visibility protected | `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` | +| `settings` | `studio_id [key=val...]` | `--display-name`, `--description`, `--license`, `--cover-image`, `--sdk-type`, `--sdk-version`, `--base-image`, `--hardware`, `--visibility`, `--private`/`--public`, OAuth app flags (see [Studio OAuth app](#studio-oauth-app-configuration)) | | `secret` | see above | — | | `variable` | see above | — | @@ -620,6 +626,46 @@ spaces includes stopped ones. Without `--owner` the server's own default applies +#### Studio OAuth app configuration + +A Studio can act as an OAuth application: once enabled, the space can obtain +ModelScope user authorization through the standard OAuth flow. Configure it at +creation time (`ms-hub create --repo-type studio`) or later with +`ms-hub studio settings`: + +```bash +# Enable at creation +ms-hub create my-org/oauth-demo --repo-type studio --sdk-type gradio \ + --oauth-enabled \ + --oauth-scope "openid profile read-repos" \ + --oauth-token-expire-time 4 \ + --oauth-callback-url https://example.org/oauth/callback \ + --oauth-homepage-url https://example.org + +# Enable / reconfigure later +ms-hub studio settings my-org/oauth-demo \ + --oauth-enabled \ + --oauth-scope "openid api-inference" \ + --oauth-token-expire-time 2 \ + --oauth-callback-url https://example.org/cb1 \ + --oauth-callback-url https://example.org/cb2 + +# Disable (other oauth_* flags are dropped together with it) +ms-hub studio settings my-org/oauth-demo --no-oauth-enabled +``` + +| Flag | Description | +|------|-------------| +| `--oauth-enabled` / `--no-oauth-enabled` | Enable or disable the OAuth app. On update, the whole `oauth_*` group is applied only when this flag is present; the CLI rejects the other `oauth_*` flags without it (the server would silently ignore them) | +| `--oauth-scope` | Space-separated scopes, required when enabling. Valid scopes: `openid` (ModelScope user identity), `profile` (public user profile), `manage-mcp-deployment` (manage MCP deployments), `list-operational-mcp` (list deployed MCP servers), `read-repos` (read personal repositories), `api-inference` (call API Inference) | +| `--oauth-token-expire-time` | Token lifetime enum, required when enabling: `1`=15 min, `2`=1 hour, `3`=8 hours, `4`=1 day, `5`=1 week, `6`=30 days | +| `--oauth-callback-url` | Callback URL, required when enabling; repeat the flag to register several. Each entry needs a scheme and a host | +| `--oauth-homepage-url` | OAuth app homepage URL, optional | + +`ms-hub info ` returns the live configuration through the +`oauth_enabled` / `oauth_scope` / `oauth_token_expire_time` / +`oauth_callback_urls` / `oauth_homepage_url` fields (omitted when unset). + ### `ms-hub mcp` Manage MCP (Model Context Protocol) servers. @@ -846,13 +892,13 @@ api = HubApi(token="...", endpoint="https://modelscope.ai") | | `stop_repo(repo_id, repo_type)` | Stop deployment | | | `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 secret keys (values are never returned) | -| | `update_secret(repo_id, key, value)` | Update a secret | +| **Secrets** | `add_secret(repo_id, key, value, description=None)` | Add a secret | +| | `list_secrets(repo_id)` | List secret keys and descriptions (values are never returned) | +| | `update_secret(repo_id, key, value, description=None)` | Update a secret; `description=None` keeps the stored one, `""` clears it | | | `delete_secret(repo_id, key)` | Delete a secret | -| **Variables** | `add_variable(repo_id, key, value)` | Add a plaintext variable *(value is public)* | +| **Variables** | `add_variable(repo_id, key, value, description=None)` | 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 | +| | `update_variable(repo_id, key, value, description=None)` | Update a variable; `description=None` keeps the stored one, `""` clears it | | | `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 | diff --git a/src/modelscope_hub/_openapi.py b/src/modelscope_hub/_openapi.py index 7217f09..ae207c0 100644 --- a/src/modelscope_hub/_openapi.py +++ b/src/modelscope_hub/_openapi.py @@ -1052,21 +1052,39 @@ def list_studio_secrets(self, owner: str, repo_name: str) -> JSON: 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.""" + def add_studio_secret( + self, owner: str, repo_name: str, key: str, value: str, description: str | None = None + ) -> JSON: + """``POST /studios/{owner}/{repo_name}/secrets`` — add a new secret. + + *description* (at most 512 characters) is optional and omitted from the + request body unless provided. + """ + body: dict[str, Any] = {"key": key, "value": value} + if description is not None: + body["description"] = description return self._request( "POST", f"/studios/{owner}/{repo_name}/secrets", - json_body={"key": key, "value": value}, + json_body=body, required_scope=TokenScope.WRITE, ) - def update_studio_secret(self, owner: str, repo_name: str, key: str, value: str) -> JSON: - """``PUT /studios/{owner}/{repo_name}/secrets`` — overwrite an existing secret.""" + def update_studio_secret( + self, owner: str, repo_name: str, key: str, value: str, description: str | None = None + ) -> JSON: + """``PUT /studios/{owner}/{repo_name}/secrets`` — overwrite an existing secret. + + *description* is tri-state: left as ``None`` the field is omitted and the + server keeps the stored description, while an empty string clears it. + """ + body: dict[str, Any] = {"key": key, "value": value} + if description is not None: + body["description"] = description return self._request( "PUT", f"/studios/{owner}/{repo_name}/secrets", - json_body={"key": key, "value": value}, + json_body=body, required_scope=TokenScope.WRITE, ) @@ -1094,25 +1112,41 @@ def list_studio_variables(self, owner: str, repo_name: str) -> JSON: required_scope=TokenScope.READ, ) - def add_studio_variable(self, owner: str, repo_name: str, key: str, value: str) -> JSON: + def add_studio_variable( + self, owner: str, repo_name: str, key: str, value: str, description: str | None = None + ) -> 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. + :meth:`add_studio_secret` for anything sensitive. *description* (at + most 512 characters) is optional and omitted unless provided. """ + body: dict[str, Any] = {"key": key, "value": value} + if description is not None: + body["description"] = description return self._request( "POST", f"/studios/{owner}/{repo_name}/variables", - json_body={"key": key, "value": value}, + json_body=body, 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.""" + def update_studio_variable( + self, owner: str, repo_name: str, key: str, value: str, description: str | None = None + ) -> JSON: + """``PUT /studios/{owner}/{repo_name}/variables`` — overwrite a plaintext variable. + + *description* is tri-state: left as ``None`` the field is omitted and + the server keeps the stored description, while an empty string clears + it. + """ + body: dict[str, Any] = {"key": key, "value": value} + if description is not None: + body["description"] = description return self._request( "PUT", f"/studios/{owner}/{repo_name}/variables", - json_body={"key": key, "value": value}, + json_body=body, required_scope=TokenScope.WRITE, ) diff --git a/src/modelscope_hub/api.py b/src/modelscope_hub/api.py index 99035b0..9794498 100644 --- a/src/modelscope_hub/api.py +++ b/src/modelscope_hub/api.py @@ -2125,9 +2125,13 @@ def add_secret( key: str, value: str, repo_type: RepoTypeLike = RepoType.STUDIO, + description: str | None = None, ) -> dict: """Add a new secret to a Studio. + *description* (optional, at most 512 characters) documents the secret + in the Studio settings UI. + Examples -------- >>> api.add_secret("alice/chat-demo", "OPENAI_API_KEY", "sk-...") @@ -2136,7 +2140,7 @@ def add_secret( if rt is not RepoType.STUDIO: raise NotSupportedError("Only studio secrets are supported.") owner, name = self._parse_repo_id(repo_id) - return self.openapi.add_studio_secret(owner, name, key, value) + return self.openapi.add_studio_secret(owner, name, key, value, description=description) def update_secret( self, @@ -2144,9 +2148,13 @@ def update_secret( key: str, value: str, repo_type: RepoTypeLike = RepoType.STUDIO, + description: str | None = None, ) -> dict: """Update an existing secret value. + *description* is tri-state: ``None`` (default) keeps the stored + description, an empty string clears it, any other value replaces it. + Examples -------- >>> api.update_secret("alice/chat-demo", "OPENAI_API_KEY", "sk-new-...") @@ -2155,7 +2163,7 @@ def update_secret( if rt is not RepoType.STUDIO: raise NotSupportedError("Only studio secrets are supported.") owner, name = self._parse_repo_id(repo_id) - return self.openapi.update_studio_secret(owner, name, key, value) + return self.openapi.update_studio_secret(owner, name, key, value, description=description) def delete_secret( self, @@ -2205,11 +2213,13 @@ def add_variable( key: str, value: str, repo_type: RepoTypeLike = RepoType.STUDIO, + description: str | None = None, ) -> dict: """Add a plaintext environment variable to a Studio. Both key and value are publicly visible; use :meth:`add_secret` for - anything sensitive. + anything sensitive. *description* (optional, at most 512 characters) + documents the variable in the Studio settings UI. Examples -------- @@ -2219,7 +2229,7 @@ def add_variable( 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) + return self.openapi.add_studio_variable(owner, name, key, value, description=description) def update_variable( self, @@ -2227,9 +2237,13 @@ def update_variable( key: str, value: str, repo_type: RepoTypeLike = RepoType.STUDIO, + description: str | None = None, ) -> dict: """Update an existing plaintext variable's value. + *description* is tri-state: ``None`` (default) keeps the stored + description, an empty string clears it, any other value replaces it. + Examples -------- >>> api.update_variable("alice/chat-demo", "MODEL_NAME", "Qwen2.5-14B") @@ -2238,7 +2252,7 @@ def update_variable( 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) + return self.openapi.update_studio_variable(owner, name, key, value, description=description) def delete_variable( self, diff --git a/src/modelscope_hub/cli/repo.py b/src/modelscope_hub/cli/repo.py index 7072896..8481a7d 100644 --- a/src/modelscope_hub/cli/repo.py +++ b/src/modelscope_hub/cli/repo.py @@ -24,6 +24,7 @@ success, ) from .compat import add_subcmd_token_endpoint +from .studio import _add_oauth_arguments def _format_visibility(value: object) -> str: @@ -115,12 +116,25 @@ def _add_arguments(p) -> None: help="Local zip for skill (max 5 MB, root must contain exactly one " "SKILL.md with YAML front-matter: name, version, description).", ) + _add_oauth_arguments(p) add_subcmd_token_endpoint(p) def execute(self) -> None: api = make_api(self.args) extra: dict[str, object] = {} - for key in ("sdk_type", "sdk_version", "base_image", "cover_image", "hardware", "category"): + for key in ( + "sdk_type", + "sdk_version", + "base_image", + "cover_image", + "hardware", + "category", + "oauth_enabled", + "oauth_scope", + "oauth_token_expire_time", + "oauth_callback_urls", + "oauth_homepage_url", + ): value = getattr(self.args, key, None) if value is not None: extra[key] = value diff --git a/src/modelscope_hub/cli/secret.py b/src/modelscope_hub/cli/secret.py index 5a09380..cfb9f3b 100644 --- a/src/modelscope_hub/cli/secret.py +++ b/src/modelscope_hub/cli/secret.py @@ -72,13 +72,20 @@ def register(subparsers: SubParsers) -> None: p.add_argument("repo_id") p.add_argument("key") p.add_argument("value") + p.add_argument("--description", default=None, help="Optional description (max 512 characters).") _add_studio_repo_type(p) add_subcmd_token_endpoint(p) p.set_defaults(_command=SecretCommand, _secret_leaf=_SecretAdd) def execute(self) -> None: api = make_api(self.args) - api.add_secret(self.args.repo_id, self.args.key, self.args.value, self.args.repo_type) + api.add_secret( + self.args.repo_id, + self.args.key, + self.args.value, + self.args.repo_type, + description=self.args.description, + ) success(f"Added secret {self.args.key!r} to {self.args.repo_id}.") @@ -89,13 +96,24 @@ def register(subparsers: SubParsers) -> None: p.add_argument("repo_id") p.add_argument("key") p.add_argument("value") + p.add_argument( + "--description", + default=None, + help="New description; omit to keep the stored one, pass '' to clear it.", + ) _add_studio_repo_type(p) add_subcmd_token_endpoint(p) p.set_defaults(_command=SecretCommand, _secret_leaf=_SecretUpdate) def execute(self) -> None: api = make_api(self.args) - api.update_secret(self.args.repo_id, self.args.key, self.args.value, self.args.repo_type) + api.update_secret( + self.args.repo_id, + self.args.key, + self.args.value, + self.args.repo_type, + description=self.args.description, + ) success(f"Updated secret {self.args.key!r} on {self.args.repo_id}.") diff --git a/src/modelscope_hub/cli/studio.py b/src/modelscope_hub/cli/studio.py index feb30c9..92ef82d 100644 --- a/src/modelscope_hub/cli/studio.py +++ b/src/modelscope_hub/cli/studio.py @@ -31,6 +31,77 @@ "hardware", "visibility", "private", + "oauth_enabled", + "oauth_scope", + "oauth_token_expire_time", + "oauth_callback_urls", + "oauth_homepage_url", +) + +_OAUTH_TOKEN_EXPIRE_CHOICES = (1, 2, 3, 4, 5, 6) +_OAUTH_TOKEN_EXPIRE_HELP = "OAuth app token lifetime enum: 1=15min, 2=1hour, 3=8hours, 4=1day, 5=1week, 6=30days" + + +def _add_oauth_arguments(p) -> None: + """Studio OAuth app configuration flags (shared by create and settings). + + The server applies the whole oauth_* group only when ``oauth_enabled`` is + explicitly present in the payload (tri-state pointer on update), so the + flags default to ``None`` rather than ``False``. + """ + enabled = p.add_mutually_exclusive_group() + enabled.add_argument( + "--oauth-enabled", + dest="oauth_enabled", + action="store_true", + default=None, + help="Enable the Studio's OAuth app configuration.", + ) + enabled.add_argument( + "--no-oauth-enabled", + dest="oauth_enabled", + action="store_false", + help="Disable the Studio's OAuth app configuration.", + ) + p.add_argument( + "--oauth-scope", + dest="oauth_scope", + default=None, + help="Space-separated OAuth scopes (required when enabled). Valid scopes: " + "openid, profile, manage-mcp-deployment, list-operational-mcp, read-repos, " + "api-inference.", + ) + p.add_argument( + "--oauth-token-expire-time", + dest="oauth_token_expire_time", + type=int, + choices=_OAUTH_TOKEN_EXPIRE_CHOICES, + default=None, + help=_OAUTH_TOKEN_EXPIRE_HELP + ".", + ) + p.add_argument( + "--oauth-callback-url", + dest="oauth_callback_urls", + action="append", + default=None, + metavar="URL", + help="OAuth callback URL; repeat the flag for multiple entries (required when enabled).", + ) + p.add_argument( + "--oauth-homepage-url", + dest="oauth_homepage_url", + default=None, + help="OAuth app homepage URL.", + ) + + +_OAUTH_EXTRA_FIELDS = frozenset( + { + "oauth_scope", + "oauth_token_expire_time", + "oauth_callback_urls", + "oauth_homepage_url", + } ) @@ -243,6 +314,7 @@ def register(subparsers: SubParsers) -> None: ) visibility.add_argument("--private", dest="private", action="store_const", const=True, default=None) visibility.add_argument("--public", dest="private", action="store_const", const=False) + _add_oauth_arguments(p) p.set_defaults(_command=StudioCommand, _studio_leaf=_StudioSettings) def execute(self) -> None: @@ -251,7 +323,13 @@ def execute(self) -> None: raise ValueError( "No setting specified. Provide key=value or one of: --display-name, --description, --license, " "--cover-image, --sdk-type, --sdk-version, --base-image, --hardware, --visibility, " - "--private/--public." + "--private/--public, --oauth-enabled/--no-oauth-enabled (with --oauth-scope, " + "--oauth-token-expire-time, --oauth-callback-url, --oauth-homepage-url)." + ) + if "oauth_enabled" not in settings and _OAUTH_EXTRA_FIELDS.intersection(settings): + raise ValueError( + "The oauth_* settings only take effect together with an explicit " + "--oauth-enabled or --no-oauth-enabled flag." ) api = make_api(self.args) data = api.update_repo_settings(self.args.studio_id, RepoType.STUDIO, **settings) @@ -275,12 +353,18 @@ def register(subparsers: SubParsers) -> None: _add_studio_id(add_p) add_p.add_argument("key") add_p.add_argument("value") + add_p.add_argument("--description", default=None, help="Optional description (max 512 characters).") add_p.set_defaults(_command=StudioCommand, _studio_leaf=_StudioSecret) update_p = actions.add_parser("update", help="Update an existing secret.") _add_studio_id(update_p) update_p.add_argument("key") update_p.add_argument("value") + update_p.add_argument( + "--description", + default=None, + help="New description; omit to keep the stored one, pass '' to clear it.", + ) update_p.set_defaults(_command=StudioCommand, _studio_leaf=_StudioSecret) delete_p = actions.add_parser("delete", help="Delete a secret.") @@ -296,15 +380,32 @@ def execute(self) -> None: if not secrets: info("(no secrets)") return - for item in secrets: - info(str(item.get("key") if isinstance(item, dict) else item)) + rows = [ + ((item.get("key") or "-"), (item.get("description") or "-")) + if isinstance(item, dict) + else (str(item), "-") + for item in secrets + ] + info(render_table(rows, headers=["key", "description"])) return if action == "add": - api.add_secret(self.args.studio_id, self.args.key, self.args.value, RepoType.STUDIO) + api.add_secret( + self.args.studio_id, + self.args.key, + self.args.value, + RepoType.STUDIO, + description=self.args.description, + ) success(f"Secret {self.args.key!r} added.") return if action == "update": - api.update_secret(self.args.studio_id, self.args.key, self.args.value, RepoType.STUDIO) + api.update_secret( + self.args.studio_id, + self.args.key, + self.args.value, + RepoType.STUDIO, + description=self.args.description, + ) success(f"Secret {self.args.key!r} updated.") return if action == "delete": @@ -338,12 +439,18 @@ def register(subparsers: SubParsers) -> None: _add_studio_id(add_p) add_p.add_argument("key") add_p.add_argument("value") + add_p.add_argument("--description", default=None, help="Optional description (max 512 characters).") 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.add_argument( + "--description", + default=None, + help="New description; omit to keep the stored one, pass '' to clear it.", + ) update_p.set_defaults(_command=StudioCommand, _studio_leaf=_StudioVariable) delete_p = actions.add_parser("delete", help="Delete a plaintext variable.") @@ -366,17 +473,31 @@ def execute(self) -> None: info("(no variables)") return rows = [ - (item.get("key") or "-", item.get("value") or "") if isinstance(item, dict) else (str(item), "") + (item.get("key") or "-", item.get("value") or "", item.get("description") or "-") + if isinstance(item, dict) + else (str(item), "", "-") for item in variables ] - info(render_table(rows, headers=["key", "value"])) + info(render_table(rows, headers=["key", "value", "description"])) return if action == "add": - api.add_variable(self.args.studio_id, self.args.key, self.args.value, RepoType.STUDIO) + api.add_variable( + self.args.studio_id, + self.args.key, + self.args.value, + RepoType.STUDIO, + description=self.args.description, + ) 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) + api.update_variable( + self.args.studio_id, + self.args.key, + self.args.value, + RepoType.STUDIO, + description=self.args.description, + ) success(f"Variable {self.args.key!r} updated.") return if action == "delete": diff --git a/src/modelscope_hub/types.py b/src/modelscope_hub/types.py index c6b90ac..2570254 100644 --- a/src/modelscope_hub/types.py +++ b/src/modelscope_hub/types.py @@ -417,6 +417,13 @@ class RepoInfo(_FromDictMixin): hardware: str | None = None mcp_support: bool | None = None runtime: dict[str, Any] | None = None + # Studio OAuth app configuration (only meaningful when oauth_enabled is + # True; the remaining fields are omitted from responses when unset). + oauth_enabled: bool | None = None + oauth_scope: str | None = None + oauth_token_expire_time: int | None = None + oauth_callback_urls: list[str] | None = None + oauth_homepage_url: str | None = None def __post_init__(self) -> None: if isinstance(self.repo_type, str): @@ -651,6 +658,13 @@ class CreateStudioPayload(TypedDict, total=False): sdk_version: str base_image: str hardware: str + # OAuth app configuration (applied together; the server validates the + # scope / token expiry / callback URLs whenever oauth_enabled is True). + oauth_enabled: bool + oauth_scope: str + oauth_token_expire_time: int + oauth_callback_urls: list[str] + oauth_homepage_url: str class UpdateStudioSettingsPayload(TypedDict, total=False): @@ -666,6 +680,14 @@ class UpdateStudioSettingsPayload(TypedDict, total=False): sdk_version: str base_image: str hardware: str + # The whole oauth_* group is applied only when ``oauth_enabled`` is + # present in the payload (tri-state pointer); without it the other + # oauth_* fields are validated but ignored by the server. + oauth_enabled: bool + oauth_scope: str + oauth_token_expire_time: int + oauth_callback_urls: list[str] + oauth_homepage_url: str class DeployMcpServerPayload(TypedDict, total=False): diff --git a/tests/cli/test_openapi_studio.py b/tests/cli/test_openapi_studio.py index 5a04e46..0840205 100644 --- a/tests/cli/test_openapi_studio.py +++ b/tests/cli/test_openapi_studio.py @@ -209,6 +209,53 @@ def test_variables_mirror_the_secrets_routes(self, client): assert variable["url"].replace("/variables", "") == secret["url"].replace("/secrets", "") +# --------------------------------------------------------------------------- +# Secret / variable descriptions +# --------------------------------------------------------------------------- +class TestSecretVariableDescription: + """The server treats ``description`` on update as a pointer: absent keeps + the stored value, an empty string clears it. Dropping the field, or + collapsing ``None`` and ``""`` into one case, silently rewrites user data. + """ + + @pytest.mark.parametrize( + ("method_name", "verb"), + [ + ("add_studio_secret", "POST"), + ("update_studio_secret", "PUT"), + ("add_studio_variable", "POST"), + ("update_studio_variable", "PUT"), + ], + ) + def test_description_is_sent_when_given(self, client, method_name, verb): + with patch.object(client._session, "request", return_value=_response()) as mock_req: + getattr(client, method_name)("alice", "demo", "K", "V", description="used by the app") + assert mock_req.call_args.kwargs["json"] == { + "key": "K", + "value": "V", + "description": "used by the app", + } + assert mock_req.call_args.kwargs["method"] == verb + + @pytest.mark.parametrize( + "method_name", + ["add_studio_secret", "update_studio_secret", "add_studio_variable", "update_studio_variable"], + ) + def test_description_is_omitted_when_none(self, client, method_name): + with patch.object(client._session, "request", return_value=_response()) as mock_req: + getattr(client, method_name)("alice", "demo", "K", "V") + assert mock_req.call_args.kwargs["json"] == {"key": "K", "value": "V"} + + @pytest.mark.parametrize( + "method_name", + ["update_studio_secret", "update_studio_variable"], + ) + def test_empty_string_description_is_sent_and_clears(self, client, method_name): + with patch.object(client._session, "request", return_value=_response()) as mock_req: + getattr(client, method_name)("alice", "demo", "K", "V", description="") + assert mock_req.call_args.kwargs["json"] == {"key": "K", "value": "V", "description": ""} + + # --------------------------------------------------------------------------- # Studio logs # --------------------------------------------------------------------------- diff --git a/tests/cli/test_repo.py b/tests/cli/test_repo.py index e4400a1..0cd250f 100644 --- a/tests/cli/test_repo.py +++ b/tests/cli/test_repo.py @@ -361,6 +361,41 @@ def test_create_studio_with_extras(self, parser, mock_api, capsys): assert call_kwargs.kwargs["sdk_version"] == "4.0" assert call_kwargs.kwargs["hardware"] == "gpu.a10" + def test_create_studio_with_oauth(self, parser, mock_api, capsys): + args = parser.parse_args( + [ + "create", + "owner/oauth-demo", + "--repo-type", + "studio", + "--oauth-enabled", + "--oauth-scope", + "openid profile", + "--oauth-token-expire-time", + "4", + "--oauth-callback-url", + "https://a.example/cb", + "--oauth-homepage-url", + "https://example.org", + ] + ) + with patch("modelscope_hub.cli.repo.make_api", return_value=mock_api): + CreateCommand(args).execute() + call_kwargs = mock_api.create_repo.call_args + assert call_kwargs.kwargs["oauth_enabled"] is True + assert call_kwargs.kwargs["oauth_scope"] == "openid profile" + assert call_kwargs.kwargs["oauth_token_expire_time"] == 4 + assert call_kwargs.kwargs["oauth_callback_urls"] == ["https://a.example/cb"] + assert call_kwargs.kwargs["oauth_homepage_url"] == "https://example.org" + + def test_create_studio_omits_unset_oauth_fields(self, parser, mock_api, capsys): + args = parser.parse_args(["create", "owner/demo", "--repo-type", "studio"]) + with patch("modelscope_hub.cli.repo.make_api", return_value=mock_api): + CreateCommand(args).execute() + call_kwargs = mock_api.create_repo.call_args + assert "oauth_enabled" not in call_kwargs.kwargs + assert "oauth_scope" not in call_kwargs.kwargs + def test_exist_ok_swallows_exist_error(self, parser, mock_api, capsys): mock_api.create_repo.side_effect = Exception("Repository already exists") args = parser.parse_args( diff --git a/tests/cli/test_secret.py b/tests/cli/test_secret.py index 2f5b6cb..2d77b3a 100644 --- a/tests/cli/test_secret.py +++ b/tests/cli/test_secret.py @@ -128,11 +128,19 @@ def test_add_secret(self, parser, mock_api, capsys): args = parser.parse_args(["secret", "add", "org/demo", "API_KEY", "sk-xxx"]) with patch("modelscope_hub.cli.secret.make_api", return_value=mock_api): _SecretAdd(args).execute() - mock_api.add_secret.assert_called_once_with("org/demo", "API_KEY", "sk-xxx", "studio") + mock_api.add_secret.assert_called_once_with("org/demo", "API_KEY", "sk-xxx", "studio", description=None) out = capsys.readouterr().out assert "Added" in out assert "API_KEY" in out + def test_add_secret_with_description(self, parser, mock_api, capsys): + args = parser.parse_args(["secret", "add", "org/demo", "API_KEY", "sk-xxx", "--description", "used by app"]) + with patch("modelscope_hub.cli.secret.make_api", return_value=mock_api): + _SecretAdd(args).execute() + mock_api.add_secret.assert_called_once_with( + "org/demo", "API_KEY", "sk-xxx", "studio", description="used by app" + ) + @pytest.mark.mock_only class TestSecretListExecute: @@ -159,10 +167,16 @@ def test_update_secret(self, parser, mock_api, capsys): args = parser.parse_args(["secret", "update", "org/demo", "KEY", "new_val"]) with patch("modelscope_hub.cli.secret.make_api", return_value=mock_api): _SecretUpdate(args).execute() - mock_api.update_secret.assert_called_once_with("org/demo", "KEY", "new_val", "studio") + mock_api.update_secret.assert_called_once_with("org/demo", "KEY", "new_val", "studio", description=None) out = capsys.readouterr().out assert "Updated" in out + def test_update_secret_with_empty_description_clears(self, parser, mock_api, capsys): + args = parser.parse_args(["secret", "update", "org/demo", "KEY", "new_val", "--description", ""]) + with patch("modelscope_hub.cli.secret.make_api", return_value=mock_api): + _SecretUpdate(args).execute() + mock_api.update_secret.assert_called_once_with("org/demo", "KEY", "new_val", "studio", description="") + @pytest.mark.mock_only class TestSecretDeleteExecute: diff --git a/tests/cli/test_studio.py b/tests/cli/test_studio.py index 62fea32..56b4ca9 100644 --- a/tests/cli/test_studio.py +++ b/tests/cli/test_studio.py @@ -271,6 +271,57 @@ def test_settings_requires_at_least_one_setting(self, mock_api): assert "No setting specified" in err mock_api.update_repo_settings.assert_not_called() + def test_settings_oauth_flags(self, mock_api): + with patch("modelscope_hub.cli.studio.make_api", return_value=mock_api): + code, out, err = run_cli( + [ + "studio", + "settings", + "org/demo", + "--oauth-enabled", + "--oauth-scope", + "openid profile", + "--oauth-token-expire-time", + "4", + "--oauth-callback-url", + "https://a.example/cb", + "--oauth-callback-url", + "https://b.example/cb", + "--oauth-homepage-url", + "https://example.org", + ] + ) + assert code == 0, err + assert "Updated settings" in out + mock_api.update_repo_settings.assert_called_once_with( + "org/demo", + RepoType.STUDIO, + oauth_enabled=True, + oauth_scope="openid profile", + oauth_token_expire_time=4, + oauth_callback_urls=["https://a.example/cb", "https://b.example/cb"], + oauth_homepage_url="https://example.org", + ) + + def test_settings_disable_oauth(self, mock_api): + with patch("modelscope_hub.cli.studio.make_api", return_value=mock_api): + code, out, err = run_cli(["studio", "settings", "org/demo", "--no-oauth-enabled"]) + assert code == 0, err + mock_api.update_repo_settings.assert_called_once_with("org/demo", RepoType.STUDIO, oauth_enabled=False) + + def test_settings_oauth_extras_require_enabled_flag(self, mock_api): + # The server applies the oauth_* group only when oauth_enabled is + # explicitly present; a lone --oauth-scope would be silently dropped. + with patch("modelscope_hub.cli.studio.make_api", return_value=mock_api): + code, out, err = run_cli(["studio", "settings", "org/demo", "--oauth-scope", "openid"]) + assert code == 2 + assert "--oauth-enabled" in err + mock_api.update_repo_settings.assert_not_called() + + def test_settings_oauth_enabled_flags_are_mutually_exclusive(self, parser): + with pytest.raises(SystemExit): + parser.parse_args(["studio", "settings", "org/demo", "--oauth-enabled", "--no-oauth-enabled"]) + def test_secret_list(self, mock_api): mock_api.list_secrets.return_value = [{"key": "API_KEY"}] with patch("modelscope_hub.cli.studio.make_api", return_value=mock_api): @@ -284,14 +335,38 @@ def test_secret_add(self, mock_api): code, out, err = run_cli(["studio", "secret", "add", "org/demo", "API_KEY", "value"]) assert code == 0 assert "added" in out.lower() - mock_api.add_secret.assert_called_once_with("org/demo", "API_KEY", "value", RepoType.STUDIO) + mock_api.add_secret.assert_called_once_with("org/demo", "API_KEY", "value", RepoType.STUDIO, description=None) + + def test_secret_add_with_description(self, mock_api): + with patch("modelscope_hub.cli.studio.make_api", return_value=mock_api): + code, out, err = run_cli( + ["studio", "secret", "add", "org/demo", "API_KEY", "value", "--description", "used by app"] + ) + assert code == 0 + mock_api.add_secret.assert_called_once_with( + "org/demo", "API_KEY", "value", RepoType.STUDIO, description="used by app" + ) def test_secret_update(self, mock_api): with patch("modelscope_hub.cli.studio.make_api", return_value=mock_api): code, out, err = run_cli(["studio", "secret", "update", "org/demo", "API_KEY", "new"]) assert code == 0 assert "updated" in out.lower() - mock_api.update_secret.assert_called_once_with("org/demo", "API_KEY", "new", RepoType.STUDIO) + mock_api.update_secret.assert_called_once_with("org/demo", "API_KEY", "new", RepoType.STUDIO, description=None) + + def test_secret_update_with_empty_description_clears(self, mock_api): + with patch("modelscope_hub.cli.studio.make_api", return_value=mock_api): + code, out, err = run_cli(["studio", "secret", "update", "org/demo", "API_KEY", "new", "--description", ""]) + assert code == 0 + mock_api.update_secret.assert_called_once_with("org/demo", "API_KEY", "new", RepoType.STUDIO, description="") + + def test_secret_list_shows_descriptions(self, mock_api): + mock_api.list_secrets.return_value = [{"key": "API_KEY", "description": "used by app"}] + with patch("modelscope_hub.cli.studio.make_api", return_value=mock_api): + code, out, err = run_cli(["studio", "secret", "list", "org/demo"]) + assert code == 0 + assert "API_KEY" in out + assert "used by app" in out def test_secret_delete(self, mock_api): with patch("modelscope_hub.cli.studio.make_api", return_value=mock_api): @@ -390,14 +465,41 @@ def test_variable_add(self, 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) + mock_api.add_variable.assert_called_once_with("org/demo", "MODEL", "Qwen", RepoType.STUDIO, description=None) + + def test_variable_add_with_description(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", "--description", "model id"] + ) + assert code == 0 + mock_api.add_variable.assert_called_once_with( + "org/demo", "MODEL", "Qwen", RepoType.STUDIO, description="model id" + ) 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) + mock_api.update_variable.assert_called_once_with( + "org/demo", "MODEL", "Qwen3", RepoType.STUDIO, description=None + ) + + def test_variable_update_with_empty_description_clears(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", "--description", ""] + ) + assert code == 0 + mock_api.update_variable.assert_called_once_with("org/demo", "MODEL", "Qwen3", RepoType.STUDIO, description="") + + def test_variable_list_shows_descriptions(self, mock_api): + mock_api.list_variables.return_value = [{"key": "MODEL", "value": "Qwen2.5-7B", "description": "model id"}] + 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 id" in out def test_variable_delete_with_yes(self, mock_api): with patch("modelscope_hub.cli.studio.make_api", return_value=mock_api): diff --git a/tests/data/openapi.json b/tests/data/openapi.json index aaed4a3..4745853 100644 --- a/tests/data/openapi.json +++ b/tests/data/openapi.json @@ -4467,7 +4467,45 @@ } ], "default": "platform/2v-cpu-16g-mem", - "description": "硬件配置。未传时使用默认免费硬件配置" + "description": "硬件配置。免费资源使用 platform/xgpu/amd 等前缀,付费资源使用 paid/。缺省或解析出老免费档时,服务端会归一到当前默认免费档" + }, + "oauth_enabled": { + "type": "boolean", + "description": "是否为创空间启用 OAuth", + "default": false + }, + "oauth_scope": { + "type": "string", + "description": "OAuth 授权范围,空格分隔多个 scope;oauth_enabled=true 时必填。可选值:openid(魔搭用户身份)、profile(用户公开信息)、manage-mcp-deployment(管理MCP部署)、list-operational-mcp(获取部署MCP列表)、read-repos(读取个人仓库)、api-inference(调用 API Inference)", + "examples": [ + "openid profile" + ] + }, + "oauth_token_expire_time": { + "type": "integer", + "description": "Token 过期时间枚举,oauth_enabled=true 时必填:1=15分钟, 2=1小时, 3=8小时, 4=1天, 5=1周, 6=30天", + "enum": [ + 1, + 2, + 3, + 4, + 5, + 6 + ] + }, + "oauth_callback_urls": { + "type": "array", + "items": { + "type": "string" + }, + "description": "OAuth 回调 URL 列表,oauth_enabled=true 时必填;每项需含 scheme 和 host", + "examples": [ + "https://example.org/oauth/callback" + ] + }, + "oauth_homepage_url": { + "type": "string", + "description": "OAuth 应用主页 URL" } }, "required": [ @@ -4688,6 +4726,29 @@ }, "runtime": { "$ref": "#/components/schemas/StudioRuntime" + }, + "oauth_enabled": { + "type": "boolean", + "description": "是否启用了创空间 OAuth" + }, + "oauth_scope": { + "type": "string", + "description": "OAuth 授权范围,空格分隔多个 scope(未启用时不返回)" + }, + "oauth_token_expire_time": { + "type": "integer", + "description": "Token 过期时间枚举:1=15分钟, 2=1小时, 3=8小时, 4=1天, 5=1周, 6=30天(未启用时不返回)" + }, + "oauth_callback_urls": { + "type": "array", + "items": { + "type": "string" + }, + "description": "OAuth 回调 URL 列表(未启用时不返回)" + }, + "oauth_homepage_url": { + "type": "string", + "description": "OAuth 应用主页 URL(未设置时不返回)" } } }, @@ -4746,6 +4807,37 @@ } ], "description": "硬件配置,修改后需重新部署才能生效" + }, + "oauth_enabled": { + "type": "boolean", + "description": "是否为创空间启用 OAuth。整组 oauth_* 字段仅在本字段显式传入时生效;不传时其他 oauth_* 字段被忽略" + }, + "oauth_scope": { + "type": "string", + "description": "OAuth 授权范围,空格分隔多个 scope。可选值:openid(魔搭用户身份)、profile(用户公开信息)、manage-mcp-deployment(管理MCP部署)、list-operational-mcp(获取部署MCP列表)、read-repos(读取个人仓库)、api-inference(调用 API Inference)" + }, + "oauth_token_expire_time": { + "type": "integer", + "description": "Token 过期时间枚举:1=15分钟, 2=1小时, 3=8小时, 4=1天, 5=1周, 6=30天", + "enum": [ + 1, + 2, + 3, + 4, + 5, + 6 + ] + }, + "oauth_callback_urls": { + "type": "array", + "items": { + "type": "string" + }, + "description": "OAuth 回调 URL 列表;每项需含 scheme 和 host" + }, + "oauth_homepage_url": { + "type": "string", + "description": "OAuth 应用主页 URL" } } }, @@ -4768,6 +4860,11 @@ "examples": [ "sk-xxxxxxxxxxxx" ] + }, + "description": { + "type": "string", + "description": "描述,非必填", + "maxLength": 512 } }, "required": [ @@ -4794,6 +4891,11 @@ "examples": [ "sk-xxxxxxxxxxxx" ] + }, + "description": { + "type": "string", + "description": "描述,非必填;不传保持原值,传空字符串表示清空", + "maxLength": 512 } }, "required": [ @@ -4808,6 +4910,10 @@ "key": { "type": "string", "description": "密文变量名称" + }, + "description": { + "type": "string", + "description": "描述" } }, "required": [ @@ -5173,6 +5279,11 @@ "examples": [ "/tmp/gradio" ] + }, + "description": { + "type": "string", + "description": "描述,非必填", + "maxLength": 512 } }, "required": [ @@ -5199,6 +5310,11 @@ "examples": [ "/new/path" ] + }, + "description": { + "type": "string", + "description": "描述,非必填;不传保持原值,传空字符串表示清空", + "maxLength": 512 } }, "required": [ @@ -5230,6 +5346,10 @@ "value": { "type": "string", "description": "明文变量值" + }, + "description": { + "type": "string", + "description": "描述" } }, "required": [