From 56bfeebbb0376dfbb144f6c7fe1ddacb84e5a573 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=BF=AF=E5=85=89?= Date: Mon, 27 Apr 2026 16:40:10 +0800 Subject: [PATCH 1/2] feat(ai): add Prompt variables and Skill download support, align with Java SDK - Add PromptVariable model with defaultValue support - Enhance Prompt.render() to merge default values from variables - Add Skill download module (model, util, HTTP endpoint) - Add HTTP variables parsing in AiHttpClientProxy - Add Client-Version/User-Agent headers to HTTP requests - Change RpcClient.start() to not throw on gRPC connect failure - Normalize context_path handling with build_context_prefix() - Unify exception types to NacosException in skill download - Bump version to 3.2.0 - Update README/README_CN with Prompt and Skill documentation - Add 32 unit tests for Prompt variables and Skill utilities --- README.md | 100 +++++++ README_CN.md | 100 +++++++ requirements.txt | 3 +- setup.py | 2 +- test/client_v2_test.py | 40 ++- test/test_skill_and_prompt.py | 292 +++++++++++++++++++++ v2/nacos/__init__.py | 2 +- v2/nacos/ai/model/ai_constant.py | 5 +- v2/nacos/ai/model/ai_param.py | 14 +- v2/nacos/ai/model/prompt/prompt.py | 34 ++- v2/nacos/ai/model/skill/__init__.py | 0 v2/nacos/ai/model/skill/skill.py | 48 ++++ v2/nacos/ai/nacos_ai_service.py | 19 +- v2/nacos/ai/remote/ai_http_client_proxy.py | 105 +++++++- v2/nacos/ai/util/skill_util.py | 105 ++++++++ v2/nacos/common/client_config.py | 31 ++- v2/nacos/common/client_config_builder.py | 4 + v2/nacos/common/constants.py | 2 +- v2/nacos/transport/auth_client.py | 7 +- v2/nacos/transport/rpc_client.py | 6 +- 20 files changed, 884 insertions(+), 35 deletions(-) create mode 100644 test/test_skill_and_prompt.py create mode 100644 v2/nacos/ai/model/skill/__init__.py create mode 100644 v2/nacos/ai/model/skill/skill.py create mode 100644 v2/nacos/ai/util/skill_util.py diff --git a/README.md b/README.md index 0761c7d..888665c 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Supported Nacos version over 3.x from v2.nacos import NacosNamingService, NacosConfigService, NacosAIService, ClientConfigBuilder, GRPCConfig, \ Instance, SubscribeServiceParam, RegisterInstanceParam, DeregisterInstanceParam, \ BatchRegisterInstanceParam, GetServiceParam, ListServiceParam, ListInstanceParam, ConfigParam +from v2.nacos.ai.model.ai_param import GetPromptParam, SubscribePromptParam, DownloadSkillParam client_config = (ClientConfigBuilder() .access_key(os.getenv('NACOS_ACCESS_KEY')) @@ -549,6 +550,105 @@ await ai_client.unsubscribe_agent_card( ) ``` +### Prompt Management + +Nacos provides prompt template management capabilities, including retrieval, subscription, and rendering with variable substitution. + +#### Get Prompt + +```python +from v2.nacos.ai.model.ai_param import GetPromptParam + +prompt = await ai_client.get_prompt( + GetPromptParam(prompt_key='my-prompt', version='1.0.0') +) +print(prompt.template) +``` + +* `param` *GetPromptParam* Parameter for retrieving prompt information. + * `prompt_key` - Key of the prompt to query (required). + * `version` - Version of the prompt (optional). + * `label` - Label of the prompt (optional). +* `return` Prompt if success or an exception will be raised. + +#### Render Prompt with Variables + +The `Prompt` object supports template rendering with `{{variableName}}` placeholders. Variables defined in the prompt may include default values via `PromptVariable.defaultValue`. When rendering, default values are applied first, then overridden by user-provided values. + +```python +# Render the prompt template with variable substitution +result = prompt.render({"name": "Alice", "place": "Nacos"}) +print(result) # e.g. "Hello Alice, welcome to Nacos!" + +# Variables with defaultValue will be used automatically if not overridden +# For example, if the prompt has a variable: PromptVariable(name="lang", defaultValue="en") +# Calling render without providing "lang" will use "en" as the value +result = prompt.render({"name": "Alice"}) +``` + +* `param` *variables* - A dict of variable name to value mappings (optional). Overrides default values defined in `PromptVariable.defaultValue`. +* `return` Rendered string with all `{{variableName}}` placeholders replaced. + +#### Subscribe Prompt + +```python +from v2.nacos.ai.model.ai_param import SubscribePromptParam + +async def prompt_listener(prompt_key, prompt): + print(f"Prompt changed: {prompt_key}, version: {prompt.version}") + +prompt = await ai_client.subscribe_prompt( + SubscribePromptParam( + prompt_key='my-prompt', + version='1.0.0', + subscribe_callback=prompt_listener + ) +) +``` + +* `param` *SubscribePromptParam* Parameter for subscribing to prompt changes. + * `prompt_key` - Key of the prompt to subscribe to (required). + * `version` - Version of the prompt (optional). + * `label` - Label of the prompt (optional). + * `subscribe_callback` - Callback function to handle prompt changes (required). +* `return` Current Prompt if success or an exception will be raised. + +#### Unsubscribe Prompt + +```python +await ai_client.unsubscribe_prompt( + SubscribePromptParam( + prompt_key='my-prompt', + version='1.0.0', + subscribe_callback=prompt_listener + ) +) +``` + +### Skill Download + +Nacos supports downloading skill packages as ZIP archives. + +#### Download Skill ZIP + +```python +from v2.nacos.ai.model.ai_param import DownloadSkillParam + +zip_bytes = await ai_client.download_skill_zip( + DownloadSkillParam(skill_name='my-skill', version='1.0.0') +) + +# Save to file +with open('my-skill.zip', 'wb') as f: + f.write(zip_bytes) +``` + +* `param` *DownloadSkillParam* Parameter for downloading a skill ZIP. + * `skill_name` - Name of the skill (required). + * `version` - Target skill version (optional, defaults to latest). + * `label` - Target skill label, e.g. "latest", "stable" (optional). +* `return` ZIP file content as bytes if success or an exception will be raised. + ### Stop AI Client ```python diff --git a/README_CN.md b/README_CN.md index e4f3b43..e6428d9 100644 --- a/README_CN.md +++ b/README_CN.md @@ -31,6 +31,7 @@ Python 3.10+ from v2.nacos import NacosNamingService, NacosConfigService, NacosAIService, ClientConfigBuilder, GRPCConfig, \ Instance, SubscribeServiceParam, RegisterInstanceParam, DeregisterInstanceParam, \ BatchRegisterInstanceParam, GetServiceParam, ListServiceParam, ListInstanceParam, ConfigParam +from v2.nacos.ai.model.ai_param import GetPromptParam, SubscribePromptParam, DownloadSkillParam client_config = (ClientConfigBuilder() .access_key(os.getenv('NACOS_ACCESS_KEY')) @@ -545,6 +546,105 @@ await ai_client.unsubscribe_agent_card( ) ``` +### Prompt 管理 + +Nacos 提供了 Prompt 模板管理能力,包括获取、订阅和变量替换渲染。 + +#### 获取 Prompt + +```python +from v2.nacos.ai.model.ai_param import GetPromptParam + +prompt = await ai_client.get_prompt( + GetPromptParam(prompt_key='my-prompt', version='1.0.0') +) +print(prompt.template) +``` + +* `param` *GetPromptParam* 获取 Prompt 信息的参数 + * `prompt_key` - 要查询的 Prompt 键名(必填) + * `version` - Prompt 版本(可选) + * `label` - Prompt 标签(可选) +* `return` 成功时返回 Prompt,失败时抛出异常 + +#### 使用变量渲染 Prompt + +`Prompt` 对象支持使用 `{{variableName}}` 占位符进行模板渲染。Prompt 中定义的变量可以通过 `PromptVariable.defaultValue` 包含默认值。渲染时,先应用默认值,然后被用户提供的值覆盖。 + +```python +# 使用变量替换渲染 Prompt 模板 +result = prompt.render({"name": "Alice", "place": "Nacos"}) +print(result) # e.g. "Hello Alice, welcome to Nacos!" + +# 如果未覆盖,将自动使用带有 defaultValue 的变量 +# 例如,如果 Prompt 有一个变量:PromptVariable(name="lang", defaultValue="en") +# 调用 render 时不提供 "lang" 将使用 "en" 作为值 +result = prompt.render({"name": "Alice"}) +``` + +* `param` *variables* - 变量名到值的映射字典(可选)。覆盖 `PromptVariable.defaultValue` 中定义的默认值。 +* `return` 替换所有 `{{variableName}}` 占位符后的渲染字符串。 + +#### 订阅 Prompt + +```python +from v2.nacos.ai.model.ai_param import SubscribePromptParam + +async def prompt_listener(prompt_key, prompt): + print(f"Prompt changed: {prompt_key}, version: {prompt.version}") + +prompt = await ai_client.subscribe_prompt( + SubscribePromptParam( + prompt_key='my-prompt', + version='1.0.0', + subscribe_callback=prompt_listener + ) +) +``` + +* `param` *SubscribePromptParam* 订阅 Prompt 变化的参数 + * `prompt_key` - 要订阅的 Prompt 键名(必填) + * `version` - Prompt 版本(可选) + * `label` - Prompt 标签(可选) + * `subscribe_callback` - 处理 Prompt 变化的回调函数(必填) +* `return` 成功时返回当前 Prompt,失败时抛出异常 + +#### 取消订阅 Prompt + +```python +await ai_client.unsubscribe_prompt( + SubscribePromptParam( + prompt_key='my-prompt', + version='1.0.0', + subscribe_callback=prompt_listener + ) +) +``` + +### 技能下载 + +Nacos 支持以 ZIP 压缩包的形式下载技能包。 + +#### 下载技能 ZIP + +```python +from v2.nacos.ai.model.ai_param import DownloadSkillParam + +zip_bytes = await ai_client.download_skill_zip( + DownloadSkillParam(skill_name='my-skill', version='1.0.0') +) + +# 保存到文件 +with open('my-skill.zip', 'wb') as f: + f.write(zip_bytes) +``` + +* `param` *DownloadSkillParam* 下载技能 ZIP 的参数 + * `skill_name` - 技能名称(必填) + * `version` - 目标技能版本(可选,默认为最新版本) + * `label` - 目标技能标签,例如 "latest"、"stable"(可选) +* `return` 成功时返回 ZIP 文件内容(bytes),失败时抛出异常 + ### 停止 AI 客户端 ```python diff --git a/requirements.txt b/requirements.txt index 509af8e..71f959e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,5 +7,4 @@ protobuf>=3.20.3 psutil>=5.9.5 pycryptodome>=3.19.1 pydantic>=2.10.4 -a2a>=0.44 -a2a-sdk>=0.3.20 +a2a-sdk>=0.3.20,<1.0.0 diff --git a/setup.py b/setup.py index 24094a2..5e53f8f 100644 --- a/setup.py +++ b/setup.py @@ -53,7 +53,7 @@ def run(self): setup( name="nacos-sdk-python", - version="3.2.0b1", + version="3.2.0", packages=find_packages( exclude=["test", "*.tests", "*.tests.*", "tests.*", "tests"]), url="https://github.com/nacos-group/nacos-sdk-python", diff --git a/test/client_v2_test.py b/test/client_v2_test.py index b79f4fe..d1b64e7 100644 --- a/test/client_v2_test.py +++ b/test/client_v2_test.py @@ -2,7 +2,6 @@ import os import unittest from typing import List -from unittest.mock import AsyncMock from unittest.mock import AsyncMock, MagicMock from v2.nacos import ConfigParam @@ -240,8 +239,6 @@ async def test_auth_login_url_with_standard_context_path(self): [Regression Test] Verifies that when context_path is '/nacos', the login URL correctly includes the prefix. """ - import logging - # 1. Setup config with standard context path config = ClientConfig( server_addresses="http://127.0.0.1:8848", @@ -278,5 +275,42 @@ async def test_auth_login_url_with_standard_context_path(self): self.assertEqual(called_url, expected_url, f"URL mismatch for standard context_path. Expected '{expected_url}', but got '{called_url}'") + +class TestClientConfigContextPathNormalization(unittest.TestCase): + """Unit tests for ClientConfig.context_path normalization (Issue #300 follow-up).""" + + def test_empty_string_falls_back_to_default(self): + cfg = ClientConfig(server_addresses="http://127.0.0.1:8848", context_path="") + self.assertEqual(cfg.context_path, "/nacos") + + def test_none_falls_back_to_default(self): + cfg = ClientConfig(server_addresses="http://127.0.0.1:8848", context_path=None) + self.assertEqual(cfg.context_path, "/nacos") + + def test_default_value(self): + cfg = ClientConfig(server_addresses="http://127.0.0.1:8848") + self.assertEqual(cfg.context_path, "/nacos") + + def test_missing_leading_slash_is_added(self): + cfg = ClientConfig(server_addresses="http://127.0.0.1:8848", context_path="nacos") + self.assertEqual(cfg.context_path, "/nacos") + + def test_trailing_slash_is_stripped(self): + cfg = ClientConfig(server_addresses="http://127.0.0.1:8848", context_path="/nacos/") + self.assertEqual(cfg.context_path, "/nacos") + + def test_root_is_preserved(self): + cfg = ClientConfig(server_addresses="http://127.0.0.1:8848", context_path="/") + self.assertEqual(cfg.context_path, "/") + + def test_build_context_prefix_for_root(self): + cfg = ClientConfig(server_addresses="http://127.0.0.1:8848", context_path="/") + self.assertEqual(cfg.build_context_prefix(), "") + + def test_build_context_prefix_for_standard(self): + cfg = ClientConfig(server_addresses="http://127.0.0.1:8848", context_path="/nacos") + self.assertEqual(cfg.build_context_prefix(), "/nacos") + + if __name__ == '__main__': unittest.main() diff --git a/test/test_skill_and_prompt.py b/test/test_skill_and_prompt.py new file mode 100644 index 0000000..07c1d86 --- /dev/null +++ b/test/test_skill_and_prompt.py @@ -0,0 +1,292 @@ +import base64 +import io +import json +import unittest +import zipfile + +from v2.nacos.ai.model.prompt.prompt import Prompt, PromptVariable +from v2.nacos.ai.model.skill.skill import Skill, SkillResource +from v2.nacos.ai.util.skill_util import ( + SecurityError, + to_zip_bytes, + validate_zip_bytes, + validate_zip_entry_paths, + resolve_resource_bytes, +) + + +class TestPromptVariable(unittest.TestCase): + """Tests for PromptVariable model.""" + + def test_create_prompt_variable(self): + v = PromptVariable(name="city", defaultValue="Hangzhou", description="Target city") + self.assertEqual(v.name, "city") + self.assertEqual(v.defaultValue, "Hangzhou") + self.assertEqual(v.description, "Target city") + + def test_create_prompt_variable_defaults(self): + v = PromptVariable() + self.assertIsNone(v.name) + self.assertIsNone(v.defaultValue) + self.assertIsNone(v.description) + + +class TestPromptRender(unittest.TestCase): + """Tests for Prompt.render() with variable default values.""" + + def _make_prompt(self, template, variables=None): + return Prompt( + promptKey="test-key", + version="1.0", + template=template, + variables=variables, + ) + + def test_render_uses_defaults_when_no_user_params(self): + prompt = self._make_prompt( + "Hello {{name}}, welcome to {{place}}!", + variables=[ + PromptVariable(name="name", defaultValue="World"), + PromptVariable(name="place", defaultValue="Nacos"), + ], + ) + result = prompt.render() + self.assertEqual(result, "Hello World, welcome to Nacos!") + + def test_render_user_params_partial_override(self): + prompt = self._make_prompt( + "Hello {{name}}, welcome to {{place}}!", + variables=[ + PromptVariable(name="name", defaultValue="World"), + PromptVariable(name="place", defaultValue="Nacos"), + ], + ) + result = prompt.render({"name": "Alice"}) + self.assertEqual(result, "Hello Alice, welcome to Nacos!") + + def test_render_user_params_full_override(self): + prompt = self._make_prompt( + "Hello {{name}}, welcome to {{place}}!", + variables=[ + PromptVariable(name="name", defaultValue="World"), + PromptVariable(name="place", defaultValue="Nacos"), + ], + ) + result = prompt.render({"name": "Bob", "place": "Shanghai"}) + self.assertEqual(result, "Hello Bob, welcome to Shanghai!") + + def test_render_backward_compat_variables_none(self): + """When self.variables is None, render should still work with user params.""" + prompt = self._make_prompt("Hello {{name}}!", variables=None) + result = prompt.render({"name": "Alice"}) + self.assertEqual(result, "Hello Alice!") + + def test_render_backward_compat_no_variables_no_params(self): + """When both variables and params are absent, template returned as-is.""" + prompt = self._make_prompt("Hello {{name}}!", variables=None) + result = prompt.render() + self.assertEqual(result, "Hello {{name}}!") + + def test_render_template_none(self): + prompt = self._make_prompt(None) + self.assertIsNone(prompt.render({"name": "Alice"})) + + def test_render_default_with_none_value(self): + """Default value that is None should not be merged; placeholder stays.""" + prompt = self._make_prompt( + "Hello {{name}}!", + variables=[PromptVariable(name="name", defaultValue=None)], + ) + result = prompt.render() + self.assertEqual(result, "Hello {{name}}!") + + def test_deserialize_prompt_with_variables(self): + """Deserialize a Prompt from JSON that includes variables.""" + data = { + "promptKey": "greeting", + "version": "2.0", + "template": "Hi {{user}}", + "variables": [ + {"name": "user", "defaultValue": "guest", "description": "Username"}, + ], + } + prompt = Prompt(**data) + self.assertEqual(prompt.promptKey, "greeting") + self.assertEqual(len(prompt.variables), 1) + self.assertEqual(prompt.variables[0].name, "user") + self.assertEqual(prompt.variables[0].defaultValue, "guest") + # Ensure render works on deserialized prompt + self.assertEqual(prompt.render(), "Hi guest") + + +class TestSkillModel(unittest.TestCase): + """Tests for Skill / SkillResource model creation.""" + + def test_create_skill_resource(self): + r = SkillResource(name="tpl.json", type="template", content='{"key":"val"}') + self.assertEqual(r.name, "tpl.json") + self.assertEqual(r.type, "template") + self.assertEqual(r.get_resource_identifier(), "template::tpl.json") + + def test_resource_identifier_no_type(self): + r = SkillResource(name="readme.txt") + self.assertEqual(r.get_resource_identifier(), "readme.txt") + + def test_create_skill(self): + skill = Skill( + namespace_id="public", + name="my-skill", + description="A test skill", + skill_md="# My Skill", + resource={ + "tpl": SkillResource(name="tpl.json", type="template", content="{}"), + }, + ) + self.assertEqual(skill.name, "my-skill") + self.assertEqual(skill.description, "A test skill") + self.assertIn("tpl", skill.resource) + + +class TestSkillUtilToZipBytes(unittest.TestCase): + """Tests for skill_util.to_zip_bytes.""" + + def _make_skill(self, name="test-skill", skill_md="# Test", resources=None): + return Skill(name=name, skill_md=skill_md, resource=resources) + + def test_to_zip_basic(self): + skill = self._make_skill() + data = to_zip_bytes(skill) + # Verify it's a valid ZIP + with zipfile.ZipFile(io.BytesIO(data), 'r') as zf: + names = zf.namelist() + self.assertIn("test-skill/SKILL.md", names) + self.assertEqual(zf.read("test-skill/SKILL.md").decode(), "# Test") + + def test_to_zip_with_typed_resource(self): + resources = { + "cfg": SkillResource(name="config.json", type="template", content='{"a":1}'), + } + skill = self._make_skill(resources=resources) + data = to_zip_bytes(skill) + with zipfile.ZipFile(io.BytesIO(data), 'r') as zf: + self.assertIn("test-skill/template/config.json", zf.namelist()) + self.assertEqual(zf.read("test-skill/template/config.json").decode(), '{"a":1}') + + def test_to_zip_with_untyped_resource(self): + resources = { + "readme": SkillResource(name="README.txt", content="hello"), + } + skill = self._make_skill(resources=resources) + data = to_zip_bytes(skill) + with zipfile.ZipFile(io.BytesIO(data), 'r') as zf: + self.assertIn("test-skill/README.txt", zf.namelist()) + + def test_to_zip_with_base64_resource(self): + raw = b'\x89PNG_FAKE_BINARY' + encoded = base64.b64encode(raw).decode() + resources = { + "img": SkillResource( + name="icon.png", + type="data", + content=encoded, + metadata={"encoding": "base64"}, + ), + } + skill = self._make_skill(resources=resources) + data = to_zip_bytes(skill) + with zipfile.ZipFile(io.BytesIO(data), 'r') as zf: + self.assertEqual(zf.read("test-skill/data/icon.png"), raw) + + def test_to_zip_none_skill_raises(self): + with self.assertRaises(ValueError): + to_zip_bytes(None) + + def test_to_zip_blank_name_raises(self): + with self.assertRaises(ValueError): + to_zip_bytes(Skill(name=" ", skill_md="x")) + + def test_to_zip_empty_name_raises(self): + with self.assertRaises(ValueError): + to_zip_bytes(Skill(name="", skill_md="x")) + + def test_to_zip_no_skill_md(self): + """skill_md is None → SKILL.md should contain empty string.""" + skill = self._make_skill(skill_md=None) + data = to_zip_bytes(skill) + with zipfile.ZipFile(io.BytesIO(data), 'r') as zf: + self.assertEqual(zf.read("test-skill/SKILL.md").decode(), "") + + +class TestValidateZipBytes(unittest.TestCase): + """Tests for validate_zip_bytes on invalid data.""" + + def test_none_raises(self): + with self.assertRaises(ValueError) as ctx: + validate_zip_bytes(None) + self.assertIn("too short", str(ctx.exception)) + + def test_short_data_raises(self): + with self.assertRaises(ValueError): + validate_zip_bytes(b'\x00' * 10) + + def test_wrong_magic_raises(self): + with self.assertRaises(ValueError) as ctx: + validate_zip_bytes(b'\x00' * 50) + self.assertIn("magic header", str(ctx.exception)) + + def test_valid_zip_passes(self): + buf = io.BytesIO() + with zipfile.ZipFile(buf, 'w') as zf: + zf.writestr("test.txt", "hello") + validate_zip_bytes(buf.getvalue()) # should not raise + + +class TestResolveResourceBytes(unittest.TestCase): + """Tests for resolve_resource_bytes.""" + + def test_text_resource(self): + r = SkillResource(name="a.txt", content="hello") + self.assertEqual(resolve_resource_bytes(r), b"hello") + + def test_base64_resource(self): + raw = b'\x01\x02\x03' + encoded = base64.b64encode(raw).decode() + r = SkillResource(name="bin", content=encoded, metadata={"encoding": "base64"}) + self.assertEqual(resolve_resource_bytes(r), raw) + + def test_none_content(self): + r = SkillResource(name="empty") + self.assertEqual(resolve_resource_bytes(r), b'') + + +class TestValidateZipEntryPaths(unittest.TestCase): + """Tests for validate_zip_entry_paths path traversal detection.""" + + def _make_zip_with_entry(self, entry_name): + buf = io.BytesIO() + with zipfile.ZipFile(buf, 'w') as zf: + zf.writestr(entry_name, "data") + return buf.getvalue() + + def test_safe_path_passes(self): + data = self._make_zip_with_entry("skill/SKILL.md") + validate_zip_entry_paths(data) # should not raise + + def test_path_traversal_raises(self): + data = self._make_zip_with_entry("skill/../../etc/passwd") + with self.assertRaises(SecurityError): + validate_zip_entry_paths(data) + + def test_absolute_path_raises(self): + data = self._make_zip_with_entry("/etc/passwd") + with self.assertRaises(SecurityError): + validate_zip_entry_paths(data) + + def test_backslash_absolute_path_raises(self): + data = self._make_zip_with_entry("\\Windows\\System32\\evil.dll") + with self.assertRaises(SecurityError): + validate_zip_entry_paths(data) + + +if __name__ == "__main__": + unittest.main() diff --git a/v2/nacos/__init__.py b/v2/nacos/__init__.py index d837023..6772847 100644 --- a/v2/nacos/__init__.py +++ b/v2/nacos/__init__.py @@ -1,4 +1,4 @@ -__version__ = "3.2.0b1" +__version__ = "3.2.0" from .common.client_config import (KMSConfig, GRPCConfig, diff --git a/v2/nacos/ai/model/ai_constant.py b/v2/nacos/ai/model/ai_constant.py index 7cd5b63..39458db 100644 --- a/v2/nacos/ai/model/ai_constant.py +++ b/v2/nacos/ai/model/ai_constant.py @@ -30,4 +30,7 @@ class AIConstants: AI_TRANSPORT_MODE_HTTP = "http" # Default prompt cache update interval in seconds - DEFAULT_PROMPT_CACHE_UPDATE_INTERVAL = 10 \ No newline at end of file + DEFAULT_PROMPT_CACHE_UPDATE_INTERVAL = 10 + + # Skill download HTTP endpoint path + SKILL_DOWNLOAD_PATH = "/v3/client/ai/skills" \ No newline at end of file diff --git a/v2/nacos/ai/model/ai_param.py b/v2/nacos/ai/model/ai_param.py index 7005be0..71e4963 100644 --- a/v2/nacos/ai/model/ai_param.py +++ b/v2/nacos/ai/model/ai_param.py @@ -116,4 +116,16 @@ class SubscribePromptParam(BaseModel): version: Optional[str] = None label: Optional[str] = None subscribe_callback: Optional[ - Callable[[str, Prompt], Awaitable[None]]] = None \ No newline at end of file + Callable[[str, Prompt], Awaitable[None]]] = None + + +# ==================== Skill Params ==================== + +class DownloadSkillParam(BaseModel): + """Parameter model for downloading a Skill ZIP""" + # Skill name (unique identifier) + skill_name: str + # Target skill version (optional, if None gets latest) + version: Optional[str] = None + # Target skill label, e.g. "latest", "stable" (optional) + label: Optional[str] = None \ No newline at end of file diff --git a/v2/nacos/ai/model/prompt/prompt.py b/v2/nacos/ai/model/prompt/prompt.py index d6f79d5..995fcc6 100644 --- a/v2/nacos/ai/model/prompt/prompt.py +++ b/v2/nacos/ai/model/prompt/prompt.py @@ -1,8 +1,23 @@ -from typing import Optional, Dict +from typing import Optional, Dict, List from pydantic import BaseModel +class PromptVariable(BaseModel): + """Prompt variable definition with optional default value. + + Represents a variable placeholder (e.g., {{variableName}}) in a prompt template, + along with its optional default value and description. + """ + + name: Optional[str] = None + defaultValue: Optional[str] = None + description: Optional[str] = None + + def __str__(self): + return f"PromptVariable(name='{self.name}', defaultValue='{self.defaultValue}', description='{self.description}')" + + class Prompt(BaseModel): """Prompt entity for AI Prompt management. @@ -14,10 +29,14 @@ class Prompt(BaseModel): version: Optional[str] = None template: Optional[str] = None md5: Optional[str] = None + variables: Optional[List[PromptVariable]] = None def render(self, variables: Optional[Dict[str, str]] = None) -> Optional[str]: """Render the prompt template by replacing {{variableName}} with values. + First applies default values from variable definitions (self.variables), + then overrides with user-provided values. + Example: prompt = Prompt(template="Hello {{name}}, welcome to {{place}}!") result = prompt.render({"name": "Alice", "place": "Nacos"}) @@ -25,11 +44,20 @@ def render(self, variables: Optional[Dict[str, str]] = None) -> Optional[str]: """ if self.template is None: return None - if not variables: + + merged = {} + if self.variables is not None: + for v in self.variables: + if v.defaultValue is not None: + merged[v.name] = v.defaultValue + if variables is not None: + merged.update(variables) + + if not merged: return self.template result = self.template - for key, value in variables.items(): + for key, value in merged.items(): placeholder = "{{" + key + "}}" result = result.replace(placeholder, value if value is not None else "") return result diff --git a/v2/nacos/ai/model/skill/__init__.py b/v2/nacos/ai/model/skill/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/v2/nacos/ai/model/skill/skill.py b/v2/nacos/ai/model/skill/skill.py new file mode 100644 index 0000000..f57649d --- /dev/null +++ b/v2/nacos/ai/model/skill/skill.py @@ -0,0 +1,48 @@ +from typing import Optional, Dict, Any + +from pydantic import BaseModel + + +class SkillResource(BaseModel): + """Skill resource structure. + + A resource file belonging to a Skill, e.g. template, data, script, etc. + """ + + # Resource name (includes file extension, e.g., config_check_template.json) + name: Optional[str] = None + # Resource type: template, data, script, etc. + type: Optional[str] = None + # Resource content (string format, read from independent configuration) + content: Optional[str] = None + # Resource metadata (optional) + metadata: Optional[Dict[str, Any]] = None + + def get_resource_identifier(self) -> str: + """Get resource unique identifier. + Format: "type::name" if type is not blank, otherwise "name". + """ + if self.type and self.type.strip(): + return f"{self.type}::{self.name}" + return self.name or "" + + +class Skill(BaseModel): + """Skill entity for independent Skills management. + + Contains the SKILL.md content and associated resource files. + """ + + # Namespace ID + namespace_id: Optional[str] = None + # Skill name (unique identifier) + name: Optional[str] = None + # Skill description + description: Optional[str] = None + # Full SKILL.md content + skill_md: Optional[str] = None + # Resource map (key is resource name) + resource: Optional[Dict[str, SkillResource]] = None + + def __str__(self): + return f"Skill(name='{self.name}', description='{self.description}')" diff --git a/v2/nacos/ai/nacos_ai_service.py b/v2/nacos/ai/nacos_ai_service.py index 1563ccc..ba58160 100644 --- a/v2/nacos/ai/nacos_ai_service.py +++ b/v2/nacos/ai/nacos_ai_service.py @@ -7,7 +7,7 @@ RegisterMcpServerEndpointParam, SubscribeMcpServerParam, GetAgentCardParam, \ ReleaseAgentCardParam, RegisterAgentEndpointParam, \ DeregisterAgentEndpointParam, SubscribeAgentCardParam, \ - GetPromptParam, SubscribePromptParam + GetPromptParam, SubscribePromptParam, DownloadSkillParam from v2.nacos.ai.model.cache.agent_info_cache import AgentInfoCacheHolder from v2.nacos.ai.model.cache.agent_subscribe_manager import \ AgentSubscribeManager @@ -49,13 +49,15 @@ def __init__(self, client_config: ClientConfig): self.agent_info_cache_holder = AgentInfoCacheHolder( self.agent_subscribe_manager, self.grpc_client_proxy) + self.http_client_proxy = AiHttpClientProxy( + client_config, self.http_agent, + self.grpc_client_proxy.nacos_server_connector) + transport_mode = getattr(client_config, 'ai_transport_mode', AIConstants.AI_TRANSPORT_MODE_GRPC) if transport_mode == AIConstants.AI_TRANSPORT_MODE_HTTP: self.logger.info("AI transport mode is HTTP, using AiHttpClientProxy.") - self.ai_client_proxy = AiHttpClientProxy( - client_config, self.http_agent, - self.grpc_client_proxy.nacos_server_connector) + self.ai_client_proxy = self.http_client_proxy else: self.ai_client_proxy = self.grpc_client_proxy @@ -236,4 +238,13 @@ async def shutdown(self): await self.agent_info_cache_holder.shutdown() await self.prompt_cache_holder.shutdown() + # ==================== Skill Download Methods ==================== + + async def download_skill_zip(self, param: DownloadSkillParam) -> bytes: + """Download skill as ZIP byte array by skill name. Defaults to latest version.""" + if not param.skill_name or len(param.skill_name) == 0: + raise NacosException(INVALID_PARAM, "skillName is required") + return await self.http_client_proxy.download_skill_zip( + param.skill_name, param.version, param.label) + diff --git a/v2/nacos/ai/remote/ai_http_client_proxy.py b/v2/nacos/ai/remote/ai_http_client_proxy.py index f31fece..fa1d239 100644 --- a/v2/nacos/ai/remote/ai_http_client_proxy.py +++ b/v2/nacos/ai/remote/ai_http_client_proxy.py @@ -6,7 +6,9 @@ from random import randrange from typing import Optional -from v2.nacos.ai.model.prompt.prompt import Prompt +from v2.nacos.ai.model.ai_constant import AIConstants +from v2.nacos.ai.model.prompt.prompt import Prompt, PromptVariable +from v2.nacos.ai.util.skill_util import validate_zip_bytes, validate_zip_entry_paths, SecurityError from v2.nacos.common.client_config import ClientConfig from v2.nacos.common.constants import Constants from v2.nacos.common.nacos_exception import NacosException, SERVER_ERROR, NOT_MODIFIED, NOT_FOUND @@ -63,13 +65,55 @@ async def query_prompt(self, prompt_key: str, version: Optional[str], if data is None: return Prompt() + raw_variables = data.get("variables") + variables = None + if raw_variables is not None: + variables = [PromptVariable(**v) for v in raw_variables] + return Prompt( promptKey=data.get("promptKey"), version=data.get("version"), template=data.get("template"), md5=data.get("md5"), + variables=variables, ) + async def download_skill_zip(self, skill_name: str, + version: Optional[str] = None, + label: Optional[str] = None) -> bytes: + """Download skill as ZIP byte array via HTTP REST API. + + Args: + skill_name: skill name (unique identifier) + version: explicit version (optional) + label: route label, e.g. latest/stable (optional) + + Returns: + ZIP file as byte array + """ + params = { + "namespaceId": self.namespace_id, + "name": skill_name, + } + if version and len(version) > 0: + params["version"] = version + if label and len(label) > 0: + params["label"] = label + + zip_bytes = await self._req_api_bytes(AIConstants.SKILL_DOWNLOAD_PATH, params) + try: + validate_zip_bytes(zip_bytes) + validate_zip_entry_paths(zip_bytes) + except ValueError as e: + raise NacosException( + SERVER_ERROR, + f"Invalid ZIP data returned from server: {e}") + except SecurityError as e: + raise NacosException( + SERVER_ERROR, + f"Downloaded ZIP contains unsafe entry paths: {e}") + return zip_bytes + async def _req_api(self, api: str, params: dict) -> str: server_list = self.nacos_server_connector.get_server_list() if not server_list: @@ -95,14 +139,40 @@ async def _req_api(self, api: str, params: dict) -> str: SERVER_ERROR, f"Failed to request API: {api} after all servers tried: {last_exception}") + async def _req_api_bytes(self, api: str, params: dict) -> bytes: + """Send HTTP GET request and return raw bytes response (for binary downloads).""" + server_list = self.nacos_server_connector.get_server_list() + if not server_list: + raise NacosException(SERVER_ERROR, "no server available") + + last_exception = None + index = randrange(0, len(server_list)) + + for i in range(max(len(server_list), MAX_RETRY)): + server = server_list[index % len(server_list)] + try: + return await self._call_server_bytes(api, params, server) + except NacosException as e: + last_exception = e + if e.error_code == NOT_MODIFIED or e.error_code == NOT_FOUND: + raise + self.logger.debug(f"Request {api} to server {server} failed: {e}") + index = (index + 1) % len(server_list) + + self.logger.error( + f"Request: {api} failed, servers: {server_list}, err: {last_exception}") + raise NacosException( + SERVER_ERROR, + f"Failed to request API: {api} after all servers tried: {last_exception}") + async def _call_server(self, api: str, params: dict, server: str) -> str: headers = await self._build_headers() - context_path = self.client_config.context_path or Constants.WEB_CONTEXT + context_prefix = self.client_config.build_context_prefix() tls_enabled = (self.client_config.tls_config and self.client_config.tls_config.enabled) scheme = "https" if tls_enabled else "http" - url = f"{scheme}://{server}{context_path}{api}" + url = f"{scheme}://{server}{context_prefix}{api}" response, err = await self.http_agent.request( url, "GET", headers=headers, params=params) @@ -119,6 +189,33 @@ async def _call_server(self, api: str, params: dict, server: str) -> str: return response.decode("utf-8") if isinstance(response, bytes) else response + async def _call_server_bytes(self, api: str, params: dict, server: str) -> bytes: + """Call a single server and return raw bytes response.""" + headers = await self._build_headers() + + context_prefix = self.client_config.build_context_prefix() + tls_enabled = (self.client_config.tls_config + and self.client_config.tls_config.enabled) + scheme = "https" if tls_enabled else "http" + url = f"{scheme}://{server}{context_prefix}{api}" + + response, err = await self.http_agent.request( + url, "GET", headers=headers, params=params) + + if err: + err_str = str(err) + if str(HTTP_NOT_MODIFIED) in err_str: + raise NacosException(NOT_MODIFIED, "not modified") + if str(HTTP_NOT_FOUND) in err_str: + raise NacosException(NOT_FOUND, "skill not found") + if str(HTTP_FORBIDDEN) in err_str: + raise NacosException(SERVER_ERROR, "forbidden") + raise NacosException(SERVER_ERROR, f"HTTP request failed: {err}") + + if isinstance(response, bytes): + return response + return response.encode("utf-8") if isinstance(response, str) else bytes(response) + async def _build_headers(self) -> dict: headers = {} await self.nacos_server_connector.inject_security_info(headers) @@ -130,6 +227,8 @@ async def _build_headers(self) -> dict: str(now) + self.client_config.app_key) headers[Constants.CHARSET_KEY] = "utf-8" headers['Timestamp'] = str(now) + headers['Client-Version'] = Constants.CLIENT_VERSION + headers['User-Agent'] = Constants.CLIENT_VERSION credentials = self.client_config.credentials_provider.get_credentials() if credentials.get_access_key_id() and credentials.get_access_key_secret(): diff --git a/v2/nacos/ai/util/skill_util.py b/v2/nacos/ai/util/skill_util.py new file mode 100644 index 0000000..7816185 --- /dev/null +++ b/v2/nacos/ai/util/skill_util.py @@ -0,0 +1,105 @@ +import base64 +import io +import zipfile +from typing import Dict + +from v2.nacos.ai.model.skill.skill import Skill, SkillResource + +# ZIP local file header signature: PK\x03\x04 +ZIP_MAGIC = b'\x50\x4B\x03\x04' + +# Minimum valid ZIP size (local file header = 30 bytes) +ZIP_MIN_SIZE = 30 + +METADATA_ENCODING = "encoding" +METADATA_ENCODING_BASE64 = "base64" +PATH_TRAVERSAL_SEQUENCE = ".." + + +def validate_zip_bytes(data: bytes) -> None: + """Validate that byte array is a valid ZIP file by checking the magic number header.""" + if data is None or len(data) < ZIP_MIN_SIZE: + size = 0 if data is None else len(data) + raise ValueError(f"Invalid ZIP data: too short ({size} bytes)") + if data[:4] != ZIP_MAGIC: + raise ValueError("Invalid ZIP data: missing ZIP magic header (PK\\x03\\x04)") + + +def validate_path_safety(path: str) -> None: + """Validate that a path does not contain path traversal sequences or absolute path indicators.""" + if path is None: + return + if PATH_TRAVERSAL_SEQUENCE in path: + raise SecurityError(f"Path traversal detected: {path}") + if path.startswith("/") or path.startswith("\\"): + raise SecurityError(f"Absolute path not allowed: {path}") + + +def validate_zip_entry_paths(data: bytes) -> None: + """Validate all ZIP entry paths for path traversal and absolute paths.""" + with zipfile.ZipFile(io.BytesIO(data), 'r') as zf: + for entry in zf.namelist(): + validate_path_safety(entry) + + +def is_base64_encoded(resource: SkillResource) -> bool: + """Check if a resource is Base64-encoded binary content.""" + if resource.metadata is None: + return False + return resource.metadata.get(METADATA_ENCODING) == METADATA_ENCODING_BASE64 + + +def resolve_resource_bytes(resource: SkillResource) -> bytes: + """Resolve resource content to raw bytes. + Base64-encoded binary resources are decoded; text resources are returned as UTF-8 bytes. + """ + if resource.content is None: + return b'' + if is_base64_encoded(resource): + return base64.b64decode(resource.content) + return resource.content.encode('utf-8') + + +def to_zip_bytes(skill: Skill) -> bytes: + """Convert Skill object to a ZIP byte array containing all skill files. + + The ZIP structure: skillName/SKILL.md, skillName/type/resourceName, etc. + Binary resources (marked with metadata encoding=base64) are decoded back to raw bytes. + """ + if skill is None: + raise ValueError("Skill cannot be None") + if not skill.name or not skill.name.strip(): + raise ValueError("Skill name cannot be blank") + + skill_name = skill.name + buf = io.BytesIO() + with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf: + # 1. SKILL.md + skill_md_content = skill.skill_md if skill.skill_md else "" + zf.writestr(f"{skill_name}/SKILL.md", skill_md_content) + + # 2. Resource files + if skill.resource: + for resource in skill.resource.values(): + if resource is None or not resource.name or not resource.name.strip(): + continue + entry_path = _build_zip_entry_path(skill_name, resource) + raw_bytes = resolve_resource_bytes(resource) + zf.writestr(entry_path, raw_bytes) + + return buf.getvalue() + + +def _build_zip_entry_path(skill_name: str, resource: SkillResource) -> str: + """Build ZIP entry path for a skill resource.""" + if resource.type and resource.type.strip(): + entry_path = f"{skill_name}/{resource.type}/{resource.name}" + else: + entry_path = f"{skill_name}/{resource.name}" + validate_path_safety(entry_path) + return entry_path + + +class SecurityError(Exception): + """Raised when a security violation is detected (e.g. path traversal).""" + pass diff --git a/v2/nacos/common/client_config.py b/v2/nacos/common/client_config.py index 6bad69d..99b17bb 100644 --- a/v2/nacos/common/client_config.py +++ b/v2/nacos/common/client_config.py @@ -63,15 +63,7 @@ def __init__(self, server_addresses=None, endpoint=None, namespace_id='', contex self.endpoint_query_header = None self.namespace_id = namespace_id self.credentials_provider = credentials_provider if credentials_provider else StaticCredentialsProvider(access_key, secret_key) - if not context_path: - self.context_path = Constants.WEB_CONTEXT - else: - cp = context_path - if not cp.startswith("/"): - cp = "/" + cp - if cp != "/" and cp.endswith("/"): - cp = cp[:-1] - self.context_path = cp + self.context_path = self._normalize_context_path(context_path) self.username = username # the username for nacos auth self.password = password # the password for nacos auth self.app_name = app_name @@ -94,6 +86,27 @@ def __init__(self, server_addresses=None, endpoint=None, namespace_id='', contex self.ai_transport_mode = "grpc" self.ai_prompt_cache_update_interval = 10 + @staticmethod + def _normalize_context_path(context_path): + """Normalize context path: fallback to default when empty, ensure leading + '/', strip trailing '/' except when the value is exactly '/'. + """ + if not context_path: + return Constants.WEB_CONTEXT + cp = context_path + if not cp.startswith("/"): + cp = "/" + cp + if cp != "/" and cp.endswith("/"): + cp = cp[:-1] + return cp + + def build_context_prefix(self): + """Return the context path to use as URL prefix. Returns '' when + context_path is '/' to avoid double slashes when concatenated with an + API path that already starts with '/'. + """ + return "" if self.context_path == "/" else self.context_path + def set_log_level(self, log_level): self.log_level = log_level return self diff --git a/v2/nacos/common/client_config_builder.py b/v2/nacos/common/client_config_builder.py index d82add1..5f649ed 100644 --- a/v2/nacos/common/client_config_builder.py +++ b/v2/nacos/common/client_config_builder.py @@ -27,6 +27,10 @@ def namespace_id(self, namespace_id: str) -> "ClientConfigBuilder": self._config.namespace_id = namespace_id return self + def context_path(self, context_path: str) -> "ClientConfigBuilder": + self._config.context_path = ClientConfig._normalize_context_path(context_path) + return self + def timeout_ms(self, timeout_ms) -> "ClientConfigBuilder": self._config.timeout_ms = timeout_ms return self diff --git a/v2/nacos/common/constants.py b/v2/nacos/common/constants.py index 4605d20..f30e84a 100644 --- a/v2/nacos/common/constants.py +++ b/v2/nacos/common/constants.py @@ -13,7 +13,7 @@ class Constants: LABEL_MODULE = "module" - CLIENT_VERSION = "Nacos-Python-Client:v3.2.0b1" + CLIENT_VERSION = "Nacos-Python-Client:v3.2.0" DATA_IN_BODY_VERSION = 204 diff --git a/v2/nacos/transport/auth_client.py b/v2/nacos/transport/auth_client.py index b80ad6a..2f7727e 100644 --- a/v2/nacos/transport/auth_client.py +++ b/v2/nacos/transport/auth_client.py @@ -28,13 +28,10 @@ async def get_access_token(self, force_refresh=False): "username": self.username, "password": self.password } - ctx_path = self.client_config.context_path + ctx_prefix = self.client_config.build_context_prefix() server_list = self.get_server_list() for server_address in server_list: - if ctx_path == "/": - url = server_address + "/v1/auth/users/login" - else: - url = server_address + ctx_path + "/v1/auth/users/login" + url = server_address + ctx_prefix + "/v1/auth/users/login" resp, error = await self.http_agent.request(url, "POST", None, params, None) if not resp or error: self.logger.warning(f"[get-access-token] request {url} failed, error: {error}") diff --git a/v2/nacos/transport/rpc_client.py b/v2/nacos/transport/rpc_client.py index 158b913..b46710a 100644 --- a/v2/nacos/transport/rpc_client.py +++ b/v2/nacos/transport/rpc_client.py @@ -203,7 +203,11 @@ async def start(self): await self._notify_connection_change(ConnectionStatus.CONNECTED) if connection is None: - raise NacosException(CLIENT_DISCONNECT, "failed to connect server") + self.logger.warning( + "failed to connect to server on startup, switch to async reconnect") + async with self.lock: + self.rpc_client_status = RpcClientStatus.UNHEALTHY + await self.switch_server_async(None, False) @abstractmethod async def connect_to_server(self, server_info: ServerInfo) -> Optional[Connection]: From dd8daccf5283ce0499599fe8dadcd44369f0db37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=BF=AF=E5=85=89?= Date: Mon, 27 Apr 2026 16:48:55 +0800 Subject: [PATCH 2/2] docs(ai): add transport mode description for AI Client --- README.md | 6 ++++++ README_CN.md | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/README.md b/README.md index 888665c..fb23f07 100644 --- a/README.md +++ b/README.md @@ -324,6 +324,12 @@ client_config = (ClientConfigBuilder() ai_client = await NacosAIService.create_ai_service(client_config) ``` +**Transport Modes:** + +- **Prompt** supports both gRPC and HTTP transport. By default, gRPC is used. If the gRPC port is unreachable, the AI client will still start normally (gRPC reconnects asynchronously in the background), and Prompt operations can fall back to HTTP. +- **Skill download** always uses HTTP, regardless of gRPC availability. +- **MCP Server / Agent Card** management uses gRPC. + ### MCP Server Management Nacos provides management capabilities for MCP (Model Context Protocol) Server, including registration, discovery, and subscription, supporting dynamic registration and service discovery of MCP servers. diff --git a/README_CN.md b/README_CN.md index e6428d9..0bffaad 100644 --- a/README_CN.md +++ b/README_CN.md @@ -320,6 +320,12 @@ client_config = (ClientConfigBuilder() ai_client = await NacosAIService.create_ai_service(client_config) ``` +**传输模式说明:** + +- **Prompt** 同时支持 gRPC 和 HTTP 两种传输模式。默认使用 gRPC。如果 gRPC 端口不可达,AI 客户端仍可正常创建(gRPC 会在后台异步重连),Prompt 操作可回退到 HTTP 模式。 +- **Skill 下载** 始终使用 HTTP,不依赖 gRPC 连接。 +- **MCP Server / Agent Card** 管理使用 gRPC。 + ### MCP Server 管理 Nacos 提供了对 MCP (Model Context Protocol) Server 的管理能力,包括注册、发现和订阅,支持 MCP Server 的动态注册和服务发现。