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