agentrun-inner-test 0.0.62__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.

Potentially problematic release.


This version of agentrun-inner-test might be problematic. Click here for more details.

Files changed (154) hide show
  1. agentrun/__init__.py +358 -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 +13 -0
  24. agentrun/integration/agentscope/adapter.py +17 -0
  25. agentrun/integration/agentscope/builtin.py +88 -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 +18 -0
  30. agentrun/integration/builtin/knowledgebase.py +137 -0
  31. agentrun/integration/builtin/model.py +93 -0
  32. agentrun/integration/builtin/sandbox.py +1234 -0
  33. agentrun/integration/builtin/toolset.py +47 -0
  34. agentrun/integration/crewai/__init__.py +13 -0
  35. agentrun/integration/crewai/adapter.py +9 -0
  36. agentrun/integration/crewai/builtin.py +88 -0
  37. agentrun/integration/crewai/model_adapter.py +31 -0
  38. agentrun/integration/crewai/tool_adapter.py +26 -0
  39. agentrun/integration/google_adk/__init__.py +13 -0
  40. agentrun/integration/google_adk/adapter.py +15 -0
  41. agentrun/integration/google_adk/builtin.py +88 -0
  42. agentrun/integration/google_adk/message_adapter.py +144 -0
  43. agentrun/integration/google_adk/model_adapter.py +46 -0
  44. agentrun/integration/google_adk/tool_adapter.py +235 -0
  45. agentrun/integration/langchain/__init__.py +31 -0
  46. agentrun/integration/langchain/adapter.py +15 -0
  47. agentrun/integration/langchain/builtin.py +94 -0
  48. agentrun/integration/langchain/message_adapter.py +141 -0
  49. agentrun/integration/langchain/model_adapter.py +37 -0
  50. agentrun/integration/langchain/tool_adapter.py +50 -0
  51. agentrun/integration/langgraph/__init__.py +36 -0
  52. agentrun/integration/langgraph/adapter.py +20 -0
  53. agentrun/integration/langgraph/agent_converter.py +1073 -0
  54. agentrun/integration/langgraph/builtin.py +88 -0
  55. agentrun/integration/pydantic_ai/__init__.py +13 -0
  56. agentrun/integration/pydantic_ai/adapter.py +13 -0
  57. agentrun/integration/pydantic_ai/builtin.py +88 -0
  58. agentrun/integration/pydantic_ai/model_adapter.py +44 -0
  59. agentrun/integration/pydantic_ai/tool_adapter.py +19 -0
  60. agentrun/integration/utils/__init__.py +112 -0
  61. agentrun/integration/utils/adapter.py +560 -0
  62. agentrun/integration/utils/canonical.py +164 -0
  63. agentrun/integration/utils/converter.py +134 -0
  64. agentrun/integration/utils/model.py +110 -0
  65. agentrun/integration/utils/tool.py +1759 -0
  66. agentrun/knowledgebase/__client_async_template.py +173 -0
  67. agentrun/knowledgebase/__init__.py +53 -0
  68. agentrun/knowledgebase/__knowledgebase_async_template.py +438 -0
  69. agentrun/knowledgebase/api/__data_async_template.py +414 -0
  70. agentrun/knowledgebase/api/__init__.py +19 -0
  71. agentrun/knowledgebase/api/control.py +606 -0
  72. agentrun/knowledgebase/api/data.py +624 -0
  73. agentrun/knowledgebase/client.py +311 -0
  74. agentrun/knowledgebase/knowledgebase.py +748 -0
  75. agentrun/knowledgebase/model.py +270 -0
  76. agentrun/memory_collection/__client_async_template.py +178 -0
  77. agentrun/memory_collection/__init__.py +37 -0
  78. agentrun/memory_collection/__memory_collection_async_template.py +457 -0
  79. agentrun/memory_collection/api/__init__.py +5 -0
  80. agentrun/memory_collection/api/control.py +610 -0
  81. agentrun/memory_collection/client.py +323 -0
  82. agentrun/memory_collection/memory_collection.py +844 -0
  83. agentrun/memory_collection/model.py +162 -0
  84. agentrun/model/__client_async_template.py +357 -0
  85. agentrun/model/__init__.py +57 -0
  86. agentrun/model/__model_proxy_async_template.py +270 -0
  87. agentrun/model/__model_service_async_template.py +267 -0
  88. agentrun/model/api/__init__.py +6 -0
  89. agentrun/model/api/control.py +1173 -0
  90. agentrun/model/api/data.py +196 -0
  91. agentrun/model/client.py +674 -0
  92. agentrun/model/model.py +235 -0
  93. agentrun/model/model_proxy.py +439 -0
  94. agentrun/model/model_service.py +438 -0
  95. agentrun/sandbox/__aio_sandbox_async_template.py +523 -0
  96. agentrun/sandbox/__browser_sandbox_async_template.py +110 -0
  97. agentrun/sandbox/__client_async_template.py +491 -0
  98. agentrun/sandbox/__code_interpreter_sandbox_async_template.py +463 -0
  99. agentrun/sandbox/__init__.py +69 -0
  100. agentrun/sandbox/__sandbox_async_template.py +463 -0
  101. agentrun/sandbox/__template_async_template.py +152 -0
  102. agentrun/sandbox/aio_sandbox.py +912 -0
  103. agentrun/sandbox/api/__aio_data_async_template.py +335 -0
  104. agentrun/sandbox/api/__browser_data_async_template.py +140 -0
  105. agentrun/sandbox/api/__code_interpreter_data_async_template.py +206 -0
  106. agentrun/sandbox/api/__init__.py +19 -0
  107. agentrun/sandbox/api/__sandbox_data_async_template.py +107 -0
  108. agentrun/sandbox/api/aio_data.py +551 -0
  109. agentrun/sandbox/api/browser_data.py +172 -0
  110. agentrun/sandbox/api/code_interpreter_data.py +396 -0
  111. agentrun/sandbox/api/control.py +1051 -0
  112. agentrun/sandbox/api/playwright_async.py +492 -0
  113. agentrun/sandbox/api/playwright_sync.py +492 -0
  114. agentrun/sandbox/api/sandbox_data.py +154 -0
  115. agentrun/sandbox/browser_sandbox.py +185 -0
  116. agentrun/sandbox/client.py +925 -0
  117. agentrun/sandbox/code_interpreter_sandbox.py +823 -0
  118. agentrun/sandbox/model.py +384 -0
  119. agentrun/sandbox/sandbox.py +848 -0
  120. agentrun/sandbox/template.py +217 -0
  121. agentrun/server/__init__.py +191 -0
  122. agentrun/server/agui_normalizer.py +180 -0
  123. agentrun/server/agui_protocol.py +797 -0
  124. agentrun/server/invoker.py +309 -0
  125. agentrun/server/model.py +427 -0
  126. agentrun/server/openai_protocol.py +535 -0
  127. agentrun/server/protocol.py +140 -0
  128. agentrun/server/server.py +208 -0
  129. agentrun/toolset/__client_async_template.py +62 -0
  130. agentrun/toolset/__init__.py +51 -0
  131. agentrun/toolset/__toolset_async_template.py +204 -0
  132. agentrun/toolset/api/__init__.py +17 -0
  133. agentrun/toolset/api/control.py +262 -0
  134. agentrun/toolset/api/mcp.py +100 -0
  135. agentrun/toolset/api/openapi.py +1251 -0
  136. agentrun/toolset/client.py +102 -0
  137. agentrun/toolset/model.py +321 -0
  138. agentrun/toolset/toolset.py +271 -0
  139. agentrun/utils/__data_api_async_template.py +721 -0
  140. agentrun/utils/__init__.py +5 -0
  141. agentrun/utils/__resource_async_template.py +158 -0
  142. agentrun/utils/config.py +270 -0
  143. agentrun/utils/control_api.py +105 -0
  144. agentrun/utils/data_api.py +1121 -0
  145. agentrun/utils/exception.py +151 -0
  146. agentrun/utils/helper.py +108 -0
  147. agentrun/utils/log.py +77 -0
  148. agentrun/utils/model.py +168 -0
  149. agentrun/utils/resource.py +291 -0
  150. agentrun_inner_test-0.0.62.dist-info/METADATA +265 -0
  151. agentrun_inner_test-0.0.62.dist-info/RECORD +154 -0
  152. agentrun_inner_test-0.0.62.dist-info/WHEEL +5 -0
  153. agentrun_inner_test-0.0.62.dist-info/licenses/LICENSE +201 -0
  154. agentrun_inner_test-0.0.62.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('/')}"