agentrun-inner-test 0.0.46__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentrun/__init__.py +325 -0
- agentrun/agent_runtime/__client_async_template.py +466 -0
- agentrun/agent_runtime/__endpoint_async_template.py +345 -0
- agentrun/agent_runtime/__init__.py +53 -0
- agentrun/agent_runtime/__runtime_async_template.py +477 -0
- agentrun/agent_runtime/api/__data_async_template.py +58 -0
- agentrun/agent_runtime/api/__init__.py +6 -0
- agentrun/agent_runtime/api/control.py +1362 -0
- agentrun/agent_runtime/api/data.py +98 -0
- agentrun/agent_runtime/client.py +868 -0
- agentrun/agent_runtime/endpoint.py +649 -0
- agentrun/agent_runtime/model.py +362 -0
- agentrun/agent_runtime/runtime.py +904 -0
- agentrun/credential/__client_async_template.py +177 -0
- agentrun/credential/__credential_async_template.py +216 -0
- agentrun/credential/__init__.py +28 -0
- agentrun/credential/api/__init__.py +5 -0
- agentrun/credential/api/control.py +606 -0
- agentrun/credential/client.py +319 -0
- agentrun/credential/credential.py +381 -0
- agentrun/credential/model.py +248 -0
- agentrun/integration/__init__.py +21 -0
- agentrun/integration/agentscope/__init__.py +12 -0
- agentrun/integration/agentscope/adapter.py +17 -0
- agentrun/integration/agentscope/builtin.py +65 -0
- agentrun/integration/agentscope/message_adapter.py +185 -0
- agentrun/integration/agentscope/model_adapter.py +60 -0
- agentrun/integration/agentscope/tool_adapter.py +59 -0
- agentrun/integration/builtin/__init__.py +16 -0
- agentrun/integration/builtin/model.py +93 -0
- agentrun/integration/builtin/sandbox.py +1234 -0
- agentrun/integration/builtin/toolset.py +47 -0
- agentrun/integration/crewai/__init__.py +12 -0
- agentrun/integration/crewai/adapter.py +9 -0
- agentrun/integration/crewai/builtin.py +65 -0
- agentrun/integration/crewai/model_adapter.py +31 -0
- agentrun/integration/crewai/tool_adapter.py +26 -0
- agentrun/integration/google_adk/__init__.py +12 -0
- agentrun/integration/google_adk/adapter.py +15 -0
- agentrun/integration/google_adk/builtin.py +65 -0
- agentrun/integration/google_adk/message_adapter.py +144 -0
- agentrun/integration/google_adk/model_adapter.py +46 -0
- agentrun/integration/google_adk/tool_adapter.py +235 -0
- agentrun/integration/langchain/__init__.py +30 -0
- agentrun/integration/langchain/adapter.py +15 -0
- agentrun/integration/langchain/builtin.py +71 -0
- agentrun/integration/langchain/message_adapter.py +141 -0
- agentrun/integration/langchain/model_adapter.py +37 -0
- agentrun/integration/langchain/tool_adapter.py +50 -0
- agentrun/integration/langgraph/__init__.py +35 -0
- agentrun/integration/langgraph/adapter.py +20 -0
- agentrun/integration/langgraph/agent_converter.py +1073 -0
- agentrun/integration/langgraph/builtin.py +65 -0
- agentrun/integration/pydantic_ai/__init__.py +12 -0
- agentrun/integration/pydantic_ai/adapter.py +13 -0
- agentrun/integration/pydantic_ai/builtin.py +65 -0
- agentrun/integration/pydantic_ai/model_adapter.py +44 -0
- agentrun/integration/pydantic_ai/tool_adapter.py +19 -0
- agentrun/integration/utils/__init__.py +112 -0
- agentrun/integration/utils/adapter.py +560 -0
- agentrun/integration/utils/canonical.py +164 -0
- agentrun/integration/utils/converter.py +134 -0
- agentrun/integration/utils/model.py +110 -0
- agentrun/integration/utils/tool.py +1759 -0
- agentrun/model/__client_async_template.py +357 -0
- agentrun/model/__init__.py +57 -0
- agentrun/model/__model_proxy_async_template.py +270 -0
- agentrun/model/__model_service_async_template.py +267 -0
- agentrun/model/api/__init__.py +6 -0
- agentrun/model/api/control.py +1173 -0
- agentrun/model/api/data.py +196 -0
- agentrun/model/client.py +674 -0
- agentrun/model/model.py +235 -0
- agentrun/model/model_proxy.py +439 -0
- agentrun/model/model_service.py +438 -0
- agentrun/sandbox/__aio_sandbox_async_template.py +523 -0
- agentrun/sandbox/__browser_sandbox_async_template.py +110 -0
- agentrun/sandbox/__client_async_template.py +491 -0
- agentrun/sandbox/__code_interpreter_sandbox_async_template.py +463 -0
- agentrun/sandbox/__init__.py +69 -0
- agentrun/sandbox/__sandbox_async_template.py +463 -0
- agentrun/sandbox/__template_async_template.py +152 -0
- agentrun/sandbox/aio_sandbox.py +905 -0
- agentrun/sandbox/api/__aio_data_async_template.py +335 -0
- agentrun/sandbox/api/__browser_data_async_template.py +140 -0
- agentrun/sandbox/api/__code_interpreter_data_async_template.py +206 -0
- agentrun/sandbox/api/__init__.py +19 -0
- agentrun/sandbox/api/__sandbox_data_async_template.py +107 -0
- agentrun/sandbox/api/aio_data.py +551 -0
- agentrun/sandbox/api/browser_data.py +172 -0
- agentrun/sandbox/api/code_interpreter_data.py +396 -0
- agentrun/sandbox/api/control.py +1051 -0
- agentrun/sandbox/api/playwright_async.py +492 -0
- agentrun/sandbox/api/playwright_sync.py +492 -0
- agentrun/sandbox/api/sandbox_data.py +154 -0
- agentrun/sandbox/browser_sandbox.py +185 -0
- agentrun/sandbox/client.py +925 -0
- agentrun/sandbox/code_interpreter_sandbox.py +823 -0
- agentrun/sandbox/model.py +397 -0
- agentrun/sandbox/sandbox.py +848 -0
- agentrun/sandbox/template.py +217 -0
- agentrun/server/__init__.py +191 -0
- agentrun/server/agui_normalizer.py +180 -0
- agentrun/server/agui_protocol.py +797 -0
- agentrun/server/invoker.py +309 -0
- agentrun/server/model.py +427 -0
- agentrun/server/openai_protocol.py +535 -0
- agentrun/server/protocol.py +140 -0
- agentrun/server/server.py +208 -0
- agentrun/toolset/__client_async_template.py +62 -0
- agentrun/toolset/__init__.py +51 -0
- agentrun/toolset/__toolset_async_template.py +204 -0
- agentrun/toolset/api/__init__.py +17 -0
- agentrun/toolset/api/control.py +262 -0
- agentrun/toolset/api/mcp.py +100 -0
- agentrun/toolset/api/openapi.py +1251 -0
- agentrun/toolset/client.py +102 -0
- agentrun/toolset/model.py +321 -0
- agentrun/toolset/toolset.py +270 -0
- agentrun/utils/__data_api_async_template.py +720 -0
- agentrun/utils/__init__.py +5 -0
- agentrun/utils/__resource_async_template.py +158 -0
- agentrun/utils/config.py +258 -0
- agentrun/utils/control_api.py +78 -0
- agentrun/utils/data_api.py +1120 -0
- agentrun/utils/exception.py +151 -0
- agentrun/utils/helper.py +108 -0
- agentrun/utils/log.py +77 -0
- agentrun/utils/model.py +168 -0
- agentrun/utils/resource.py +291 -0
- agentrun_inner_test-0.0.46.dist-info/METADATA +263 -0
- agentrun_inner_test-0.0.46.dist-info/RECORD +135 -0
- agentrun_inner_test-0.0.46.dist-info/WHEEL +5 -0
- agentrun_inner_test-0.0.46.dist-info/licenses/LICENSE +201 -0
- agentrun_inner_test-0.0.46.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1251 @@
|
|
|
1
|
+
"""OpenAPI协议处理 / OpenAPI Protocol Handler
|
|
2
|
+
|
|
3
|
+
处理OpenAPI规范的工具调用。
|
|
4
|
+
Handles tool invocations for OpenAPI specification.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from copy import deepcopy
|
|
8
|
+
import json
|
|
9
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from pydash import get as pg
|
|
13
|
+
|
|
14
|
+
from agentrun.integration.utils.tool import normalize_tool_name
|
|
15
|
+
from agentrun.utils.config import Config
|
|
16
|
+
from agentrun.utils.log import logger
|
|
17
|
+
from agentrun.utils.model import BaseModel
|
|
18
|
+
|
|
19
|
+
from ..model import ToolInfo, ToolSchema
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ApiSet:
|
|
23
|
+
"""统一的工具集接口,支持 OpenAPI 和 MCP 工具"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
tools: List[ToolInfo],
|
|
28
|
+
invoker: Any,
|
|
29
|
+
base_url: Optional[str] = None,
|
|
30
|
+
headers: Optional[Dict[str, str]] = None,
|
|
31
|
+
query_params: Optional[Dict[str, Any]] = None,
|
|
32
|
+
config: Optional[Config] = None,
|
|
33
|
+
):
|
|
34
|
+
self._tools = {tool.name: tool for tool in tools if tool.name}
|
|
35
|
+
self._invoker = invoker
|
|
36
|
+
self._base_url = base_url
|
|
37
|
+
self._default_headers = headers.copy() if headers else {}
|
|
38
|
+
self._default_query_params = query_params.copy() if query_params else {}
|
|
39
|
+
self._base_config = config
|
|
40
|
+
|
|
41
|
+
def invoke(
|
|
42
|
+
self,
|
|
43
|
+
name: str,
|
|
44
|
+
arguments: Optional[Dict[str, Any]] = None,
|
|
45
|
+
config: Optional[Config] = None,
|
|
46
|
+
) -> Dict[str, Any]:
|
|
47
|
+
"""调用指定的工具"""
|
|
48
|
+
if name not in self._tools:
|
|
49
|
+
raise ValueError(f"Tool '{name}' not found.")
|
|
50
|
+
|
|
51
|
+
# 先将 arguments 中的不序列化类型转换为原生 python 类型
|
|
52
|
+
arguments = self._convert_arguments(arguments)
|
|
53
|
+
|
|
54
|
+
# 合并配置:优先使用传入的 config,否则使用 base_config
|
|
55
|
+
effective_config = Config.with_configs(self._base_config, config)
|
|
56
|
+
|
|
57
|
+
# 调用实际的 invoker
|
|
58
|
+
if hasattr(self._invoker, "invoke_tool"):
|
|
59
|
+
return self._invoker.invoke_tool(name, arguments, effective_config)
|
|
60
|
+
elif hasattr(self._invoker, "call_tool"):
|
|
61
|
+
return self._invoker.call_tool(name, arguments, effective_config)
|
|
62
|
+
elif callable(self._invoker):
|
|
63
|
+
return self._invoker(name, arguments)
|
|
64
|
+
else:
|
|
65
|
+
raise ValueError("Invalid invoker provided.")
|
|
66
|
+
|
|
67
|
+
async def invoke_async(
|
|
68
|
+
self,
|
|
69
|
+
name: str,
|
|
70
|
+
arguments: Optional[Dict[str, Any]] = None,
|
|
71
|
+
config: Optional[Config] = None,
|
|
72
|
+
) -> Dict[str, Any]:
|
|
73
|
+
"""异步调用指定的工具"""
|
|
74
|
+
if name not in self._tools:
|
|
75
|
+
raise ValueError(f"Tool '{name}' not found.")
|
|
76
|
+
|
|
77
|
+
# 先将 arguments 中的不序列化类型转换为原生 python 类型
|
|
78
|
+
arguments = self._convert_arguments(arguments)
|
|
79
|
+
|
|
80
|
+
# 合并配置:优先使用传入的 config,否则使用 base_config
|
|
81
|
+
effective_config = Config.with_configs(self._base_config, config)
|
|
82
|
+
|
|
83
|
+
# 调用实际的 invoker
|
|
84
|
+
if hasattr(self._invoker, "invoke_tool_async"):
|
|
85
|
+
return await self._invoker.invoke_tool_async(
|
|
86
|
+
name, arguments, effective_config
|
|
87
|
+
)
|
|
88
|
+
elif hasattr(self._invoker, "call_tool_async"):
|
|
89
|
+
return await self._invoker.call_tool_async(
|
|
90
|
+
name, arguments, effective_config
|
|
91
|
+
)
|
|
92
|
+
else:
|
|
93
|
+
raise ValueError("Async invoker not available.")
|
|
94
|
+
|
|
95
|
+
def tools(self) -> List[ToolInfo]:
|
|
96
|
+
"""返回所有工具列表"""
|
|
97
|
+
return list(self._tools.values())
|
|
98
|
+
|
|
99
|
+
def get_tool(self, name: str) -> Optional[ToolInfo]:
|
|
100
|
+
"""获取指定名称的工具"""
|
|
101
|
+
return self._tools.get(name)
|
|
102
|
+
|
|
103
|
+
def to_function_tool(self, name: str):
|
|
104
|
+
"""将工具转换为 Python 函数
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
name: 工具名称
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
一个 Python 函数,其 __name__ 是工具名称,__doc__ 是描述,
|
|
111
|
+
参数与工具规范定义相同
|
|
112
|
+
"""
|
|
113
|
+
from functools import wraps
|
|
114
|
+
import inspect
|
|
115
|
+
import re
|
|
116
|
+
|
|
117
|
+
if name not in self._tools:
|
|
118
|
+
raise ValueError(f"Tool '{name}' not found.")
|
|
119
|
+
|
|
120
|
+
tool = self._tools[name]
|
|
121
|
+
parameters_schema = tool.parameters
|
|
122
|
+
|
|
123
|
+
# 构建函数签名和参数映射
|
|
124
|
+
sig_params = []
|
|
125
|
+
doc_parts = [tool.description or ""] if tool.description else []
|
|
126
|
+
param_mapping = {} # 记录参数来源(path/query/body)
|
|
127
|
+
|
|
128
|
+
if parameters_schema and parameters_schema.properties:
|
|
129
|
+
doc_parts.append("\n参数:")
|
|
130
|
+
|
|
131
|
+
# 首先处理嵌套的 path、query、body 参数
|
|
132
|
+
for container_name in ["path", "query", "body"]:
|
|
133
|
+
if container_name in parameters_schema.properties:
|
|
134
|
+
container_schema = parameters_schema.properties[
|
|
135
|
+
container_name
|
|
136
|
+
]
|
|
137
|
+
if container_schema.properties:
|
|
138
|
+
for (
|
|
139
|
+
param_name,
|
|
140
|
+
param_schema,
|
|
141
|
+
) in container_schema.properties.items():
|
|
142
|
+
# 如果参数已经在顶层存在,跳过(避免重名)
|
|
143
|
+
if param_name in param_mapping:
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
param_type = param_schema.type or "Any"
|
|
147
|
+
param_desc = param_schema.description or ""
|
|
148
|
+
|
|
149
|
+
# 确定 Python 类型
|
|
150
|
+
python_type = self._schema_type_to_python_type(
|
|
151
|
+
param_type
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# 检查是否必需
|
|
155
|
+
is_required = (
|
|
156
|
+
container_schema.required is not None
|
|
157
|
+
and param_name in container_schema.required
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# 创建参数
|
|
161
|
+
if is_required:
|
|
162
|
+
param = inspect.Parameter(
|
|
163
|
+
param_name,
|
|
164
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
165
|
+
annotation=python_type,
|
|
166
|
+
)
|
|
167
|
+
else:
|
|
168
|
+
from typing import Optional
|
|
169
|
+
|
|
170
|
+
param = inspect.Parameter(
|
|
171
|
+
param_name,
|
|
172
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
173
|
+
default=None,
|
|
174
|
+
annotation=Optional[python_type],
|
|
175
|
+
)
|
|
176
|
+
sig_params.append(param)
|
|
177
|
+
param_mapping[param_name] = container_name
|
|
178
|
+
|
|
179
|
+
# 添加到文档
|
|
180
|
+
doc_parts.append(
|
|
181
|
+
f" {param_name} ({param_type},"
|
|
182
|
+
f" {container_name}): {param_desc}"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# 然后处理顶层参数(不包括 path、query、body)
|
|
186
|
+
for (
|
|
187
|
+
param_name,
|
|
188
|
+
param_schema,
|
|
189
|
+
) in parameters_schema.properties.items():
|
|
190
|
+
if param_name in ["path", "query", "body"]:
|
|
191
|
+
continue
|
|
192
|
+
if param_name in param_mapping:
|
|
193
|
+
continue
|
|
194
|
+
|
|
195
|
+
param_type = param_schema.type or "Any"
|
|
196
|
+
param_desc = param_schema.description or ""
|
|
197
|
+
|
|
198
|
+
# 确定 Python 类型
|
|
199
|
+
python_type = self._schema_type_to_python_type(param_type)
|
|
200
|
+
|
|
201
|
+
# 检查是否必需
|
|
202
|
+
is_required = (
|
|
203
|
+
parameters_schema.required is not None
|
|
204
|
+
and param_name in parameters_schema.required
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# 创建参数
|
|
208
|
+
if is_required:
|
|
209
|
+
param = inspect.Parameter(
|
|
210
|
+
param_name,
|
|
211
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
212
|
+
annotation=python_type,
|
|
213
|
+
)
|
|
214
|
+
else:
|
|
215
|
+
from typing import Optional
|
|
216
|
+
|
|
217
|
+
param = inspect.Parameter(
|
|
218
|
+
param_name,
|
|
219
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
220
|
+
default=None,
|
|
221
|
+
annotation=Optional[python_type],
|
|
222
|
+
)
|
|
223
|
+
sig_params.append(param)
|
|
224
|
+
param_mapping[param_name] = "top"
|
|
225
|
+
|
|
226
|
+
# 添加到文档
|
|
227
|
+
doc_parts.append(f" {param_name} ({param_type}): {param_desc}")
|
|
228
|
+
|
|
229
|
+
# 添加 path、query、body 作为可选参数(用于显式传递)
|
|
230
|
+
for container_name in ["path", "query", "body"]:
|
|
231
|
+
if container_name in parameters_schema.properties:
|
|
232
|
+
from typing import Dict, Optional
|
|
233
|
+
|
|
234
|
+
param = inspect.Parameter(
|
|
235
|
+
container_name,
|
|
236
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
237
|
+
default=None,
|
|
238
|
+
annotation=Optional[Dict[str, Any]],
|
|
239
|
+
)
|
|
240
|
+
sig_params.append(param)
|
|
241
|
+
|
|
242
|
+
# 创建函数实现
|
|
243
|
+
def impl(**kwargs):
|
|
244
|
+
# 处理嵌套的 path、query、body 参数
|
|
245
|
+
normalized_kwargs = {}
|
|
246
|
+
|
|
247
|
+
# 收集 path 参数
|
|
248
|
+
path_params = {}
|
|
249
|
+
if "path" in kwargs and isinstance(kwargs["path"], dict):
|
|
250
|
+
path_params.update(kwargs.pop("path"))
|
|
251
|
+
# 从顶层参数中提取 path 参数
|
|
252
|
+
for param_name, source in param_mapping.items():
|
|
253
|
+
if source == "path" and param_name in kwargs:
|
|
254
|
+
path_params[param_name] = kwargs.pop(param_name)
|
|
255
|
+
if path_params:
|
|
256
|
+
normalized_kwargs["path"] = path_params
|
|
257
|
+
|
|
258
|
+
# 收集 query 参数
|
|
259
|
+
query_params = {}
|
|
260
|
+
if "query" in kwargs and isinstance(kwargs["query"], dict):
|
|
261
|
+
query_params.update(kwargs.pop("query"))
|
|
262
|
+
# 从顶层参数中提取 query 参数
|
|
263
|
+
for param_name, source in param_mapping.items():
|
|
264
|
+
if source == "query" and param_name in kwargs:
|
|
265
|
+
query_params[param_name] = kwargs.pop(param_name)
|
|
266
|
+
if query_params:
|
|
267
|
+
normalized_kwargs["query"] = query_params
|
|
268
|
+
|
|
269
|
+
# 处理 body 参数 - 检查是否有来自 requestBody 的参数
|
|
270
|
+
if "body" in kwargs:
|
|
271
|
+
normalized_kwargs["body"] = kwargs.pop("body")
|
|
272
|
+
else:
|
|
273
|
+
# 收集所有非 path/query 参数作为 body
|
|
274
|
+
# 首先检查是否有显式的 body 类型参数
|
|
275
|
+
body_params = {}
|
|
276
|
+
for param_name, source in param_mapping.items():
|
|
277
|
+
if source == "body" and param_name in kwargs:
|
|
278
|
+
body_params[param_name] = kwargs.pop(param_name)
|
|
279
|
+
|
|
280
|
+
# 如果没有显式的 body 参数,检查是否有来自 requestBody 的顶层参数
|
|
281
|
+
if not body_params:
|
|
282
|
+
# 从 self._operations 中获取操作信息(对于 OpenAPI 工具)
|
|
283
|
+
operation = {}
|
|
284
|
+
if (
|
|
285
|
+
hasattr(self._invoker, "_operations")
|
|
286
|
+
and name in self._invoker._operations
|
|
287
|
+
):
|
|
288
|
+
operation = self._invoker._operations[name]
|
|
289
|
+
request_body = operation.get("request_body", {})
|
|
290
|
+
if request_body:
|
|
291
|
+
content = request_body.get("content", {})
|
|
292
|
+
for content_type in [
|
|
293
|
+
"application/json",
|
|
294
|
+
"application/x-www-form-urlencoded",
|
|
295
|
+
]:
|
|
296
|
+
if content_type in content:
|
|
297
|
+
body_schema = content[content_type].get(
|
|
298
|
+
"schema", {}
|
|
299
|
+
)
|
|
300
|
+
if body_schema.get("type") == "object":
|
|
301
|
+
body_properties = body_schema.get(
|
|
302
|
+
"properties", {}
|
|
303
|
+
)
|
|
304
|
+
for prop_name in body_properties:
|
|
305
|
+
if prop_name in kwargs:
|
|
306
|
+
body_params[prop_name] = kwargs.pop(
|
|
307
|
+
prop_name
|
|
308
|
+
)
|
|
309
|
+
break
|
|
310
|
+
|
|
311
|
+
if body_params:
|
|
312
|
+
normalized_kwargs["body"] = body_params
|
|
313
|
+
|
|
314
|
+
# 合并其他参数(headers、timeout 等)
|
|
315
|
+
normalized_kwargs.update(kwargs)
|
|
316
|
+
|
|
317
|
+
return self.invoke(name, normalized_kwargs)
|
|
318
|
+
|
|
319
|
+
# 包装函数
|
|
320
|
+
@wraps(impl)
|
|
321
|
+
def wrapper(**kwargs):
|
|
322
|
+
return impl(**kwargs)
|
|
323
|
+
|
|
324
|
+
# 设置函数属性
|
|
325
|
+
clean_name = re.sub(r"[^0-9a-zA-Z_]", "_", name)
|
|
326
|
+
# Normalize for provider limits / external frameworks
|
|
327
|
+
clean_name = normalize_tool_name(clean_name)
|
|
328
|
+
wrapper.__name__ = clean_name
|
|
329
|
+
wrapper.__qualname__ = clean_name
|
|
330
|
+
wrapper.__doc__ = "\n".join(doc_parts)
|
|
331
|
+
|
|
332
|
+
# 设置函数签名
|
|
333
|
+
if sig_params:
|
|
334
|
+
wrapper.__signature__ = inspect.Signature(sig_params) # type: ignore
|
|
335
|
+
|
|
336
|
+
return wrapper
|
|
337
|
+
|
|
338
|
+
def _schema_type_to_python_type(self, schema_type: str):
|
|
339
|
+
"""将 schema 类型转换为 Python 类型"""
|
|
340
|
+
type_mapping = {
|
|
341
|
+
"string": str,
|
|
342
|
+
"integer": int,
|
|
343
|
+
"number": float,
|
|
344
|
+
"boolean": bool,
|
|
345
|
+
"object": dict,
|
|
346
|
+
"array": list,
|
|
347
|
+
}
|
|
348
|
+
return type_mapping.get(schema_type, Any)
|
|
349
|
+
|
|
350
|
+
def _convert_to_native(self, value: Any) -> Any:
|
|
351
|
+
"""将常见框架类型或 Pydantic/GoogleADK 等对象转换为 Python 原生类型
|
|
352
|
+
|
|
353
|
+
目的是确保我们发送到 OpenAPI 的 JSON body 是可以被 json 序列化的
|
|
354
|
+
"""
|
|
355
|
+
# 基本类型
|
|
356
|
+
if value is None or isinstance(value, (str, int, float, bool)):
|
|
357
|
+
return value
|
|
358
|
+
|
|
359
|
+
# 列表与字典
|
|
360
|
+
if isinstance(value, list):
|
|
361
|
+
return [self._convert_to_native(item) for item in value]
|
|
362
|
+
if isinstance(value, dict):
|
|
363
|
+
return {k: self._convert_to_native(v) for k, v in value.items()}
|
|
364
|
+
|
|
365
|
+
# Pydantic v2
|
|
366
|
+
if hasattr(value, "model_dump"):
|
|
367
|
+
try:
|
|
368
|
+
dumped = value.model_dump(mode="python", exclude_unset=True)
|
|
369
|
+
return self._convert_to_native(dumped)
|
|
370
|
+
except Exception:
|
|
371
|
+
pass
|
|
372
|
+
|
|
373
|
+
# Pydantic v1 or other obj
|
|
374
|
+
if hasattr(value, "dict"):
|
|
375
|
+
try:
|
|
376
|
+
dumped = value.dict(exclude_none=True)
|
|
377
|
+
return self._convert_to_native(dumped)
|
|
378
|
+
except Exception:
|
|
379
|
+
pass
|
|
380
|
+
|
|
381
|
+
# Google ADK 的 Nestedobject 等
|
|
382
|
+
if hasattr(value, "to_dict"):
|
|
383
|
+
try:
|
|
384
|
+
dumped = value.to_dict()
|
|
385
|
+
return self._convert_to_native(dumped)
|
|
386
|
+
except Exception:
|
|
387
|
+
pass
|
|
388
|
+
|
|
389
|
+
if hasattr(value, "__dict__"):
|
|
390
|
+
try:
|
|
391
|
+
dumped = getattr(value, "__dict__")
|
|
392
|
+
return self._convert_to_native(dumped)
|
|
393
|
+
except Exception:
|
|
394
|
+
pass
|
|
395
|
+
|
|
396
|
+
# 无法转换,返回原值,httpx/JSON 序列化时可能会失败
|
|
397
|
+
return value
|
|
398
|
+
|
|
399
|
+
def _convert_arguments(
|
|
400
|
+
self, args: Optional[Dict[str, Any]]
|
|
401
|
+
) -> Optional[Dict[str, Any]]:
|
|
402
|
+
if args is None:
|
|
403
|
+
return None
|
|
404
|
+
if not isinstance(args, dict):
|
|
405
|
+
return args
|
|
406
|
+
return {k: self._convert_to_native(v) for k, v in args.items()}
|
|
407
|
+
|
|
408
|
+
@classmethod
|
|
409
|
+
def from_openapi_schema(
|
|
410
|
+
cls,
|
|
411
|
+
schema: Union[str, dict],
|
|
412
|
+
base_url: Optional[str] = None,
|
|
413
|
+
headers: Optional[Dict[str, str]] = None,
|
|
414
|
+
query_params: Optional[Dict[str, Any]] = None,
|
|
415
|
+
config: Optional[Config] = None,
|
|
416
|
+
timeout: Optional[int] = None,
|
|
417
|
+
) -> "ApiSet":
|
|
418
|
+
"""从 OpenAPI schema 创建 ApiSet
|
|
419
|
+
|
|
420
|
+
Args:
|
|
421
|
+
input: OpenAPI schema (字符串或字典)
|
|
422
|
+
base_url: 基础 URL
|
|
423
|
+
headers: 默认请求头
|
|
424
|
+
query_params: 默认查询参数
|
|
425
|
+
config: 配置对象
|
|
426
|
+
timeout: 超时时间
|
|
427
|
+
"""
|
|
428
|
+
# 创建 OpenAPI 客户端(会解析 $ref)
|
|
429
|
+
openapi_client = OpenAPI(
|
|
430
|
+
schema=schema,
|
|
431
|
+
base_url=base_url,
|
|
432
|
+
headers=headers,
|
|
433
|
+
query_params=query_params,
|
|
434
|
+
config=config,
|
|
435
|
+
timeout=timeout,
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
tools = []
|
|
439
|
+
# 使用已解析 $ref 的 schema
|
|
440
|
+
resolved_schema = openapi_client._schema
|
|
441
|
+
|
|
442
|
+
if isinstance(resolved_schema, dict):
|
|
443
|
+
paths = resolved_schema.get("paths", {})
|
|
444
|
+
if isinstance(paths, dict):
|
|
445
|
+
for path, path_item in paths.items():
|
|
446
|
+
if not isinstance(path_item, dict):
|
|
447
|
+
continue
|
|
448
|
+
|
|
449
|
+
# 获取路径级别的参数
|
|
450
|
+
path_parameters = path_item.get("parameters", [])
|
|
451
|
+
|
|
452
|
+
for method, operation in path_item.items():
|
|
453
|
+
if method.upper() not in OpenAPI._SUPPORTED_METHODS:
|
|
454
|
+
continue
|
|
455
|
+
if not isinstance(operation, dict):
|
|
456
|
+
continue
|
|
457
|
+
|
|
458
|
+
operation_id = (
|
|
459
|
+
operation.get("operationId")
|
|
460
|
+
or f"{method.upper()} {path}"
|
|
461
|
+
)
|
|
462
|
+
description = operation.get(
|
|
463
|
+
"description"
|
|
464
|
+
) or operation.get("summary", "")
|
|
465
|
+
|
|
466
|
+
# 构建参数 schema,区分 path、query、body
|
|
467
|
+
properties = {}
|
|
468
|
+
required = []
|
|
469
|
+
|
|
470
|
+
# 合并路径级别和操作级别的参数
|
|
471
|
+
all_parameters = []
|
|
472
|
+
if isinstance(path_parameters, list):
|
|
473
|
+
all_parameters.extend(path_parameters)
|
|
474
|
+
operation_parameters = operation.get("parameters", [])
|
|
475
|
+
if isinstance(operation_parameters, list):
|
|
476
|
+
all_parameters.extend(operation_parameters)
|
|
477
|
+
|
|
478
|
+
# 处理 path 和 query 参数
|
|
479
|
+
path_params = {}
|
|
480
|
+
query_params = {}
|
|
481
|
+
for param in all_parameters:
|
|
482
|
+
if not isinstance(param, dict):
|
|
483
|
+
continue
|
|
484
|
+
param_name = param.get("name")
|
|
485
|
+
param_in = param.get("in", "query")
|
|
486
|
+
param_schema = param.get("schema", {})
|
|
487
|
+
param_description = param.get("description", "")
|
|
488
|
+
param_required = param.get("required", False)
|
|
489
|
+
|
|
490
|
+
if not param_name:
|
|
491
|
+
continue
|
|
492
|
+
|
|
493
|
+
# 为了避免重名,使用命名空间
|
|
494
|
+
if param_in == "path":
|
|
495
|
+
path_params[param_name] = {
|
|
496
|
+
"schema": (
|
|
497
|
+
ToolSchema.from_any_openapi_schema(
|
|
498
|
+
param_schema
|
|
499
|
+
)
|
|
500
|
+
),
|
|
501
|
+
"description": param_description,
|
|
502
|
+
"required": param_required,
|
|
503
|
+
}
|
|
504
|
+
elif param_in == "query":
|
|
505
|
+
query_params[param_name] = {
|
|
506
|
+
"schema": (
|
|
507
|
+
ToolSchema.from_any_openapi_schema(
|
|
508
|
+
param_schema
|
|
509
|
+
)
|
|
510
|
+
),
|
|
511
|
+
"description": param_description,
|
|
512
|
+
"required": param_required,
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
# 将 path 和 query 参数添加到 properties
|
|
516
|
+
if path_params:
|
|
517
|
+
path_properties = {
|
|
518
|
+
name: info["schema"]
|
|
519
|
+
for name, info in path_params.items()
|
|
520
|
+
}
|
|
521
|
+
properties["path"] = ToolSchema(
|
|
522
|
+
type="object",
|
|
523
|
+
properties=path_properties,
|
|
524
|
+
required=[
|
|
525
|
+
name
|
|
526
|
+
for name, info in path_params.items()
|
|
527
|
+
if info["required"]
|
|
528
|
+
],
|
|
529
|
+
description="Path parameters",
|
|
530
|
+
)
|
|
531
|
+
# 同时将 path 参数直接添加到顶层(方便使用)
|
|
532
|
+
for name, info in path_params.items():
|
|
533
|
+
if (
|
|
534
|
+
name not in properties
|
|
535
|
+
): # 避免与 query 参数重名
|
|
536
|
+
properties[name] = info["schema"]
|
|
537
|
+
if info["required"]:
|
|
538
|
+
required.append(name)
|
|
539
|
+
|
|
540
|
+
if query_params:
|
|
541
|
+
query_properties = {
|
|
542
|
+
name: info["schema"]
|
|
543
|
+
for name, info in query_params.items()
|
|
544
|
+
}
|
|
545
|
+
properties["query"] = ToolSchema(
|
|
546
|
+
type="object",
|
|
547
|
+
properties=query_properties,
|
|
548
|
+
required=[
|
|
549
|
+
name
|
|
550
|
+
for name, info in query_params.items()
|
|
551
|
+
if info["required"]
|
|
552
|
+
],
|
|
553
|
+
description="Query parameters",
|
|
554
|
+
)
|
|
555
|
+
# 同时将 query 参数直接添加到顶层(方便使用)
|
|
556
|
+
for name, info in query_params.items():
|
|
557
|
+
if (
|
|
558
|
+
name not in properties
|
|
559
|
+
): # 避免与 path 参数重名
|
|
560
|
+
properties[name] = info["schema"]
|
|
561
|
+
if info["required"]:
|
|
562
|
+
required.append(name)
|
|
563
|
+
|
|
564
|
+
# 处理 requestBody - 将属性直接展开到顶层
|
|
565
|
+
request_body = operation.get("requestBody", {})
|
|
566
|
+
if isinstance(request_body, dict):
|
|
567
|
+
content = request_body.get("content", {})
|
|
568
|
+
for content_type in [
|
|
569
|
+
"application/json",
|
|
570
|
+
"application/x-www-form-urlencoded",
|
|
571
|
+
]:
|
|
572
|
+
if content_type in content:
|
|
573
|
+
body_schema = content[content_type].get(
|
|
574
|
+
"schema", {}
|
|
575
|
+
)
|
|
576
|
+
if (
|
|
577
|
+
body_schema
|
|
578
|
+
and body_schema.get("type") == "object"
|
|
579
|
+
):
|
|
580
|
+
# 将 requestBody schema 的属性直接展开到顶层
|
|
581
|
+
body_properties = body_schema.get(
|
|
582
|
+
"properties", {}
|
|
583
|
+
)
|
|
584
|
+
for (
|
|
585
|
+
prop_name,
|
|
586
|
+
prop_schema,
|
|
587
|
+
) in body_properties.items():
|
|
588
|
+
if (
|
|
589
|
+
prop_name not in properties
|
|
590
|
+
): # 避免与已有参数冲突
|
|
591
|
+
properties[prop_name] = (
|
|
592
|
+
ToolSchema.from_any_openapi_schema(
|
|
593
|
+
prop_schema
|
|
594
|
+
)
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
# 添加必需的 requestBody 参数
|
|
598
|
+
if request_body.get("required"):
|
|
599
|
+
body_required = body_schema.get(
|
|
600
|
+
"required", []
|
|
601
|
+
)
|
|
602
|
+
for req_param in body_required:
|
|
603
|
+
if req_param not in required:
|
|
604
|
+
required.append(req_param)
|
|
605
|
+
break
|
|
606
|
+
|
|
607
|
+
parameters = ToolSchema(
|
|
608
|
+
type="object",
|
|
609
|
+
properties=properties if properties else None,
|
|
610
|
+
required=required if required else None,
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
tools.append(
|
|
614
|
+
ToolInfo(
|
|
615
|
+
name=normalize_tool_name(operation_id),
|
|
616
|
+
description=description,
|
|
617
|
+
parameters=parameters,
|
|
618
|
+
)
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
return cls(
|
|
622
|
+
tools=tools,
|
|
623
|
+
invoker=openapi_client,
|
|
624
|
+
base_url=base_url,
|
|
625
|
+
headers=headers,
|
|
626
|
+
query_params=query_params,
|
|
627
|
+
config=config,
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
@classmethod
|
|
631
|
+
def from_mcp_tools(
|
|
632
|
+
cls,
|
|
633
|
+
tools: Any,
|
|
634
|
+
mcp_client: Any,
|
|
635
|
+
config: Optional[Config] = None,
|
|
636
|
+
) -> "ApiSet":
|
|
637
|
+
"""从 MCP tools 创建 ApiSet
|
|
638
|
+
|
|
639
|
+
Args:
|
|
640
|
+
tools: MCP tools 列表或单个工具
|
|
641
|
+
mcp_client: MCP 客户端(MCPToolSet 实例)
|
|
642
|
+
config: 配置对象
|
|
643
|
+
"""
|
|
644
|
+
# 获取工具列表
|
|
645
|
+
tool_infos = []
|
|
646
|
+
if tools:
|
|
647
|
+
|
|
648
|
+
# 如果 tools 是单个工具,转换为列表
|
|
649
|
+
if not isinstance(tools, (list, tuple)):
|
|
650
|
+
tools = [tools]
|
|
651
|
+
|
|
652
|
+
for tool in tools:
|
|
653
|
+
# 处理不同的 MCP tool 格式
|
|
654
|
+
if hasattr(tool, "name"):
|
|
655
|
+
# MCP Tool 对象
|
|
656
|
+
tool_name = tool.name
|
|
657
|
+
tool_description = getattr(tool, "description", None)
|
|
658
|
+
input_schema = getattr(
|
|
659
|
+
tool, "inputSchema", None
|
|
660
|
+
) or getattr(tool, "input_schema", None)
|
|
661
|
+
elif isinstance(tool, dict):
|
|
662
|
+
# 字典格式
|
|
663
|
+
tool_name = tool.get("name")
|
|
664
|
+
tool_description = tool.get("description")
|
|
665
|
+
input_schema = tool.get("inputSchema") or tool.get(
|
|
666
|
+
"input_schema"
|
|
667
|
+
)
|
|
668
|
+
else:
|
|
669
|
+
continue
|
|
670
|
+
|
|
671
|
+
if not tool_name:
|
|
672
|
+
continue
|
|
673
|
+
|
|
674
|
+
# 构建 parameters schema
|
|
675
|
+
parameters = None
|
|
676
|
+
if input_schema:
|
|
677
|
+
if isinstance(input_schema, dict):
|
|
678
|
+
parameters = ToolSchema.from_any_openapi_schema(
|
|
679
|
+
input_schema
|
|
680
|
+
)
|
|
681
|
+
elif hasattr(input_schema, "model_dump"):
|
|
682
|
+
parameters = ToolSchema.from_any_openapi_schema(
|
|
683
|
+
input_schema.model_dump()
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
tool_infos.append(
|
|
687
|
+
ToolInfo(
|
|
688
|
+
name=normalize_tool_name(tool_name),
|
|
689
|
+
description=tool_description,
|
|
690
|
+
parameters=parameters
|
|
691
|
+
or ToolSchema(type="object", properties={}),
|
|
692
|
+
)
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
# 创建调用器包装
|
|
696
|
+
class MCPInvoker:
|
|
697
|
+
|
|
698
|
+
def __init__(self, mcp_client, config):
|
|
699
|
+
self.mcp_client = mcp_client
|
|
700
|
+
self.config = config
|
|
701
|
+
|
|
702
|
+
def invoke_tool(
|
|
703
|
+
self,
|
|
704
|
+
name: str,
|
|
705
|
+
arguments: Optional[Dict[str, Any]] = None,
|
|
706
|
+
config: Optional[Config] = None,
|
|
707
|
+
):
|
|
708
|
+
cfg = Config.with_configs(self.config, config)
|
|
709
|
+
return self.mcp_client.call_tool(name, arguments, cfg)
|
|
710
|
+
|
|
711
|
+
async def invoke_tool_async(
|
|
712
|
+
self,
|
|
713
|
+
name: str,
|
|
714
|
+
arguments: Optional[Dict[str, Any]] = None,
|
|
715
|
+
config: Optional[Config] = None,
|
|
716
|
+
):
|
|
717
|
+
cfg = Config.with_configs(self.config, config)
|
|
718
|
+
return await self.mcp_client.call_tool_async(
|
|
719
|
+
name, arguments, cfg
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
invoker = MCPInvoker(mcp_client, config)
|
|
723
|
+
|
|
724
|
+
return cls(
|
|
725
|
+
tools=tool_infos,
|
|
726
|
+
invoker=invoker,
|
|
727
|
+
config=config,
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
class OpenAPI:
|
|
732
|
+
"""OpenAPI schema based tool client."""
|
|
733
|
+
|
|
734
|
+
_SUPPORTED_METHODS = {
|
|
735
|
+
"DELETE",
|
|
736
|
+
"GET",
|
|
737
|
+
"HEAD",
|
|
738
|
+
"OPTIONS",
|
|
739
|
+
"PATCH",
|
|
740
|
+
"POST",
|
|
741
|
+
"PUT",
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
def __init__(
|
|
745
|
+
self,
|
|
746
|
+
schema: Any,
|
|
747
|
+
base_url: Optional[str] = None,
|
|
748
|
+
headers: Optional[Dict[str, str]] = None,
|
|
749
|
+
query_params: Optional[Dict[str, Any]] = None,
|
|
750
|
+
config: Optional[Config] = None,
|
|
751
|
+
timeout: Optional[int] = None,
|
|
752
|
+
):
|
|
753
|
+
self._raw_schema = schema or ""
|
|
754
|
+
self._schema = self._load_schema(self._raw_schema)
|
|
755
|
+
# Resolve local $ref (e.g. "#/components/schemas/...") to simplify later usage.
|
|
756
|
+
# Only resolves internal JSON Pointers; external refs (urls) are left as-is.
|
|
757
|
+
try:
|
|
758
|
+
self._resolve_refs(self._schema)
|
|
759
|
+
except Exception:
|
|
760
|
+
# If resolving fails for any reason, fall back to original schema.
|
|
761
|
+
logger.debug(
|
|
762
|
+
"OpenAPI $ref resolution failed; continuing without expansion"
|
|
763
|
+
)
|
|
764
|
+
self._operations = self._build_operations(self._schema)
|
|
765
|
+
self._base_url = base_url or self._extract_base_url(self._schema)
|
|
766
|
+
self._default_headers = headers.copy() if headers else {}
|
|
767
|
+
self._default_query_params = query_params.copy() if query_params else {}
|
|
768
|
+
self._base_config = config
|
|
769
|
+
self._default_timeout = (
|
|
770
|
+
timeout or (config.get_timeout() if config else None) or 60
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
def list_tools(self, name: Optional[str] = None):
|
|
774
|
+
"""List tools defined in the OpenAPI schema.
|
|
775
|
+
|
|
776
|
+
Args:
|
|
777
|
+
name: OperationId of the tool. When provided, return the single
|
|
778
|
+
tool definition; otherwise return all tools.
|
|
779
|
+
|
|
780
|
+
Returns:
|
|
781
|
+
A list of tool metadata dictionaries.
|
|
782
|
+
"""
|
|
783
|
+
|
|
784
|
+
if name:
|
|
785
|
+
if name not in self._operations:
|
|
786
|
+
raise ValueError(f"Tool '{name}' not found in OpenAPI schema.")
|
|
787
|
+
return [deepcopy(self._operations[name])]
|
|
788
|
+
|
|
789
|
+
return [deepcopy(item) for item in self._operations.values()]
|
|
790
|
+
|
|
791
|
+
# return [
|
|
792
|
+
# ToolInfo(
|
|
793
|
+
# name=item["operationId"],
|
|
794
|
+
# description=item.get("description") or item.get("summary"),
|
|
795
|
+
# parameters=ToolSchema(
|
|
796
|
+
# type="object",
|
|
797
|
+
# properties={
|
|
798
|
+
# "path": ToolSchema(
|
|
799
|
+
# type="object",
|
|
800
|
+
# properties={
|
|
801
|
+
# param["name"]: ToolSchema(
|
|
802
|
+
# type=pg(param, "schema.type", "string"),
|
|
803
|
+
# description=pg(param, "schema.description", pg("description", "")),
|
|
804
|
+
# properties:
|
|
805
|
+
# )
|
|
806
|
+
# for param in item["parameters"]
|
|
807
|
+
# },
|
|
808
|
+
# ),
|
|
809
|
+
# "parameters": ToolSchema(
|
|
810
|
+
# type="object",
|
|
811
|
+
# ),
|
|
812
|
+
# "body": ToolSchema(type="object", description="Request body"),
|
|
813
|
+
# },
|
|
814
|
+
# ),
|
|
815
|
+
# )
|
|
816
|
+
# for item in self._operations.values()
|
|
817
|
+
# ]
|
|
818
|
+
|
|
819
|
+
def has_tool(self, name: str) -> bool:
|
|
820
|
+
return name in self._operations
|
|
821
|
+
|
|
822
|
+
def invoke_tool(
|
|
823
|
+
self,
|
|
824
|
+
name: str,
|
|
825
|
+
arguments: Optional[Dict[str, Any]] = None,
|
|
826
|
+
config: Optional[Config] = None,
|
|
827
|
+
) -> Dict[str, Any]:
|
|
828
|
+
arguments = self._convert_arguments(arguments)
|
|
829
|
+
request_kwargs, timeout, raise_for_status = self._prepare_request(
|
|
830
|
+
name, arguments, config
|
|
831
|
+
)
|
|
832
|
+
with httpx.Client(timeout=timeout) as client:
|
|
833
|
+
response = client.request(**request_kwargs)
|
|
834
|
+
if raise_for_status:
|
|
835
|
+
response.raise_for_status()
|
|
836
|
+
return self._format_response(response)
|
|
837
|
+
|
|
838
|
+
async def invoke_tool_async(
|
|
839
|
+
self,
|
|
840
|
+
name: str,
|
|
841
|
+
arguments: Optional[Dict[str, Any]] = None,
|
|
842
|
+
config: Optional[Config] = None,
|
|
843
|
+
) -> Dict[str, Any]:
|
|
844
|
+
arguments = self._convert_arguments(arguments)
|
|
845
|
+
request_kwargs, timeout, raise_for_status = self._prepare_request(
|
|
846
|
+
name, arguments, config
|
|
847
|
+
)
|
|
848
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
849
|
+
response = await client.request(**request_kwargs)
|
|
850
|
+
if raise_for_status:
|
|
851
|
+
response.raise_for_status()
|
|
852
|
+
return self._format_response(response)
|
|
853
|
+
|
|
854
|
+
def _load_schema(self, schema: Any) -> Dict[str, Any]:
|
|
855
|
+
if isinstance(schema, dict):
|
|
856
|
+
return schema
|
|
857
|
+
|
|
858
|
+
if isinstance(schema, (bytes, bytearray)):
|
|
859
|
+
schema = schema.decode("utf-8")
|
|
860
|
+
|
|
861
|
+
if not schema:
|
|
862
|
+
raise ValueError("OpenAPI schema detail is required.")
|
|
863
|
+
|
|
864
|
+
try:
|
|
865
|
+
return json.loads(schema)
|
|
866
|
+
except json.JSONDecodeError:
|
|
867
|
+
pass
|
|
868
|
+
|
|
869
|
+
try:
|
|
870
|
+
import yaml
|
|
871
|
+
except ImportError as exc: # pragma: no cover
|
|
872
|
+
raise ImportError(
|
|
873
|
+
"PyYAML is required to parse OpenAPI YAML schema."
|
|
874
|
+
) from exc
|
|
875
|
+
|
|
876
|
+
try:
|
|
877
|
+
return yaml.safe_load(schema) or {}
|
|
878
|
+
except yaml.YAMLError as exc: # pragma: no cover
|
|
879
|
+
raise ValueError("Invalid OpenAPI schema content.") from exc
|
|
880
|
+
|
|
881
|
+
def _resolve_refs(self, root: Any) -> None:
|
|
882
|
+
"""Resolve local JSON-Pointer $ref entries in-place.
|
|
883
|
+
|
|
884
|
+
This implementation only handles refs that start with "#/" and
|
|
885
|
+
resolves them by replacing the node with a deep copy of the target.
|
|
886
|
+
It also merges other sibling keys (e.g. description) onto the
|
|
887
|
+
resolved target, matching common OpenAPI semantics.
|
|
888
|
+
"""
|
|
889
|
+
if not isinstance(root, (dict, list)):
|
|
890
|
+
return
|
|
891
|
+
|
|
892
|
+
def resolve_pointer(doc: Dict[str, Any], parts: List[str]):
|
|
893
|
+
cur = doc
|
|
894
|
+
for p in parts:
|
|
895
|
+
# unescape per JSON Pointer spec
|
|
896
|
+
p = p.replace("~1", "/").replace("~0", "~")
|
|
897
|
+
if isinstance(cur, dict) and p in cur:
|
|
898
|
+
cur = cur[p]
|
|
899
|
+
else:
|
|
900
|
+
return None
|
|
901
|
+
return cur
|
|
902
|
+
|
|
903
|
+
def _walk(node: Any):
|
|
904
|
+
if isinstance(node, dict):
|
|
905
|
+
# if this node is a $ref, resolve it and return replacement
|
|
906
|
+
if "$ref" in node and isinstance(node["$ref"], str):
|
|
907
|
+
ref = node["$ref"]
|
|
908
|
+
if ref.startswith("#/"):
|
|
909
|
+
parts = ref[2:].split("/")
|
|
910
|
+
target = resolve_pointer(root, parts)
|
|
911
|
+
if target is None:
|
|
912
|
+
return node
|
|
913
|
+
replacement = deepcopy(target)
|
|
914
|
+
# merge other keys (except $ref) into replacement
|
|
915
|
+
for k, v in node.items():
|
|
916
|
+
if k == "$ref":
|
|
917
|
+
continue
|
|
918
|
+
replacement[k] = v
|
|
919
|
+
# continue resolving inside replacement
|
|
920
|
+
return _walk(replacement)
|
|
921
|
+
# external refs: leave as-is
|
|
922
|
+
return node
|
|
923
|
+
|
|
924
|
+
# otherwise recurse into children
|
|
925
|
+
for k, v in list(node.items()):
|
|
926
|
+
node[k] = _walk(v)
|
|
927
|
+
return node
|
|
928
|
+
elif isinstance(node, list):
|
|
929
|
+
return [_walk(item) for item in node]
|
|
930
|
+
else:
|
|
931
|
+
return node
|
|
932
|
+
|
|
933
|
+
# perform in-place walk and resolution
|
|
934
|
+
resolved = _walk(root)
|
|
935
|
+
# If root was replaced by a resolved value, try to update it in-place
|
|
936
|
+
if (
|
|
937
|
+
resolved is not root
|
|
938
|
+
and isinstance(root, dict)
|
|
939
|
+
and isinstance(resolved, dict)
|
|
940
|
+
):
|
|
941
|
+
root.clear()
|
|
942
|
+
root.update(resolved)
|
|
943
|
+
|
|
944
|
+
def _extract_base_url(self, schema: Dict[str, Any]) -> Optional[str]:
|
|
945
|
+
servers = schema.get("servers") or []
|
|
946
|
+
return self._pick_server_url(servers)
|
|
947
|
+
|
|
948
|
+
def _convert_to_native(self, value: Any) -> Any:
|
|
949
|
+
if value is None or isinstance(value, (str, int, float, bool)):
|
|
950
|
+
return value
|
|
951
|
+
if isinstance(value, list):
|
|
952
|
+
return [self._convert_to_native(item) for item in value]
|
|
953
|
+
if isinstance(value, dict):
|
|
954
|
+
return {k: self._convert_to_native(v) for k, v in value.items()}
|
|
955
|
+
if hasattr(value, "model_dump"):
|
|
956
|
+
try:
|
|
957
|
+
dumped = value.model_dump(mode="python", exclude_unset=True)
|
|
958
|
+
return self._convert_to_native(dumped)
|
|
959
|
+
except Exception:
|
|
960
|
+
pass
|
|
961
|
+
if hasattr(value, "dict"):
|
|
962
|
+
try:
|
|
963
|
+
dumped = value.dict(exclude_none=True)
|
|
964
|
+
return self._convert_to_native(dumped)
|
|
965
|
+
except Exception:
|
|
966
|
+
pass
|
|
967
|
+
if hasattr(value, "to_dict"):
|
|
968
|
+
try:
|
|
969
|
+
dumped = value.to_dict()
|
|
970
|
+
return self._convert_to_native(dumped)
|
|
971
|
+
except Exception:
|
|
972
|
+
pass
|
|
973
|
+
if hasattr(value, "__dict__"):
|
|
974
|
+
try:
|
|
975
|
+
dumped = getattr(value, "__dict__")
|
|
976
|
+
return self._convert_to_native(dumped)
|
|
977
|
+
except Exception:
|
|
978
|
+
pass
|
|
979
|
+
return value
|
|
980
|
+
|
|
981
|
+
def _convert_arguments(
|
|
982
|
+
self, args: Optional[Dict[str, Any]]
|
|
983
|
+
) -> Optional[Dict[str, Any]]:
|
|
984
|
+
if args is None:
|
|
985
|
+
return None
|
|
986
|
+
if not isinstance(args, dict):
|
|
987
|
+
return args
|
|
988
|
+
return {k: self._convert_to_native(v) for k, v in args.items()}
|
|
989
|
+
|
|
990
|
+
def _pick_server_url(self, servers: Any) -> Optional[str]:
|
|
991
|
+
if not servers:
|
|
992
|
+
return None
|
|
993
|
+
|
|
994
|
+
if isinstance(servers, dict):
|
|
995
|
+
servers = [servers]
|
|
996
|
+
|
|
997
|
+
if not isinstance(servers, list):
|
|
998
|
+
return None
|
|
999
|
+
|
|
1000
|
+
for server in servers:
|
|
1001
|
+
if isinstance(server, str):
|
|
1002
|
+
return server
|
|
1003
|
+
if not isinstance(server, dict):
|
|
1004
|
+
continue
|
|
1005
|
+
url = server.get("url")
|
|
1006
|
+
if not url:
|
|
1007
|
+
continue
|
|
1008
|
+
variables = server.get("variables", {})
|
|
1009
|
+
if isinstance(variables, dict):
|
|
1010
|
+
for key, meta in variables.items():
|
|
1011
|
+
default = (
|
|
1012
|
+
meta.get("default") if isinstance(meta, dict) else None
|
|
1013
|
+
)
|
|
1014
|
+
if default is not None:
|
|
1015
|
+
url = url.replace(f"{{{key}}}", str(default))
|
|
1016
|
+
return url
|
|
1017
|
+
|
|
1018
|
+
return None
|
|
1019
|
+
|
|
1020
|
+
def _build_operations(
|
|
1021
|
+
self, schema: Dict[str, Any]
|
|
1022
|
+
) -> Dict[str, Dict[str, Any]]:
|
|
1023
|
+
operations: Dict[str, Dict[str, Any]] = {}
|
|
1024
|
+
paths = schema.get("paths") or {}
|
|
1025
|
+
for path, path_item in paths.items():
|
|
1026
|
+
if not isinstance(path_item, dict):
|
|
1027
|
+
continue
|
|
1028
|
+
path_parameters = path_item.get("parameters", [])
|
|
1029
|
+
path_servers = path_item.get("servers")
|
|
1030
|
+
for method, operation in path_item.items():
|
|
1031
|
+
if method.upper() not in self._SUPPORTED_METHODS:
|
|
1032
|
+
continue
|
|
1033
|
+
if not isinstance(operation, dict):
|
|
1034
|
+
continue
|
|
1035
|
+
operation_id = operation.get("operationId") or (
|
|
1036
|
+
f"{method.upper()} {path}"
|
|
1037
|
+
)
|
|
1038
|
+
parameters = []
|
|
1039
|
+
if isinstance(path_parameters, list):
|
|
1040
|
+
parameters.extend(path_parameters)
|
|
1041
|
+
operation_parameters = operation.get("parameters", [])
|
|
1042
|
+
if isinstance(operation_parameters, list):
|
|
1043
|
+
parameters.extend(operation_parameters)
|
|
1044
|
+
server_url = self._pick_server_url(
|
|
1045
|
+
operation.get("servers") or path_servers
|
|
1046
|
+
)
|
|
1047
|
+
operations[operation_id] = {
|
|
1048
|
+
"operationId": operation_id,
|
|
1049
|
+
"method": method.upper(),
|
|
1050
|
+
"path": path,
|
|
1051
|
+
"summary": operation.get("summary"),
|
|
1052
|
+
"description": operation.get("description"),
|
|
1053
|
+
"parameters": parameters,
|
|
1054
|
+
"path_parameters": [
|
|
1055
|
+
param.get("name")
|
|
1056
|
+
for param in parameters
|
|
1057
|
+
if isinstance(param, dict)
|
|
1058
|
+
and param.get("in") == "path"
|
|
1059
|
+
and param.get("name")
|
|
1060
|
+
],
|
|
1061
|
+
"request_body": operation.get("requestBody"),
|
|
1062
|
+
"responses": operation.get("responses"),
|
|
1063
|
+
"tags": operation.get("tags", []),
|
|
1064
|
+
"server_url": server_url,
|
|
1065
|
+
}
|
|
1066
|
+
return operations
|
|
1067
|
+
|
|
1068
|
+
def _prepare_request(
|
|
1069
|
+
self,
|
|
1070
|
+
name: str,
|
|
1071
|
+
arguments: Optional[Dict[str, Any]],
|
|
1072
|
+
config: Optional[Config],
|
|
1073
|
+
) -> Tuple[Dict[str, Any], float, bool]:
|
|
1074
|
+
if name not in self._operations:
|
|
1075
|
+
raise ValueError(f"Tool '{name}' not found in OpenAPI schema.")
|
|
1076
|
+
|
|
1077
|
+
operation = self._operations[name]
|
|
1078
|
+
args = deepcopy(arguments) if arguments else {}
|
|
1079
|
+
|
|
1080
|
+
combined_config = config or self._base_config
|
|
1081
|
+
headers: Dict[str, str] = {}
|
|
1082
|
+
timeout = self._default_timeout
|
|
1083
|
+
if combined_config:
|
|
1084
|
+
headers.update(combined_config.get_headers())
|
|
1085
|
+
_timeout = combined_config.get_timeout()
|
|
1086
|
+
if _timeout:
|
|
1087
|
+
timeout = _timeout
|
|
1088
|
+
|
|
1089
|
+
headers.update(self._default_headers)
|
|
1090
|
+
|
|
1091
|
+
user_headers = args.pop("headers", {})
|
|
1092
|
+
if isinstance(user_headers, dict):
|
|
1093
|
+
headers.update(user_headers)
|
|
1094
|
+
|
|
1095
|
+
timeout_override = args.pop("timeout", None)
|
|
1096
|
+
if isinstance(timeout_override, (int, float)):
|
|
1097
|
+
timeout = timeout_override
|
|
1098
|
+
|
|
1099
|
+
raise_for_status = bool(args.pop("raise_for_status", True))
|
|
1100
|
+
|
|
1101
|
+
path_params = self._extract_dict(args, ["path", "path_params"])
|
|
1102
|
+
query_params = self._merge_dicts(
|
|
1103
|
+
self._default_query_params,
|
|
1104
|
+
self._extract_dict(args, ["query", "query_params"]),
|
|
1105
|
+
)
|
|
1106
|
+
body = self._extract_body(args)
|
|
1107
|
+
files = args.pop("files", None)
|
|
1108
|
+
data = args.pop("data", None)
|
|
1109
|
+
|
|
1110
|
+
# Fill parameters defined in the schema.
|
|
1111
|
+
for param in operation.get("parameters", []):
|
|
1112
|
+
if not isinstance(param, dict):
|
|
1113
|
+
continue
|
|
1114
|
+
name = param.get("name", "")
|
|
1115
|
+
location = param.get("in")
|
|
1116
|
+
if not name or name not in args:
|
|
1117
|
+
continue
|
|
1118
|
+
value = args.pop(name)
|
|
1119
|
+
if location == "path":
|
|
1120
|
+
path_params[name] = value
|
|
1121
|
+
elif location == "query":
|
|
1122
|
+
query_params[name] = value
|
|
1123
|
+
elif location == "header":
|
|
1124
|
+
headers[name] = value
|
|
1125
|
+
|
|
1126
|
+
method = operation["method"]
|
|
1127
|
+
path = self._render_path(
|
|
1128
|
+
operation["path"], operation["path_parameters"], path_params
|
|
1129
|
+
)
|
|
1130
|
+
|
|
1131
|
+
base_url = (
|
|
1132
|
+
args.pop("base_url", None)
|
|
1133
|
+
or operation.get("server_url")
|
|
1134
|
+
or self._base_url
|
|
1135
|
+
)
|
|
1136
|
+
|
|
1137
|
+
if not base_url:
|
|
1138
|
+
raise ValueError(
|
|
1139
|
+
"Base URL is required to invoke an OpenAPI tool. Provide it "
|
|
1140
|
+
"via OpenAPI(..., base_url=...) or in arguments['base_url']."
|
|
1141
|
+
)
|
|
1142
|
+
|
|
1143
|
+
url = self._join_url(base_url, path)
|
|
1144
|
+
|
|
1145
|
+
if method in {"GET", "HEAD"} and args:
|
|
1146
|
+
if not isinstance(query_params, dict):
|
|
1147
|
+
query_params = {}
|
|
1148
|
+
for key, value in args.items():
|
|
1149
|
+
query_params[key] = value
|
|
1150
|
+
args.clear()
|
|
1151
|
+
elif args and body is None and data is None:
|
|
1152
|
+
body = args
|
|
1153
|
+
args = {}
|
|
1154
|
+
|
|
1155
|
+
request_kwargs: Dict[str, Any] = {
|
|
1156
|
+
"method": method,
|
|
1157
|
+
"url": url,
|
|
1158
|
+
"headers": headers,
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
if query_params:
|
|
1162
|
+
request_kwargs["params"] = query_params
|
|
1163
|
+
if files is not None:
|
|
1164
|
+
request_kwargs["files"] = files
|
|
1165
|
+
if data is not None:
|
|
1166
|
+
request_kwargs["data"] = data
|
|
1167
|
+
if body is not None and method not in {"GET", "HEAD"}:
|
|
1168
|
+
request_kwargs["json"] = body
|
|
1169
|
+
|
|
1170
|
+
if args:
|
|
1171
|
+
logger.debug(
|
|
1172
|
+
"Unused arguments when invoking OpenAPI tool '%s': %s",
|
|
1173
|
+
name,
|
|
1174
|
+
args,
|
|
1175
|
+
)
|
|
1176
|
+
|
|
1177
|
+
return request_kwargs, timeout, raise_for_status
|
|
1178
|
+
|
|
1179
|
+
def _format_response(self, response: httpx.Response) -> Dict[str, Any]:
|
|
1180
|
+
try:
|
|
1181
|
+
body = response.json()
|
|
1182
|
+
except ValueError:
|
|
1183
|
+
body = response.text
|
|
1184
|
+
|
|
1185
|
+
return {
|
|
1186
|
+
"status_code": response.status_code,
|
|
1187
|
+
"headers": dict(response.headers),
|
|
1188
|
+
"body": body,
|
|
1189
|
+
"raw_body": response.text,
|
|
1190
|
+
"url": str(response.request.url),
|
|
1191
|
+
"method": response.request.method,
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
def _extract_dict(
|
|
1195
|
+
self, source: Dict[str, Any], keys: List[str]
|
|
1196
|
+
) -> Dict[str, Any]:
|
|
1197
|
+
for key in keys:
|
|
1198
|
+
value = source.pop(key, None)
|
|
1199
|
+
if value is None:
|
|
1200
|
+
continue
|
|
1201
|
+
if isinstance(value, dict):
|
|
1202
|
+
return value
|
|
1203
|
+
logger.warning(
|
|
1204
|
+
"Expected dictionary for argument '%s', got %s. Ignoring.",
|
|
1205
|
+
key,
|
|
1206
|
+
type(value).__name__,
|
|
1207
|
+
)
|
|
1208
|
+
return {}
|
|
1209
|
+
|
|
1210
|
+
def _extract_body(self, source: Dict[str, Any]) -> Optional[Any]:
|
|
1211
|
+
if "json" in source:
|
|
1212
|
+
return source.pop("json")
|
|
1213
|
+
if "body" in source:
|
|
1214
|
+
return source.pop("body")
|
|
1215
|
+
if "payload" in source:
|
|
1216
|
+
return source.pop("payload")
|
|
1217
|
+
return None
|
|
1218
|
+
|
|
1219
|
+
def _merge_dicts(
|
|
1220
|
+
self, base: Optional[Dict[str, Any]], override: Optional[Dict[str, Any]]
|
|
1221
|
+
) -> Dict[str, Any]:
|
|
1222
|
+
merged: Dict[str, Any] = {}
|
|
1223
|
+
if isinstance(base, dict):
|
|
1224
|
+
merged.update(base)
|
|
1225
|
+
if isinstance(override, dict):
|
|
1226
|
+
merged.update(override)
|
|
1227
|
+
return merged
|
|
1228
|
+
|
|
1229
|
+
def _render_path(
|
|
1230
|
+
self, path: str, expected_params: List[str], path_params: Dict[str, Any]
|
|
1231
|
+
) -> str:
|
|
1232
|
+
rendered = path
|
|
1233
|
+
missing = []
|
|
1234
|
+
for name in expected_params:
|
|
1235
|
+
if name not in path_params:
|
|
1236
|
+
missing.append(name)
|
|
1237
|
+
continue
|
|
1238
|
+
rendered = rendered.replace(f"{{{name}}}", str(path_params[name]))
|
|
1239
|
+
|
|
1240
|
+
if missing:
|
|
1241
|
+
raise ValueError(
|
|
1242
|
+
f"Missing path parameters for {path}: {', '.join(missing)}"
|
|
1243
|
+
)
|
|
1244
|
+
return rendered
|
|
1245
|
+
|
|
1246
|
+
def _join_url(self, base_url: str, path: str) -> str:
|
|
1247
|
+
if not base_url:
|
|
1248
|
+
raise ValueError("Base URL cannot be empty.")
|
|
1249
|
+
if not path:
|
|
1250
|
+
return base_url
|
|
1251
|
+
return f"{base_url.rstrip('/')}/{path.lstrip('/')}"
|