parser.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. import re
  2. import uuid
  3. from json import loads as json_loads
  4. from requests import get
  5. from yaml import FullLoader, load
  6. from core.tools.entities.common_entities import I18nObject
  7. from core.tools.entities.tool_bundle import ApiBasedToolBundle
  8. from core.tools.entities.tool_entities import ApiProviderSchemaType, ToolParameter
  9. from core.tools.errors import ToolApiSchemaError, ToolNotSupportedError, ToolProviderNotFoundError
  10. class ApiBasedToolSchemaParser:
  11. @staticmethod
  12. def parse_openapi_to_tool_bundle(openapi: dict, extra_info: dict = None, warning: dict = None) -> list[ApiBasedToolBundle]:
  13. warning = warning if warning is not None else {}
  14. extra_info = extra_info if extra_info is not None else {}
  15. # set description to extra_info
  16. if 'description' in openapi['info']:
  17. extra_info['description'] = openapi['info']['description']
  18. else:
  19. extra_info['description'] = ''
  20. if len(openapi['servers']) == 0:
  21. raise ToolProviderNotFoundError('No server found in the openapi yaml.')
  22. server_url = openapi['servers'][0]['url']
  23. # list all interfaces
  24. interfaces = []
  25. for path, path_item in openapi['paths'].items():
  26. methods = ['get', 'post', 'put', 'delete', 'patch', 'head', 'options', 'trace']
  27. for method in methods:
  28. if method in path_item:
  29. interfaces.append({
  30. 'path': path,
  31. 'method': method,
  32. 'operation': path_item[method],
  33. })
  34. # get all parameters
  35. bundles = []
  36. for interface in interfaces:
  37. # convert parameters
  38. parameters = []
  39. if 'parameters' in interface['operation']:
  40. for parameter in interface['operation']['parameters']:
  41. tool_parameter = ToolParameter(
  42. name=parameter['name'],
  43. label=I18nObject(
  44. en_US=parameter['name'],
  45. zh_Hans=parameter['name']
  46. ),
  47. human_description=I18nObject(
  48. en_US=parameter.get('description', ''),
  49. zh_Hans=parameter.get('description', '')
  50. ),
  51. type=ToolParameter.ToolParameterType.STRING,
  52. required=parameter.get('required', False),
  53. form=ToolParameter.ToolParameterForm.LLM,
  54. llm_description=parameter.get('description'),
  55. default=parameter['schema']['default'] if 'schema' in parameter and 'default' in parameter['schema'] else None,
  56. )
  57. # check if there is a type
  58. typ = ApiBasedToolSchemaParser._get_tool_parameter_type(parameter)
  59. if typ:
  60. tool_parameter.type = typ
  61. parameters.append(tool_parameter)
  62. # create tool bundle
  63. # check if there is a request body
  64. if 'requestBody' in interface['operation']:
  65. request_body = interface['operation']['requestBody']
  66. if 'content' in request_body:
  67. for content_type, content in request_body['content'].items():
  68. # if there is a reference, get the reference and overwrite the content
  69. if 'schema' not in content:
  70. content
  71. if '$ref' in content['schema']:
  72. # get the reference
  73. root = openapi
  74. reference = content['schema']['$ref'].split('/')[1:]
  75. for ref in reference:
  76. root = root[ref]
  77. # overwrite the content
  78. interface['operation']['requestBody']['content'][content_type]['schema'] = root
  79. # parse body parameters
  80. if 'schema' in interface['operation']['requestBody']['content'][content_type]:
  81. body_schema = interface['operation']['requestBody']['content'][content_type]['schema']
  82. required = body_schema['required'] if 'required' in body_schema else []
  83. properties = body_schema['properties'] if 'properties' in body_schema else {}
  84. for name, property in properties.items():
  85. tool = ToolParameter(
  86. name=name,
  87. label=I18nObject(
  88. en_US=name,
  89. zh_Hans=name
  90. ),
  91. human_description=I18nObject(
  92. en_US=property['description'] if 'description' in property else '',
  93. zh_Hans=property['description'] if 'description' in property else ''
  94. ),
  95. type=ToolParameter.ToolParameterType.STRING,
  96. required=name in required,
  97. form=ToolParameter.ToolParameterForm.LLM,
  98. llm_description=property['description'] if 'description' in property else '',
  99. default=property['default'] if 'default' in property else None,
  100. )
  101. # check if there is a type
  102. typ = ApiBasedToolSchemaParser._get_tool_parameter_type(property)
  103. if typ:
  104. tool.type = typ
  105. parameters.append(tool)
  106. # check if parameters is duplicated
  107. parameters_count = {}
  108. for parameter in parameters:
  109. if parameter.name not in parameters_count:
  110. parameters_count[parameter.name] = 0
  111. parameters_count[parameter.name] += 1
  112. for name, count in parameters_count.items():
  113. if count > 1:
  114. warning['duplicated_parameter'] = f'Parameter {name} is duplicated.'
  115. # check if there is a operation id, use $path_$method as operation id if not
  116. if 'operationId' not in interface['operation']:
  117. # remove special characters like / to ensure the operation id is valid ^[a-zA-Z0-9_-]{1,64}$
  118. path = interface['path']
  119. if interface['path'].startswith('/'):
  120. path = interface['path'][1:]
  121. # remove special characters like / to ensure the operation id is valid ^[a-zA-Z0-9_-]{1,64}$
  122. path = re.sub(r'[^a-zA-Z0-9_-]', '', path)
  123. if not path:
  124. path = str(uuid.uuid4())
  125. interface['operation']['operationId'] = f'{path}_{interface["method"]}'
  126. bundles.append(ApiBasedToolBundle(
  127. server_url=server_url + interface['path'],
  128. method=interface['method'],
  129. summary=interface['operation']['description'] if 'description' in interface['operation'] else
  130. interface['operation']['summary'] if 'summary' in interface['operation'] else None,
  131. operation_id=interface['operation']['operationId'],
  132. parameters=parameters,
  133. author='',
  134. icon=None,
  135. openapi=interface['operation'],
  136. ))
  137. return bundles
  138. @staticmethod
  139. def _get_tool_parameter_type(parameter: dict) -> ToolParameter.ToolParameterType:
  140. parameter = parameter or {}
  141. typ = None
  142. if 'type' in parameter:
  143. typ = parameter['type']
  144. elif 'schema' in parameter and 'type' in parameter['schema']:
  145. typ = parameter['schema']['type']
  146. if typ == 'integer' or typ == 'number':
  147. return ToolParameter.ToolParameterType.NUMBER
  148. elif typ == 'boolean':
  149. return ToolParameter.ToolParameterType.BOOLEAN
  150. elif typ == 'string':
  151. return ToolParameter.ToolParameterType.STRING
  152. @staticmethod
  153. def parse_openapi_yaml_to_tool_bundle(yaml: str, extra_info: dict = None, warning: dict = None) -> list[ApiBasedToolBundle]:
  154. """
  155. parse openapi yaml to tool bundle
  156. :param yaml: the yaml string
  157. :return: the tool bundle
  158. """
  159. warning = warning if warning is not None else {}
  160. extra_info = extra_info if extra_info is not None else {}
  161. openapi: dict = load(yaml, Loader=FullLoader)
  162. if openapi is None:
  163. raise ToolApiSchemaError('Invalid openapi yaml.')
  164. return ApiBasedToolSchemaParser.parse_openapi_to_tool_bundle(openapi, extra_info=extra_info, warning=warning)
  165. @staticmethod
  166. def parse_openapi_json_to_tool_bundle(json: str, extra_info: dict = None, warning: dict = None) -> list[ApiBasedToolBundle]:
  167. """
  168. parse openapi yaml to tool bundle
  169. :param yaml: the yaml string
  170. :return: the tool bundle
  171. """
  172. warning = warning if warning is not None else {}
  173. extra_info = extra_info if extra_info is not None else {}
  174. openapi: dict = json_loads(json)
  175. if openapi is None:
  176. raise ToolApiSchemaError('Invalid openapi json.')
  177. return ApiBasedToolSchemaParser.parse_openapi_to_tool_bundle(openapi, extra_info=extra_info, warning=warning)
  178. @staticmethod
  179. def parse_swagger_to_openapi(swagger: dict, extra_info: dict = None, warning: dict = None) -> dict:
  180. """
  181. parse swagger to openapi
  182. :param swagger: the swagger dict
  183. :return: the openapi dict
  184. """
  185. # convert swagger to openapi
  186. info = swagger.get('info', {
  187. 'title': 'Swagger',
  188. 'description': 'Swagger',
  189. 'version': '1.0.0'
  190. })
  191. servers = swagger.get('servers', [])
  192. if len(servers) == 0:
  193. raise ToolApiSchemaError('No server found in the swagger yaml.')
  194. openapi = {
  195. 'openapi': '3.0.0',
  196. 'info': {
  197. 'title': info.get('title', 'Swagger'),
  198. 'description': info.get('description', 'Swagger'),
  199. 'version': info.get('version', '1.0.0')
  200. },
  201. 'servers': swagger['servers'],
  202. 'paths': {},
  203. 'components': {
  204. 'schemas': {}
  205. }
  206. }
  207. # check paths
  208. if 'paths' not in swagger or len(swagger['paths']) == 0:
  209. raise ToolApiSchemaError('No paths found in the swagger yaml.')
  210. # convert paths
  211. for path, path_item in swagger['paths'].items():
  212. openapi['paths'][path] = {}
  213. for method, operation in path_item.items():
  214. if 'operationId' not in operation:
  215. raise ToolApiSchemaError(f'No operationId found in operation {method} {path}.')
  216. if ('summary' not in operation or len(operation['summary']) == 0) and \
  217. ('description' not in operation or len(operation['description']) == 0):
  218. warning['missing_summary'] = f'No summary or description found in operation {method} {path}.'
  219. openapi['paths'][path][method] = {
  220. 'operationId': operation['operationId'],
  221. 'summary': operation.get('summary', ''),
  222. 'description': operation.get('description', ''),
  223. 'parameters': operation.get('parameters', []),
  224. 'responses': operation.get('responses', {}),
  225. }
  226. if 'requestBody' in operation:
  227. openapi['paths'][path][method]['requestBody'] = operation['requestBody']
  228. # convert definitions
  229. for name, definition in swagger['definitions'].items():
  230. openapi['components']['schemas'][name] = definition
  231. return openapi
  232. @staticmethod
  233. def parse_swagger_yaml_to_tool_bundle(yaml: str, extra_info: dict = None, warning: dict = None) -> list[ApiBasedToolBundle]:
  234. """
  235. parse swagger yaml to tool bundle
  236. :param yaml: the yaml string
  237. :return: the tool bundle
  238. """
  239. warning = warning if warning is not None else {}
  240. extra_info = extra_info if extra_info is not None else {}
  241. swagger: dict = load(yaml, Loader=FullLoader)
  242. openapi = ApiBasedToolSchemaParser.parse_swagger_to_openapi(swagger, extra_info=extra_info, warning=warning)
  243. return ApiBasedToolSchemaParser.parse_openapi_to_tool_bundle(openapi, extra_info=extra_info, warning=warning)
  244. @staticmethod
  245. def parse_swagger_json_to_tool_bundle(json: str, extra_info: dict = None, warning: dict = None) -> list[ApiBasedToolBundle]:
  246. """
  247. parse swagger yaml to tool bundle
  248. :param yaml: the yaml string
  249. :return: the tool bundle
  250. """
  251. warning = warning if warning is not None else {}
  252. extra_info = extra_info if extra_info is not None else {}
  253. swagger: dict = json_loads(json)
  254. openapi = ApiBasedToolSchemaParser.parse_swagger_to_openapi(swagger, extra_info=extra_info, warning=warning)
  255. return ApiBasedToolSchemaParser.parse_openapi_to_tool_bundle(openapi, extra_info=extra_info, warning=warning)
  256. @staticmethod
  257. def parse_openai_plugin_json_to_tool_bundle(json: str, extra_info: dict = None, warning: dict = None) -> list[ApiBasedToolBundle]:
  258. """
  259. parse openapi plugin yaml to tool bundle
  260. :param json: the json string
  261. :return: the tool bundle
  262. """
  263. warning = warning if warning is not None else {}
  264. extra_info = extra_info if extra_info is not None else {}
  265. try:
  266. openai_plugin = json_loads(json)
  267. api = openai_plugin['api']
  268. api_url = api['url']
  269. api_type = api['type']
  270. except:
  271. raise ToolProviderNotFoundError('Invalid openai plugin json.')
  272. if api_type != 'openapi':
  273. raise ToolNotSupportedError('Only openapi is supported now.')
  274. # get openapi yaml
  275. response = get(api_url, headers={
  276. 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) '
  277. }, timeout=5)
  278. if response.status_code != 200:
  279. raise ToolProviderNotFoundError('cannot get openapi yaml from url.')
  280. return ApiBasedToolSchemaParser.parse_openapi_yaml_to_tool_bundle(response.text, extra_info=extra_info, warning=warning)
  281. @staticmethod
  282. def auto_parse_to_tool_bundle(content: str, extra_info: dict = None, warning: dict = None) -> tuple[list[ApiBasedToolBundle], str]:
  283. """
  284. auto parse to tool bundle
  285. :param content: the content
  286. :return: tools bundle, schema_type
  287. """
  288. warning = warning if warning is not None else {}
  289. extra_info = extra_info if extra_info is not None else {}
  290. json_possible = False
  291. content = content.strip()
  292. if content.startswith('{') and content.endswith('}'):
  293. json_possible = True
  294. if json_possible:
  295. try:
  296. return ApiBasedToolSchemaParser.parse_openapi_json_to_tool_bundle(content, extra_info=extra_info, warning=warning), \
  297. ApiProviderSchemaType.OPENAPI.value
  298. except:
  299. pass
  300. try:
  301. return ApiBasedToolSchemaParser.parse_swagger_json_to_tool_bundle(content, extra_info=extra_info, warning=warning), \
  302. ApiProviderSchemaType.SWAGGER.value
  303. except:
  304. pass
  305. try:
  306. return ApiBasedToolSchemaParser.parse_openai_plugin_json_to_tool_bundle(content, extra_info=extra_info, warning=warning), \
  307. ApiProviderSchemaType.OPENAI_PLUGIN.value
  308. except:
  309. pass
  310. else:
  311. try:
  312. return ApiBasedToolSchemaParser.parse_openapi_yaml_to_tool_bundle(content, extra_info=extra_info, warning=warning), \
  313. ApiProviderSchemaType.OPENAPI.value
  314. except:
  315. pass
  316. try:
  317. return ApiBasedToolSchemaParser.parse_swagger_yaml_to_tool_bundle(content, extra_info=extra_info, warning=warning), \
  318. ApiProviderSchemaType.SWAGGER.value
  319. except:
  320. pass
  321. raise ToolApiSchemaError('Invalid api schema.')