alita-sdk 0.3.257__py3-none-any.whl → 0.3.562__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 (278) hide show
  1. alita_sdk/cli/__init__.py +10 -0
  2. alita_sdk/cli/__main__.py +17 -0
  3. alita_sdk/cli/agent/__init__.py +5 -0
  4. alita_sdk/cli/agent/default.py +258 -0
  5. alita_sdk/cli/agent_executor.py +155 -0
  6. alita_sdk/cli/agent_loader.py +215 -0
  7. alita_sdk/cli/agent_ui.py +228 -0
  8. alita_sdk/cli/agents.py +3601 -0
  9. alita_sdk/cli/callbacks.py +647 -0
  10. alita_sdk/cli/cli.py +168 -0
  11. alita_sdk/cli/config.py +306 -0
  12. alita_sdk/cli/context/__init__.py +30 -0
  13. alita_sdk/cli/context/cleanup.py +198 -0
  14. alita_sdk/cli/context/manager.py +731 -0
  15. alita_sdk/cli/context/message.py +285 -0
  16. alita_sdk/cli/context/strategies.py +289 -0
  17. alita_sdk/cli/context/token_estimation.py +127 -0
  18. alita_sdk/cli/formatting.py +182 -0
  19. alita_sdk/cli/input_handler.py +419 -0
  20. alita_sdk/cli/inventory.py +1073 -0
  21. alita_sdk/cli/mcp_loader.py +315 -0
  22. alita_sdk/cli/toolkit.py +327 -0
  23. alita_sdk/cli/toolkit_loader.py +85 -0
  24. alita_sdk/cli/tools/__init__.py +43 -0
  25. alita_sdk/cli/tools/approval.py +224 -0
  26. alita_sdk/cli/tools/filesystem.py +1751 -0
  27. alita_sdk/cli/tools/planning.py +389 -0
  28. alita_sdk/cli/tools/terminal.py +414 -0
  29. alita_sdk/community/__init__.py +72 -12
  30. alita_sdk/community/inventory/__init__.py +236 -0
  31. alita_sdk/community/inventory/config.py +257 -0
  32. alita_sdk/community/inventory/enrichment.py +2137 -0
  33. alita_sdk/community/inventory/extractors.py +1469 -0
  34. alita_sdk/community/inventory/ingestion.py +3172 -0
  35. alita_sdk/community/inventory/knowledge_graph.py +1457 -0
  36. alita_sdk/community/inventory/parsers/__init__.py +218 -0
  37. alita_sdk/community/inventory/parsers/base.py +295 -0
  38. alita_sdk/community/inventory/parsers/csharp_parser.py +907 -0
  39. alita_sdk/community/inventory/parsers/go_parser.py +851 -0
  40. alita_sdk/community/inventory/parsers/html_parser.py +389 -0
  41. alita_sdk/community/inventory/parsers/java_parser.py +593 -0
  42. alita_sdk/community/inventory/parsers/javascript_parser.py +629 -0
  43. alita_sdk/community/inventory/parsers/kotlin_parser.py +768 -0
  44. alita_sdk/community/inventory/parsers/markdown_parser.py +362 -0
  45. alita_sdk/community/inventory/parsers/python_parser.py +604 -0
  46. alita_sdk/community/inventory/parsers/rust_parser.py +858 -0
  47. alita_sdk/community/inventory/parsers/swift_parser.py +832 -0
  48. alita_sdk/community/inventory/parsers/text_parser.py +322 -0
  49. alita_sdk/community/inventory/parsers/yaml_parser.py +370 -0
  50. alita_sdk/community/inventory/patterns/__init__.py +61 -0
  51. alita_sdk/community/inventory/patterns/ast_adapter.py +380 -0
  52. alita_sdk/community/inventory/patterns/loader.py +348 -0
  53. alita_sdk/community/inventory/patterns/registry.py +198 -0
  54. alita_sdk/community/inventory/presets.py +535 -0
  55. alita_sdk/community/inventory/retrieval.py +1403 -0
  56. alita_sdk/community/inventory/toolkit.py +173 -0
  57. alita_sdk/community/inventory/toolkit_utils.py +176 -0
  58. alita_sdk/community/inventory/visualize.py +1370 -0
  59. alita_sdk/configurations/__init__.py +11 -0
  60. alita_sdk/configurations/ado.py +148 -2
  61. alita_sdk/configurations/azure_search.py +1 -1
  62. alita_sdk/configurations/bigquery.py +1 -1
  63. alita_sdk/configurations/bitbucket.py +94 -2
  64. alita_sdk/configurations/browser.py +18 -0
  65. alita_sdk/configurations/carrier.py +19 -0
  66. alita_sdk/configurations/confluence.py +130 -1
  67. alita_sdk/configurations/delta_lake.py +1 -1
  68. alita_sdk/configurations/figma.py +76 -5
  69. alita_sdk/configurations/github.py +65 -1
  70. alita_sdk/configurations/gitlab.py +81 -0
  71. alita_sdk/configurations/google_places.py +17 -0
  72. alita_sdk/configurations/jira.py +103 -0
  73. alita_sdk/configurations/openapi.py +111 -0
  74. alita_sdk/configurations/postman.py +1 -1
  75. alita_sdk/configurations/qtest.py +72 -3
  76. alita_sdk/configurations/report_portal.py +115 -0
  77. alita_sdk/configurations/salesforce.py +19 -0
  78. alita_sdk/configurations/service_now.py +1 -12
  79. alita_sdk/configurations/sharepoint.py +167 -0
  80. alita_sdk/configurations/sonar.py +18 -0
  81. alita_sdk/configurations/sql.py +20 -0
  82. alita_sdk/configurations/testio.py +101 -0
  83. alita_sdk/configurations/testrail.py +88 -0
  84. alita_sdk/configurations/xray.py +94 -1
  85. alita_sdk/configurations/zephyr_enterprise.py +94 -1
  86. alita_sdk/configurations/zephyr_essential.py +95 -0
  87. alita_sdk/runtime/clients/artifact.py +21 -4
  88. alita_sdk/runtime/clients/client.py +458 -67
  89. alita_sdk/runtime/clients/mcp_discovery.py +342 -0
  90. alita_sdk/runtime/clients/mcp_manager.py +262 -0
  91. alita_sdk/runtime/clients/sandbox_client.py +352 -0
  92. alita_sdk/runtime/langchain/_constants_bkup.py +1318 -0
  93. alita_sdk/runtime/langchain/assistant.py +183 -43
  94. alita_sdk/runtime/langchain/constants.py +647 -1
  95. alita_sdk/runtime/langchain/document_loaders/AlitaDocxMammothLoader.py +315 -3
  96. alita_sdk/runtime/langchain/document_loaders/AlitaExcelLoader.py +209 -31
  97. alita_sdk/runtime/langchain/document_loaders/AlitaImageLoader.py +1 -1
  98. alita_sdk/runtime/langchain/document_loaders/AlitaJSONLinesLoader.py +77 -0
  99. alita_sdk/runtime/langchain/document_loaders/AlitaJSONLoader.py +10 -3
  100. alita_sdk/runtime/langchain/document_loaders/AlitaMarkdownLoader.py +66 -0
  101. alita_sdk/runtime/langchain/document_loaders/AlitaPDFLoader.py +79 -10
  102. alita_sdk/runtime/langchain/document_loaders/AlitaPowerPointLoader.py +52 -15
  103. alita_sdk/runtime/langchain/document_loaders/AlitaPythonLoader.py +9 -0
  104. alita_sdk/runtime/langchain/document_loaders/AlitaTableLoader.py +1 -4
  105. alita_sdk/runtime/langchain/document_loaders/AlitaTextLoader.py +15 -2
  106. alita_sdk/runtime/langchain/document_loaders/ImageParser.py +30 -0
  107. alita_sdk/runtime/langchain/document_loaders/constants.py +189 -41
  108. alita_sdk/runtime/langchain/interfaces/llm_processor.py +4 -2
  109. alita_sdk/runtime/langchain/langraph_agent.py +407 -92
  110. alita_sdk/runtime/langchain/utils.py +102 -8
  111. alita_sdk/runtime/llms/preloaded.py +2 -6
  112. alita_sdk/runtime/models/mcp_models.py +61 -0
  113. alita_sdk/runtime/skills/__init__.py +91 -0
  114. alita_sdk/runtime/skills/callbacks.py +498 -0
  115. alita_sdk/runtime/skills/discovery.py +540 -0
  116. alita_sdk/runtime/skills/executor.py +610 -0
  117. alita_sdk/runtime/skills/input_builder.py +371 -0
  118. alita_sdk/runtime/skills/models.py +330 -0
  119. alita_sdk/runtime/skills/registry.py +355 -0
  120. alita_sdk/runtime/skills/skill_runner.py +330 -0
  121. alita_sdk/runtime/toolkits/__init__.py +28 -0
  122. alita_sdk/runtime/toolkits/application.py +14 -4
  123. alita_sdk/runtime/toolkits/artifact.py +24 -9
  124. alita_sdk/runtime/toolkits/datasource.py +13 -6
  125. alita_sdk/runtime/toolkits/mcp.py +780 -0
  126. alita_sdk/runtime/toolkits/planning.py +178 -0
  127. alita_sdk/runtime/toolkits/skill_router.py +238 -0
  128. alita_sdk/runtime/toolkits/subgraph.py +11 -6
  129. alita_sdk/runtime/toolkits/tools.py +314 -70
  130. alita_sdk/runtime/toolkits/vectorstore.py +11 -5
  131. alita_sdk/runtime/tools/__init__.py +24 -0
  132. alita_sdk/runtime/tools/application.py +16 -4
  133. alita_sdk/runtime/tools/artifact.py +367 -33
  134. alita_sdk/runtime/tools/data_analysis.py +183 -0
  135. alita_sdk/runtime/tools/function.py +100 -4
  136. alita_sdk/runtime/tools/graph.py +81 -0
  137. alita_sdk/runtime/tools/image_generation.py +218 -0
  138. alita_sdk/runtime/tools/llm.py +1013 -177
  139. alita_sdk/runtime/tools/loop.py +3 -1
  140. alita_sdk/runtime/tools/loop_output.py +3 -1
  141. alita_sdk/runtime/tools/mcp_inspect_tool.py +284 -0
  142. alita_sdk/runtime/tools/mcp_remote_tool.py +181 -0
  143. alita_sdk/runtime/tools/mcp_server_tool.py +3 -1
  144. alita_sdk/runtime/tools/planning/__init__.py +36 -0
  145. alita_sdk/runtime/tools/planning/models.py +246 -0
  146. alita_sdk/runtime/tools/planning/wrapper.py +607 -0
  147. alita_sdk/runtime/tools/router.py +2 -1
  148. alita_sdk/runtime/tools/sandbox.py +375 -0
  149. alita_sdk/runtime/tools/skill_router.py +776 -0
  150. alita_sdk/runtime/tools/tool.py +3 -1
  151. alita_sdk/runtime/tools/vectorstore.py +69 -65
  152. alita_sdk/runtime/tools/vectorstore_base.py +163 -90
  153. alita_sdk/runtime/utils/AlitaCallback.py +137 -21
  154. alita_sdk/runtime/utils/mcp_client.py +492 -0
  155. alita_sdk/runtime/utils/mcp_oauth.py +361 -0
  156. alita_sdk/runtime/utils/mcp_sse_client.py +434 -0
  157. alita_sdk/runtime/utils/mcp_tools_discovery.py +124 -0
  158. alita_sdk/runtime/utils/streamlit.py +41 -14
  159. alita_sdk/runtime/utils/toolkit_utils.py +28 -9
  160. alita_sdk/runtime/utils/utils.py +48 -0
  161. alita_sdk/tools/__init__.py +135 -37
  162. alita_sdk/tools/ado/__init__.py +2 -2
  163. alita_sdk/tools/ado/repos/__init__.py +15 -19
  164. alita_sdk/tools/ado/repos/repos_wrapper.py +12 -20
  165. alita_sdk/tools/ado/test_plan/__init__.py +26 -8
  166. alita_sdk/tools/ado/test_plan/test_plan_wrapper.py +56 -28
  167. alita_sdk/tools/ado/wiki/__init__.py +27 -12
  168. alita_sdk/tools/ado/wiki/ado_wrapper.py +114 -40
  169. alita_sdk/tools/ado/work_item/__init__.py +27 -12
  170. alita_sdk/tools/ado/work_item/ado_wrapper.py +95 -11
  171. alita_sdk/tools/advanced_jira_mining/__init__.py +12 -8
  172. alita_sdk/tools/aws/delta_lake/__init__.py +14 -11
  173. alita_sdk/tools/aws/delta_lake/tool.py +5 -1
  174. alita_sdk/tools/azure_ai/search/__init__.py +13 -8
  175. alita_sdk/tools/base/tool.py +5 -1
  176. alita_sdk/tools/base_indexer_toolkit.py +454 -110
  177. alita_sdk/tools/bitbucket/__init__.py +27 -19
  178. alita_sdk/tools/bitbucket/api_wrapper.py +285 -27
  179. alita_sdk/tools/bitbucket/cloud_api_wrapper.py +5 -5
  180. alita_sdk/tools/browser/__init__.py +41 -16
  181. alita_sdk/tools/browser/crawler.py +3 -1
  182. alita_sdk/tools/browser/utils.py +15 -6
  183. alita_sdk/tools/carrier/__init__.py +18 -17
  184. alita_sdk/tools/carrier/backend_reports_tool.py +8 -4
  185. alita_sdk/tools/carrier/excel_reporter.py +8 -4
  186. alita_sdk/tools/chunkers/__init__.py +3 -1
  187. alita_sdk/tools/chunkers/code/codeparser.py +1 -1
  188. alita_sdk/tools/chunkers/sematic/json_chunker.py +2 -1
  189. alita_sdk/tools/chunkers/sematic/markdown_chunker.py +97 -6
  190. alita_sdk/tools/chunkers/sematic/proposal_chunker.py +1 -1
  191. alita_sdk/tools/chunkers/universal_chunker.py +270 -0
  192. alita_sdk/tools/cloud/aws/__init__.py +11 -7
  193. alita_sdk/tools/cloud/azure/__init__.py +11 -7
  194. alita_sdk/tools/cloud/gcp/__init__.py +11 -7
  195. alita_sdk/tools/cloud/k8s/__init__.py +11 -7
  196. alita_sdk/tools/code/linter/__init__.py +9 -8
  197. alita_sdk/tools/code/loaders/codesearcher.py +3 -2
  198. alita_sdk/tools/code/sonar/__init__.py +20 -13
  199. alita_sdk/tools/code_indexer_toolkit.py +199 -0
  200. alita_sdk/tools/confluence/__init__.py +21 -14
  201. alita_sdk/tools/confluence/api_wrapper.py +197 -58
  202. alita_sdk/tools/confluence/loader.py +14 -2
  203. alita_sdk/tools/custom_open_api/__init__.py +11 -5
  204. alita_sdk/tools/elastic/__init__.py +10 -8
  205. alita_sdk/tools/elitea_base.py +546 -64
  206. alita_sdk/tools/figma/__init__.py +11 -8
  207. alita_sdk/tools/figma/api_wrapper.py +352 -153
  208. alita_sdk/tools/github/__init__.py +17 -17
  209. alita_sdk/tools/github/api_wrapper.py +9 -26
  210. alita_sdk/tools/github/github_client.py +81 -12
  211. alita_sdk/tools/github/schemas.py +2 -1
  212. alita_sdk/tools/github/tool.py +5 -1
  213. alita_sdk/tools/gitlab/__init__.py +18 -13
  214. alita_sdk/tools/gitlab/api_wrapper.py +224 -80
  215. alita_sdk/tools/gitlab_org/__init__.py +13 -10
  216. alita_sdk/tools/google/bigquery/__init__.py +13 -13
  217. alita_sdk/tools/google/bigquery/tool.py +5 -1
  218. alita_sdk/tools/google_places/__init__.py +20 -11
  219. alita_sdk/tools/jira/__init__.py +21 -11
  220. alita_sdk/tools/jira/api_wrapper.py +315 -168
  221. alita_sdk/tools/keycloak/__init__.py +10 -8
  222. alita_sdk/tools/localgit/__init__.py +8 -3
  223. alita_sdk/tools/localgit/local_git.py +62 -54
  224. alita_sdk/tools/localgit/tool.py +5 -1
  225. alita_sdk/tools/memory/__init__.py +38 -14
  226. alita_sdk/tools/non_code_indexer_toolkit.py +7 -2
  227. alita_sdk/tools/ocr/__init__.py +10 -8
  228. alita_sdk/tools/openapi/__init__.py +281 -108
  229. alita_sdk/tools/openapi/api_wrapper.py +883 -0
  230. alita_sdk/tools/openapi/tool.py +20 -0
  231. alita_sdk/tools/pandas/__init__.py +18 -11
  232. alita_sdk/tools/pandas/api_wrapper.py +40 -45
  233. alita_sdk/tools/pandas/dataframe/generator/base.py +3 -1
  234. alita_sdk/tools/postman/__init__.py +10 -11
  235. alita_sdk/tools/postman/api_wrapper.py +19 -8
  236. alita_sdk/tools/postman/postman_analysis.py +8 -1
  237. alita_sdk/tools/pptx/__init__.py +10 -10
  238. alita_sdk/tools/qtest/__init__.py +21 -14
  239. alita_sdk/tools/qtest/api_wrapper.py +1784 -88
  240. alita_sdk/tools/rally/__init__.py +12 -10
  241. alita_sdk/tools/report_portal/__init__.py +22 -16
  242. alita_sdk/tools/salesforce/__init__.py +21 -16
  243. alita_sdk/tools/servicenow/__init__.py +20 -16
  244. alita_sdk/tools/servicenow/api_wrapper.py +1 -1
  245. alita_sdk/tools/sharepoint/__init__.py +16 -14
  246. alita_sdk/tools/sharepoint/api_wrapper.py +179 -39
  247. alita_sdk/tools/sharepoint/authorization_helper.py +191 -1
  248. alita_sdk/tools/sharepoint/utils.py +8 -2
  249. alita_sdk/tools/slack/__init__.py +11 -7
  250. alita_sdk/tools/sql/__init__.py +21 -19
  251. alita_sdk/tools/sql/api_wrapper.py +71 -23
  252. alita_sdk/tools/testio/__init__.py +20 -13
  253. alita_sdk/tools/testrail/__init__.py +12 -11
  254. alita_sdk/tools/testrail/api_wrapper.py +214 -46
  255. alita_sdk/tools/utils/__init__.py +28 -4
  256. alita_sdk/tools/utils/content_parser.py +182 -62
  257. alita_sdk/tools/utils/text_operations.py +254 -0
  258. alita_sdk/tools/vector_adapters/VectorStoreAdapter.py +83 -27
  259. alita_sdk/tools/xray/__init__.py +17 -14
  260. alita_sdk/tools/xray/api_wrapper.py +58 -113
  261. alita_sdk/tools/yagmail/__init__.py +8 -3
  262. alita_sdk/tools/zephyr/__init__.py +11 -7
  263. alita_sdk/tools/zephyr_enterprise/__init__.py +15 -9
  264. alita_sdk/tools/zephyr_enterprise/api_wrapper.py +30 -15
  265. alita_sdk/tools/zephyr_essential/__init__.py +15 -10
  266. alita_sdk/tools/zephyr_essential/api_wrapper.py +297 -54
  267. alita_sdk/tools/zephyr_essential/client.py +6 -4
  268. alita_sdk/tools/zephyr_scale/__init__.py +12 -8
  269. alita_sdk/tools/zephyr_scale/api_wrapper.py +39 -31
  270. alita_sdk/tools/zephyr_squad/__init__.py +11 -7
  271. {alita_sdk-0.3.257.dist-info → alita_sdk-0.3.562.dist-info}/METADATA +184 -37
  272. alita_sdk-0.3.562.dist-info/RECORD +450 -0
  273. alita_sdk-0.3.562.dist-info/entry_points.txt +2 -0
  274. alita_sdk/tools/bitbucket/tools.py +0 -304
  275. alita_sdk-0.3.257.dist-info/RECORD +0 -343
  276. {alita_sdk-0.3.257.dist-info → alita_sdk-0.3.562.dist-info}/WHEEL +0 -0
  277. {alita_sdk-0.3.257.dist-info → alita_sdk-0.3.562.dist-info}/licenses/LICENSE +0 -0
  278. {alita_sdk-0.3.257.dist-info → alita_sdk-0.3.562.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,883 @@
1
+ import json
2
+ import logging
3
+ import re
4
+ from urllib.parse import urlencode
5
+ from typing import Any, Callable, Optional
6
+ import copy
7
+
8
+ import yaml
9
+ from langchain_core.tools import ToolException
10
+ from pydantic import BaseModel, Field, PrivateAttr, create_model
11
+ from requests_openapi import Client, Operation
12
+
13
+ from ..elitea_base import BaseToolApiWrapper
14
+ from ..utils import clean_string
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def _raise_openapi_tool_exception(
20
+ *,
21
+ code: str,
22
+ message: str,
23
+ operation_id: Optional[str] = None,
24
+ url: Optional[str] = None,
25
+ retryable: Optional[bool] = None,
26
+ missing_inputs: Optional[list[str]] = None,
27
+ http_status: Optional[int] = None,
28
+ http_body_preview: Optional[str] = None,
29
+ details: Optional[dict[str, Any]] = None,
30
+ ) -> None:
31
+ payload: dict[str, Any] = {
32
+ "tool": "openapi",
33
+ "code": code,
34
+ "message": message,
35
+ }
36
+ if operation_id:
37
+ payload["operation_id"] = operation_id
38
+ if url:
39
+ payload["url"] = url
40
+ if retryable is not None:
41
+ payload["retryable"] = bool(retryable)
42
+ if missing_inputs:
43
+ payload["missing_inputs"] = list(missing_inputs)
44
+ if http_status is not None:
45
+ payload["http_status"] = int(http_status)
46
+ if http_body_preview:
47
+ payload["http_body_preview"] = str(http_body_preview)
48
+ if details:
49
+ payload["details"] = details
50
+
51
+ try:
52
+ details_json = json.dumps(payload, ensure_ascii=False, indent=2)
53
+ except Exception:
54
+ details_json = str(payload)
55
+
56
+ raise ToolException(f"{message}\n\nToolError:\n{details_json}")
57
+
58
+
59
+ def _truncate(text: Any, max_len: int) -> str:
60
+ if text is None:
61
+ return ""
62
+ s = str(text)
63
+ if len(s) <= max_len:
64
+ return s
65
+ return s[:max_len] + "…"
66
+
67
+
68
+ def _is_retryable_http_status(status_code: Optional[int]) -> bool:
69
+ if status_code is None:
70
+ return False
71
+ return int(status_code) in (408, 425, 429, 500, 502, 503, 504)
72
+
73
+
74
+ def _get_base_url_from_spec(spec: dict) -> str:
75
+ servers = spec.get("servers") if isinstance(spec, dict) else None
76
+ if isinstance(servers, list) and servers:
77
+ first = servers[0]
78
+ if isinstance(first, dict) and isinstance(first.get("url"), str):
79
+ return first["url"].strip()
80
+ return ""
81
+
82
+
83
+ def _is_absolute_url(url: str) -> bool:
84
+ return isinstance(url, str) and (url.startswith("http://") or url.startswith("https://"))
85
+
86
+
87
+ def _apply_base_url_override(spec: dict, base_url_override: str) -> dict:
88
+ """Normalize server URL when OpenAPI spec uses relative servers.
89
+
90
+ Some public specs (including Petstore) use relative server URLs like "/api/v3".
91
+ To execute requests against a real host, we can provide a base URL override like
92
+ "https://petstore3.swagger.io" and convert the first server URL to an absolute URL.
93
+ """
94
+ if not isinstance(spec, dict):
95
+ return spec
96
+ if not isinstance(base_url_override, str) or not base_url_override.strip():
97
+ return spec
98
+ base_url_override = base_url_override.strip().rstrip("/")
99
+
100
+ servers = spec.get("servers")
101
+ if not isinstance(servers, list) or not servers:
102
+ spec["servers"] = [{"url": base_url_override}]
103
+ return spec
104
+
105
+ first = servers[0]
106
+ if not isinstance(first, dict):
107
+ return spec
108
+ server_url = first.get("url")
109
+ if not isinstance(server_url, str):
110
+ return spec
111
+ server_url = server_url.strip()
112
+ if not server_url:
113
+ first["url"] = base_url_override
114
+ return spec
115
+ if _is_absolute_url(server_url):
116
+ return spec
117
+
118
+ # Relative server URL ("/api/v3" or "api/v3") -> join with base host.
119
+ if not server_url.startswith("/"):
120
+ server_url = "/" + server_url
121
+ first["url"] = base_url_override + server_url
122
+ return spec
123
+
124
+
125
+ def _join_base_and_path(base_url: str, path: str) -> str:
126
+ base = (base_url or "").rstrip("/")
127
+ p = (path or "")
128
+ if not p.startswith("/"):
129
+ p = "/" + p
130
+ if not base:
131
+ return p
132
+ return base + p
133
+
134
+
135
+ def _parse_openapi_spec(spec: str | dict) -> dict:
136
+ if isinstance(spec, dict):
137
+ return spec
138
+ if not isinstance(spec, str) or not spec.strip():
139
+ _raise_openapi_tool_exception(code="missing_spec", message="OpenAPI spec is required")
140
+
141
+ try:
142
+ parsed = json.loads(spec)
143
+ except json.JSONDecodeError:
144
+ try:
145
+ parsed = yaml.safe_load(spec)
146
+ except yaml.YAMLError as e:
147
+ _raise_openapi_tool_exception(
148
+ code="invalid_spec",
149
+ message=f"Failed to parse OpenAPI spec as JSON or YAML: {e}",
150
+ details={"error": str(e)},
151
+ )
152
+
153
+ if not isinstance(parsed, dict):
154
+ _raise_openapi_tool_exception(code="invalid_spec", message="OpenAPI spec must parse to an object")
155
+ return parsed
156
+
157
+
158
+ def _guess_python_type(openapi_schema: dict | None) -> type:
159
+ schema_type = (openapi_schema or {}).get("type")
160
+ if schema_type == "integer":
161
+ return int
162
+ if schema_type == "number":
163
+ return float
164
+ if schema_type == "boolean":
165
+ return bool
166
+ # arrays/objects are left as string for now (simple start)
167
+ return str
168
+
169
+
170
+ def _schema_type_hint(openapi_schema: dict | None) -> str:
171
+ if not isinstance(openapi_schema, dict):
172
+ return ""
173
+ type_ = openapi_schema.get("type")
174
+ fmt = openapi_schema.get("format")
175
+ if not type_:
176
+ return ""
177
+ if fmt:
178
+ return f"{type_} ({fmt})"
179
+ return str(type_)
180
+
181
+
182
+ def _extract_request_body_example(spec: Optional[dict], op_raw: dict) -> Optional[str]:
183
+ request_body = op_raw.get("requestBody") or {}
184
+ content = request_body.get("content") or {}
185
+ for media_type in ("application/json", "application/*+json"):
186
+ mt = content.get(media_type)
187
+ if not isinstance(mt, dict):
188
+ continue
189
+
190
+ if "example" in mt:
191
+ try:
192
+ return json.dumps(mt["example"], indent=2)
193
+ except Exception:
194
+ return str(mt["example"])
195
+
196
+ examples = mt.get("examples")
197
+ if isinstance(examples, dict) and examples:
198
+ first = next(iter(examples.values()))
199
+ if isinstance(first, dict) and "value" in first:
200
+ try:
201
+ return json.dumps(first["value"], indent=2)
202
+ except Exception:
203
+ return str(first["value"])
204
+
205
+ schema = mt.get("schema")
206
+ if isinstance(schema, dict) and "example" in schema:
207
+ try:
208
+ return json.dumps(schema["example"], indent=2)
209
+ except Exception:
210
+ return str(schema["example"])
211
+
212
+ # No explicit example found; fall back to schema-based template.
213
+ if isinstance(schema, dict):
214
+ template_obj = _schema_to_template_json(
215
+ spec=spec,
216
+ schema=schema,
217
+ max_depth=3,
218
+ max_properties=20,
219
+ )
220
+ if template_obj is not None:
221
+ try:
222
+ return json.dumps(template_obj, indent=2)
223
+ except Exception:
224
+ return str(template_obj)
225
+ return None
226
+
227
+
228
+ def _schema_to_template_json(
229
+ spec: Any,
230
+ schema: dict,
231
+ max_depth: int,
232
+ max_properties: int,
233
+ ) -> Any:
234
+ """Build a schema-shaped JSON template from an OpenAPI/JSONSchema fragment.
235
+
236
+ This is a best-effort helper intended for LLM prompting. It avoids infinite recursion
237
+ (via depth and $ref cycle checks) and prefers enum/default/example when available.
238
+ """
239
+ visited_refs: set[str] = set()
240
+ return _schema_node_to_value(
241
+ spec=spec if isinstance(spec, dict) else None,
242
+ node=schema,
243
+ depth=0,
244
+ max_depth=max_depth,
245
+ max_properties=max_properties,
246
+ visited_refs=visited_refs,
247
+ )
248
+
249
+
250
+ def _schema_node_to_value(
251
+ spec: Optional[dict],
252
+ node: Any,
253
+ depth: int,
254
+ max_depth: int,
255
+ max_properties: int,
256
+ visited_refs: set[str],
257
+ ) -> Any:
258
+ if depth > max_depth:
259
+ return "<...>"
260
+
261
+ if not isinstance(node, dict):
262
+ return "<value>"
263
+
264
+ # Prefer explicit example/default/enum at this node.
265
+ if "example" in node:
266
+ return node.get("example")
267
+ if "default" in node:
268
+ return node.get("default")
269
+ if isinstance(node.get("enum"), list) and node.get("enum"):
270
+ return node.get("enum")[0]
271
+
272
+ ref = node.get("$ref")
273
+ if isinstance(ref, str):
274
+ if ref in visited_refs:
275
+ return "<ref-cycle>"
276
+ visited_refs.add(ref)
277
+ resolved = _resolve_ref(spec, ref)
278
+ if resolved is None:
279
+ return "<ref>"
280
+ return _schema_node_to_value(
281
+ spec=spec,
282
+ node=resolved,
283
+ depth=depth + 1,
284
+ max_depth=max_depth,
285
+ max_properties=max_properties,
286
+ visited_refs=visited_refs,
287
+ )
288
+
289
+ # Combinators
290
+ for key in ("oneOf", "anyOf"):
291
+ if isinstance(node.get(key), list) and node.get(key):
292
+ return _schema_node_to_value(
293
+ spec=spec,
294
+ node=node.get(key)[0],
295
+ depth=depth + 1,
296
+ max_depth=max_depth,
297
+ max_properties=max_properties,
298
+ visited_refs=visited_refs,
299
+ )
300
+
301
+ if isinstance(node.get("allOf"), list) and node.get("allOf"):
302
+ # Best-effort merge for objects.
303
+ merged: dict = {"type": "object", "properties": {}, "required": []}
304
+ for part in node.get("allOf"):
305
+ part_resolved = _schema_node_to_value(
306
+ spec=spec,
307
+ node=part,
308
+ depth=depth + 1,
309
+ max_depth=max_depth,
310
+ max_properties=max_properties,
311
+ visited_refs=visited_refs,
312
+ )
313
+ # If a part produced an object template, merge keys.
314
+ if isinstance(part_resolved, dict):
315
+ for k, v in part_resolved.items():
316
+ merged.setdefault(k, v)
317
+ return merged
318
+
319
+ type_ = node.get("type")
320
+ fmt = node.get("format")
321
+
322
+ if type_ == "object" or (type_ is None and ("properties" in node or "additionalProperties" in node)):
323
+ props = node.get("properties") if isinstance(node.get("properties"), dict) else {}
324
+ required = node.get("required") if isinstance(node.get("required"), list) else []
325
+
326
+ out: dict[str, Any] = {}
327
+ # Prefer required fields, then a small subset of optional fields for guidance.
328
+ keys: list[str] = []
329
+ for k in required:
330
+ if isinstance(k, str) and k in props:
331
+ keys.append(k)
332
+ if not keys:
333
+ keys = list(props.keys())[: min(3, len(props))]
334
+ else:
335
+ optional = [k for k in props.keys() if k not in keys]
336
+ keys.extend(optional[: max(0, min(3, len(optional)))])
337
+
338
+ keys = keys[:max_properties]
339
+ for k in keys:
340
+ out[k] = _schema_node_to_value(
341
+ spec=spec,
342
+ node=props.get(k),
343
+ depth=depth + 1,
344
+ max_depth=max_depth,
345
+ max_properties=max_properties,
346
+ visited_refs=visited_refs,
347
+ )
348
+ return out
349
+
350
+ if type_ == "array":
351
+ items = node.get("items")
352
+ return [
353
+ _schema_node_to_value(
354
+ spec=spec,
355
+ node=items,
356
+ depth=depth + 1,
357
+ max_depth=max_depth,
358
+ max_properties=max_properties,
359
+ visited_refs=visited_refs,
360
+ )
361
+ ]
362
+
363
+ if type_ == "integer":
364
+ return 0
365
+ if type_ == "number":
366
+ return 0.0
367
+ if type_ == "boolean":
368
+ return False
369
+ if type_ == "string":
370
+ if fmt == "date-time":
371
+ return "2025-01-01T00:00:00Z"
372
+ if fmt == "date":
373
+ return "2025-01-01"
374
+ if fmt == "uuid":
375
+ return "00000000-0000-0000-0000-000000000000"
376
+ return "<string>"
377
+
378
+ # Unknown: return a placeholder
379
+ return "<value>"
380
+
381
+
382
+ def _resolve_ref(spec: Optional[dict], ref: str) -> Optional[dict]:
383
+ if not spec or not isinstance(ref, str):
384
+ return None
385
+ if not ref.startswith("#/"):
386
+ return None
387
+ # Only local refs supported for now.
388
+ parts = ref.lstrip("#/").split("/")
389
+ cur: Any = spec
390
+ for part in parts:
391
+ if not isinstance(cur, dict):
392
+ return None
393
+ cur = cur.get(part)
394
+ if isinstance(cur, dict):
395
+ return cur
396
+ return None
397
+
398
+
399
+ def _normalize_output(value: Any) -> str:
400
+ if value is None:
401
+ return ""
402
+ if isinstance(value, bytes):
403
+ try:
404
+ return value.decode("utf-8")
405
+ except Exception:
406
+ return value.decode("utf-8", errors="replace")
407
+ return str(value)
408
+
409
+
410
+ class OpenApiApiWrapper(BaseToolApiWrapper):
411
+ """Builds callable tool functions for OpenAPI operations and executes them."""
412
+
413
+ spec: dict = Field(description="Parsed OpenAPI spec")
414
+ base_headers: dict[str, str] = Field(default_factory=dict)
415
+
416
+ _client: Client = PrivateAttr()
417
+ _op_meta: dict[str, dict] = PrivateAttr(default_factory=dict)
418
+ _tool_defs: list[dict[str, Any]] = PrivateAttr(default_factory=list)
419
+ _tool_ref_by_name: dict[str, Callable[..., str]] = PrivateAttr(default_factory=dict)
420
+
421
+ def model_post_init(self, __context: Any) -> None:
422
+ # Build meta from raw spec (method/path/examples)
423
+ op_meta: dict[str, dict] = {}
424
+ paths = self.spec.get("paths") or {}
425
+ if isinstance(paths, dict):
426
+ for path, path_item in paths.items():
427
+ if not isinstance(path_item, dict):
428
+ continue
429
+ for method, op_raw in path_item.items():
430
+ if not isinstance(op_raw, dict):
431
+ continue
432
+ operation_id = op_raw.get("operationId")
433
+ if not operation_id:
434
+ continue
435
+ op_meta[str(operation_id)] = {
436
+ "method": str(method).upper(),
437
+ "path": str(path),
438
+ "raw": op_raw,
439
+ }
440
+
441
+ client = Client()
442
+ client.load_spec(self.spec)
443
+ if self.base_headers:
444
+ client.requestor.headers.update({str(k): str(v) for k, v in self.base_headers.items()})
445
+
446
+ self._client = client
447
+ self._op_meta = op_meta
448
+
449
+ # Build tool definitions once.
450
+ self._tool_defs = self._build_tool_defs()
451
+ self._tool_ref_by_name = {t["name"]: t["ref"] for t in self._tool_defs if "ref" in t}
452
+
453
+ def _build_tool_defs(self) -> list[dict[str, Any]]:
454
+ tool_defs: list[dict[str, Any]] = []
455
+ for operation_id, op in getattr(self._client, "operations", {}).items():
456
+ if not isinstance(op, Operation):
457
+ continue
458
+ op_id = str(operation_id)
459
+ meta = self._op_meta.get(op_id, {})
460
+ op_raw = meta.get("raw") if isinstance(meta.get("raw"), dict) else {}
461
+
462
+ method = meta.get("method")
463
+ path = meta.get("path")
464
+
465
+ title_line = ""
466
+ if method and path:
467
+ title_line = f"{method} {path}"
468
+
469
+ summary = op.spec.summary or ""
470
+ description = op.spec.description or ""
471
+ tool_desc_parts = [p for p in [title_line, summary, description] if p]
472
+
473
+ has_request_body = bool(op.spec.requestBody)
474
+ usage_lines: list[str] = ["How to call:"]
475
+ usage_lines.append("- Provide path/query parameters as named arguments.")
476
+ if has_request_body:
477
+ usage_lines.append("- For JSON request bodies, pass `body_json` as a JSON string.")
478
+ usage_lines.append(
479
+ "- Use `headers` only for per-call extra headers; base/toolkit headers (including auth) are already applied."
480
+ )
481
+ tool_desc_parts.append("\n".join(usage_lines))
482
+
483
+ args_schema = self._create_args_schema(op_id, op, op_raw)
484
+ ref = self._make_operation_callable(op_id)
485
+
486
+ tool_defs.append(
487
+ {
488
+ "name": op_id,
489
+ "description": "\n".join(tool_desc_parts).strip(),
490
+ "args_schema": args_schema,
491
+ "ref": ref,
492
+ }
493
+ )
494
+ return tool_defs
495
+
496
+ def _make_operation_callable(self, operation_id: str) -> Callable[..., str]:
497
+ def _call_operation(*args: Any, **kwargs: Any) -> str:
498
+ return self._execute(operation_id, *args, **kwargs)
499
+
500
+ return _call_operation
501
+
502
+ def _create_args_schema(self, operation_id: str, op: Operation, op_raw: dict) -> type[BaseModel]:
503
+ fields: dict[str, tuple[Any, Any]] = {}
504
+
505
+ # Parameters
506
+ raw_params = op_raw.get("parameters") or []
507
+ raw_param_map: dict[tuple[str, str], dict] = {}
508
+ if isinstance(raw_params, list):
509
+ for p in raw_params:
510
+ if isinstance(p, dict) and p.get("name") and p.get("in"):
511
+ raw_param_map[(str(p.get("name")), str(p.get("in")))] = p
512
+
513
+ for param in op.spec.parameters or []:
514
+ param_name = str(param.name)
515
+ param_in_obj = getattr(param, "param_in", None)
516
+ # requests_openapi uses an enum-like value for `param_in`.
517
+ # For prompt quality and stable matching against raw spec, normalize to e.g. "query".
518
+ if hasattr(param_in_obj, "value"):
519
+ param_in = str(getattr(param_in_obj, "value"))
520
+ else:
521
+ param_in = str(param_in_obj)
522
+ raw_param = raw_param_map.get((param_name, param_in), {})
523
+
524
+ required = bool(raw_param.get("required", False))
525
+ schema = raw_param.get("schema") if isinstance(raw_param.get("schema"), dict) else None
526
+ py_type = _guess_python_type(schema)
527
+
528
+ example = raw_param.get("example")
529
+ if example is None and isinstance(schema, dict):
530
+ example = schema.get("example")
531
+
532
+ default = getattr(param.param_schema, "default", None)
533
+ desc = (param.description or "").strip()
534
+ desc = f"({param_in}) {desc}".strip()
535
+ type_hint = _schema_type_hint(schema)
536
+ if type_hint:
537
+ desc = f"{desc}\nType: {type_hint}".strip()
538
+ if required:
539
+ desc = f"{desc}\nRequired: true".strip()
540
+ if example is not None:
541
+ desc = f"{desc}\nExample: {example}".strip()
542
+ if default is not None:
543
+ desc = f"{desc}\nDefault: {default}".strip()
544
+
545
+ # Required fields have no default.
546
+ if required:
547
+ fields[param_name] = (py_type, Field(description=desc))
548
+ else:
549
+ fields[param_name] = (Optional[py_type], Field(default=default, description=desc))
550
+
551
+ # Additional headers not modeled in spec
552
+ fields["headers"] = (
553
+ Optional[dict],
554
+ Field(
555
+ default_factory=dict,
556
+ description=(
557
+ "Additional HTTP headers to include in this request. "
558
+ "These are merged with the toolkit/base headers (including auth headers). "
559
+ "Only add headers if the API requires them. "
560
+ "Provide a JSON object/dict. Example: {\"X-Trace-Id\": \"123\"}"
561
+ ),
562
+ ),
563
+ )
564
+
565
+ # Request body
566
+ request_body = op_raw.get("requestBody") if isinstance(op_raw.get("requestBody"), dict) else None
567
+ body_required = bool((request_body or {}).get("required", False))
568
+ body_example = _extract_request_body_example(self.spec, op_raw)
569
+ body_desc = (
570
+ "Request body (JSON) as a string. The tool will parse it with json.loads and send as the request JSON body."
571
+ )
572
+ if body_example:
573
+ body_desc = f"{body_desc}\nExample JSON:\n{body_example}"
574
+ if op.spec.requestBody:
575
+ if body_required:
576
+ fields["body_json"] = (str, Field(description=body_desc))
577
+ else:
578
+ fields["body_json"] = (Optional[str], Field(default=None, description=body_desc))
579
+
580
+ model_name = f"OpenApi_{clean_string(operation_id, max_length=40) or 'Operation'}_Params"
581
+ return create_model(
582
+ model_name,
583
+ regexp=(
584
+ Optional[str],
585
+ Field(
586
+ description="Regular expression to remove from the final output (optional)",
587
+ default=None,
588
+ ),
589
+ ),
590
+ **fields,
591
+ )
592
+
593
+ def get_available_tools(self, selected_tools: Optional[list[str]] = None) -> list[dict[str, Any]]:
594
+ if not selected_tools:
595
+ return list(self._tool_defs)
596
+ selected_set = {t for t in selected_tools if isinstance(t, str) and t}
597
+ return [t for t in self._tool_defs if t.get("name") in selected_set]
598
+
599
+ def run(self, mode: str, *args: Any, **kwargs: Any) -> str:
600
+ try:
601
+ ref = self._tool_ref_by_name[mode]
602
+ except KeyError:
603
+ _raise_openapi_tool_exception(
604
+ code="unknown_operation",
605
+ message=f"Unknown operation: {mode}",
606
+ details={"known_operations": sorted(list(self._tool_ref_by_name.keys()))[:200]},
607
+ )
608
+ return ref(*args, **kwargs)
609
+
610
+ def _get_required_inputs_from_raw_spec(self, operation_id: str) -> dict[str, Any]:
611
+ meta = self._op_meta.get(str(operation_id), {})
612
+ op_raw = meta.get("raw") if isinstance(meta, dict) and isinstance(meta.get("raw"), dict) else {}
613
+
614
+ required_path: list[str] = []
615
+ required_query: list[str] = []
616
+ raw_params = op_raw.get("parameters")
617
+ if isinstance(raw_params, list):
618
+ for p in raw_params:
619
+ if not isinstance(p, dict):
620
+ continue
621
+ name = p.get("name")
622
+ where = p.get("in")
623
+ required = bool(p.get("required", False))
624
+ if not required or not isinstance(name, str) or not isinstance(where, str):
625
+ continue
626
+ if where == "path":
627
+ required_path.append(name)
628
+ elif where == "query":
629
+ required_query.append(name)
630
+
631
+ req_body = False
632
+ rb = op_raw.get("requestBody")
633
+ if isinstance(rb, dict):
634
+ req_body = bool(rb.get("required", False))
635
+
636
+ return {
637
+ "required_path": required_path,
638
+ "required_query": required_query,
639
+ "required_body": req_body,
640
+ }
641
+
642
+ def get_operation_request_url(self, operation_id: str, params: dict[str, Any]) -> str:
643
+ """Best-effort resolved URL for debugging/prompt-quality inspection.
644
+
645
+ This does not execute the request.
646
+ """
647
+ meta = self._op_meta.get(str(operation_id), {})
648
+ path = meta.get("path") if isinstance(meta, dict) else None
649
+ if not isinstance(path, str):
650
+ return ""
651
+ base_url = _get_base_url_from_spec(self.spec)
652
+ url = _join_base_and_path(base_url, path)
653
+
654
+ # Substitute {pathParams}
655
+ for k, v in (params or {}).items():
656
+ placeholder = "{" + str(k) + "}"
657
+ if placeholder in url:
658
+ url = url.replace(placeholder, str(v))
659
+
660
+ # Add query params if present.
661
+ query: dict[str, Any] = {}
662
+ try:
663
+ op = self._client.operations[str(operation_id)]
664
+ if isinstance(op, Operation):
665
+ for p in op.spec.parameters or []:
666
+ p_in_obj = getattr(p, "param_in", None)
667
+ p_in = str(getattr(p_in_obj, "value", p_in_obj))
668
+ if p_in != "query":
669
+ continue
670
+ name = str(p.name)
671
+ if name in (params or {}) and (params or {}).get(name) is not None:
672
+ query[name] = (params or {})[name]
673
+ except Exception:
674
+ query = {}
675
+
676
+ if query:
677
+ url = url + "?" + urlencode(query, doseq=True)
678
+ return url
679
+
680
+ def _execute(self, operation_id: str, *args: Any, **kwargs: Any) -> str:
681
+ regexp = kwargs.pop("regexp", None)
682
+ extra_headers = kwargs.pop("headers", None)
683
+
684
+ if extra_headers is not None and not isinstance(extra_headers, dict):
685
+ _raise_openapi_tool_exception(
686
+ code="invalid_headers",
687
+ message="'headers' must be a dict",
688
+ operation_id=str(operation_id),
689
+ details={"provided_type": str(type(extra_headers))},
690
+ )
691
+
692
+ # Preferred: body_json (string) -> parsed object -> Operation json=
693
+ if "body_json" in kwargs and kwargs.get("body_json") is not None:
694
+ raw_json = kwargs.pop("body_json")
695
+ if isinstance(raw_json, str):
696
+ try:
697
+ kwargs["json"] = json.loads(raw_json)
698
+ except Exception as e:
699
+ _raise_openapi_tool_exception(
700
+ code="invalid_json_body",
701
+ message=f"Invalid JSON body: {e}",
702
+ operation_id=str(operation_id),
703
+ details={"hint": "Ensure body_json is valid JSON (double quotes, no trailing commas)."},
704
+ )
705
+ else:
706
+ kwargs["json"] = raw_json
707
+
708
+ # Backward compatible: accept `json` as a string too.
709
+ if "json" in kwargs and isinstance(kwargs.get("json"), str):
710
+ try:
711
+ kwargs["json"] = json.loads(kwargs["json"])
712
+ except Exception as e:
713
+ _raise_openapi_tool_exception(
714
+ code="invalid_json_body",
715
+ message=f"Invalid JSON body: {e}",
716
+ operation_id=str(operation_id),
717
+ details={"hint": "If you pass `json` as a string, it must be valid JSON."},
718
+ )
719
+
720
+ try:
721
+ op = self._client.operations[operation_id]
722
+ except Exception:
723
+ _raise_openapi_tool_exception(
724
+ code="operation_not_found",
725
+ message=f"Operation '{operation_id}' not found in OpenAPI spec",
726
+ operation_id=str(operation_id),
727
+ )
728
+ if not isinstance(op, Operation):
729
+ _raise_openapi_tool_exception(
730
+ code="invalid_operation",
731
+ message=f"Operation '{operation_id}' is not a valid OpenAPI operation",
732
+ operation_id=str(operation_id),
733
+ )
734
+
735
+ # Best-effort URL reconstruction for error context.
736
+ debug_url = ""
737
+ try:
738
+ debug_url = self.get_operation_request_url(operation_id, dict(kwargs))
739
+ except Exception:
740
+ debug_url = ""
741
+
742
+ # Preflight required input checks (helps LLM recover without needing spec knowledge).
743
+ missing: list[str] = []
744
+ required_info = self._get_required_inputs_from_raw_spec(str(operation_id))
745
+ for name in required_info.get("required_path", []) or []:
746
+ if name not in kwargs or kwargs.get(name) is None:
747
+ missing.append(name)
748
+ for name in required_info.get("required_query", []) or []:
749
+ if name not in kwargs or kwargs.get(name) is None:
750
+ missing.append(name)
751
+ if bool(required_info.get("required_body")) and kwargs.get("json") is None:
752
+ missing.append("body_json")
753
+
754
+ # Also check for unresolved {param} placeholders in the path.
755
+ meta = self._op_meta.get(str(operation_id), {})
756
+ path = meta.get("path") if isinstance(meta, dict) else None
757
+ if isinstance(path, str):
758
+ for placeholder in re.findall(r"\{([^}]+)\}", path):
759
+ if placeholder and (placeholder not in kwargs or kwargs.get(placeholder) is None):
760
+ missing.append(str(placeholder))
761
+
762
+ if missing:
763
+ _raise_openapi_tool_exception(
764
+ code="missing_required_inputs",
765
+ message=f"Missing required inputs for operation '{operation_id}': {', '.join(sorted(set(missing)))}",
766
+ operation_id=str(operation_id),
767
+ url=debug_url or None,
768
+ retryable=True,
769
+ missing_inputs=sorted(set(missing)),
770
+ details={"hint": "Provide the missing fields and retry the same operation."},
771
+ )
772
+
773
+ # Preflight base URL check: requests_openapi needs an absolute server URL to execute HTTP.
774
+ base_url = _get_base_url_from_spec(self.spec)
775
+ if not base_url or not _is_absolute_url(base_url):
776
+ servers = self.spec.get("servers") if isinstance(self.spec, dict) else None
777
+ server_url = None
778
+ if isinstance(servers, list) and servers and isinstance(servers[0], dict):
779
+ server_url = servers[0].get("url")
780
+
781
+ _raise_openapi_tool_exception(
782
+ code="missing_base_url",
783
+ message=(
784
+ "Cannot execute HTTP request because the OpenAPI spec does not contain an absolute server URL. "
785
+ "Provide `base_url_override`/`base_url` in the toolkit settings (e.g. 'https://host') "
786
+ "or update `servers[0].url` to an absolute URL (https://...)."
787
+ ),
788
+ operation_id=str(operation_id),
789
+ url=debug_url or None,
790
+ retryable=False,
791
+ details={
792
+ "servers_0_url": server_url,
793
+ "computed_base_url": base_url,
794
+ "hint": "If servers[0].url is relative like '/api/v3', set base_url_override to the host (e.g. 'https://petstore3.swagger.io').",
795
+ },
796
+ )
797
+
798
+ # Apply per-call extra headers (best-effort) without permanently mutating global headers.
799
+ old_headers = dict(getattr(self._client.requestor, "headers", {}) or {})
800
+ try:
801
+ if extra_headers:
802
+ self._client.requestor.headers.update({str(k): str(v) for k, v in extra_headers.items()})
803
+ response = op(*args, **kwargs)
804
+ except Exception as e:
805
+ _raise_openapi_tool_exception(
806
+ code="request_failed",
807
+ message=f"OpenAPI request failed for operation '{operation_id}': {e}",
808
+ operation_id=str(operation_id),
809
+ url=debug_url or None,
810
+ retryable=True,
811
+ details={"exception": repr(e)},
812
+ )
813
+ finally:
814
+ try:
815
+ self._client.requestor.headers.clear()
816
+ self._client.requestor.headers.update(old_headers)
817
+ except Exception:
818
+ pass
819
+
820
+ # If this looks like a requests.Response, raise on HTTP errors with actionable context.
821
+ status_code = getattr(response, "status_code", None)
822
+ if isinstance(status_code, int) and status_code >= 400:
823
+ body_preview = ""
824
+ for attr in ("text", "content", "data"):
825
+ if hasattr(response, attr):
826
+ body_preview = _normalize_output(getattr(response, attr))
827
+ break
828
+ body_preview = _truncate(body_preview, 2000)
829
+ retryable = _is_retryable_http_status(status_code)
830
+
831
+ hint = ""
832
+ if status_code in (401, 403):
833
+ hint = "Authentication/authorization failed. Verify toolkit authentication settings / base headers."
834
+ elif status_code == 404:
835
+ hint = "Resource not found. Check path parameters and identifiers."
836
+ elif status_code == 400:
837
+ hint = "Bad request. Check required parameters and request body schema."
838
+ elif status_code == 415:
839
+ hint = "Unsupported media type. The API may require Content-Type headers."
840
+ elif status_code == 429:
841
+ hint = "Rate limited. Retry after a short delay."
842
+
843
+ _raise_openapi_tool_exception(
844
+ code="http_error",
845
+ message=f"OpenAPI request failed with HTTP {status_code} for operation '{operation_id}'",
846
+ operation_id=str(operation_id),
847
+ url=debug_url or None,
848
+ retryable=retryable,
849
+ http_status=status_code,
850
+ http_body_preview=body_preview,
851
+ details={"hint": hint} if hint else None,
852
+ )
853
+
854
+ output = None
855
+ for attr in ("content", "data", "text"):
856
+ if hasattr(response, attr):
857
+ output = getattr(response, attr)
858
+ break
859
+ if output is None:
860
+ output = response
861
+
862
+ output_str = _normalize_output(output)
863
+
864
+ if regexp:
865
+ try:
866
+ output_str = re.sub(rf"{regexp}", "", output_str)
867
+ except Exception as e:
868
+ logger.debug(f"Failed to apply regexp filter: {e}")
869
+
870
+ return output_str
871
+
872
+
873
+ def build_wrapper(
874
+ openapi_spec: str | dict,
875
+ base_headers: Optional[dict[str, str]] = None,
876
+ base_url_override: Optional[str] = None,
877
+ ) -> OpenApiApiWrapper:
878
+ parsed = _parse_openapi_spec(openapi_spec)
879
+ # Avoid mutating caller-owned spec dict.
880
+ spec = copy.deepcopy(parsed)
881
+ if base_url_override:
882
+ spec = _apply_base_url_override(spec, base_url_override)
883
+ return OpenApiApiWrapper(spec=spec, base_headers=base_headers or {})