Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 57 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

</details>

Expand Down Expand Up @@ -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
Expand All @@ -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).
Expand Down Expand Up @@ -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 | — |

Expand All @@ -620,6 +626,46 @@ spaces includes stopped ones. Without `--owner` the server's own default applies

</details>

#### 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 <studio_id>` 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.
Expand Down Expand Up @@ -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 |
Expand Down
58 changes: 46 additions & 12 deletions src/modelscope_hub/_openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down Expand Up @@ -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,
)

Expand Down
24 changes: 19 additions & 5 deletions src/modelscope_hub/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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-...")
Expand All @@ -2136,17 +2140,21 @@ 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,
repo_id: str,
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-...")
Expand All @@ -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,
Expand Down Expand Up @@ -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
--------
Expand All @@ -2219,17 +2229,21 @@ 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,
repo_id: str,
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")
Expand All @@ -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,
Expand Down
16 changes: 15 additions & 1 deletion src/modelscope_hub/cli/repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
success,
)
from .compat import add_subcmd_token_endpoint
from .studio import _add_oauth_arguments


def _format_visibility(value: object) -> str:
Expand Down Expand Up @@ -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
Expand Down
22 changes: 20 additions & 2 deletions src/modelscope_hub/cli/secret.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}.")


Expand All @@ -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}.")


Expand Down
Loading