Spaces:
Configuration error
Configuration error
# ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. ========= | |
# Licensed under the Apache License, Version 2.0 (the "License"); | |
# you may not use this file except in compliance with the License. | |
# You may obtain a copy of the License at | |
# | |
# http://www.apache.org/licenses/LICENSE-2.0 | |
# | |
# Unless required by applicable law or agreed to in writing, software | |
# distributed under the License is distributed on an "AS IS" BASIS, | |
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
# See the License for the specific language governing permissions and | |
# limitations under the License. | |
# ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. ========= | |
import json | |
import os | |
from typing import Any, Callable, Dict, List, Optional, Tuple | |
import requests | |
from camel.toolkits import FunctionTool, openapi_security_config | |
from camel.types import OpenAPIName | |
class OpenAPIToolkit: | |
r"""A class representing a toolkit for interacting with OpenAPI APIs. | |
This class provides methods for interacting with APIs based on OpenAPI | |
specifications. It dynamically generates functions for each API operation | |
defined in the OpenAPI specification, allowing users to make HTTP requests | |
to the API endpoints. | |
""" | |
def parse_openapi_file( | |
self, openapi_spec_path: str | |
) -> Optional[Dict[str, Any]]: | |
r"""Load and parse an OpenAPI specification file. | |
This function utilizes the `prance.ResolvingParser` to parse and | |
resolve the given OpenAPI specification file, returning the parsed | |
OpenAPI specification as a dictionary. | |
Args: | |
openapi_spec_path (str): The file path or URL to the OpenAPI | |
specification. | |
Returns: | |
Optional[Dict[str, Any]]: The parsed OpenAPI specification | |
as a dictionary. :obj:`None` if the package is not installed. | |
""" | |
try: | |
import prance | |
except Exception: | |
return None | |
# Load the OpenAPI spec | |
parser = prance.ResolvingParser( | |
openapi_spec_path, backend="openapi-spec-validator", strict=False | |
) | |
openapi_spec = parser.specification | |
version = openapi_spec.get('openapi', {}) | |
if not version: | |
raise ValueError( | |
"OpenAPI version not specified in the spec. " | |
"Only OPENAPI 3.0.x and 3.1.x are supported." | |
) | |
if not (version.startswith('3.0') or version.startswith('3.1')): | |
raise ValueError( | |
f"Unsupported OpenAPI version: {version}. " | |
f"Only OPENAPI 3.0.x and 3.1.x are supported." | |
) | |
return openapi_spec | |
def openapi_spec_to_openai_schemas( | |
self, api_name: str, openapi_spec: Dict[str, Any] | |
) -> List[Dict[str, Any]]: | |
r"""Convert OpenAPI specification to OpenAI schema format. | |
This function iterates over the paths and operations defined in an | |
OpenAPI specification, filtering out deprecated operations. For each | |
operation, it constructs a schema in a format suitable for OpenAI, | |
including operation metadata such as function name, description, | |
parameters, and request bodies. It raises a ValueError if an operation | |
lacks a description or summary. | |
Args: | |
api_name (str): The name of the API, used to prefix generated | |
function names. | |
openapi_spec (Dict[str, Any]): The OpenAPI specification as a | |
dictionary. | |
Returns: | |
List[Dict[str, Any]]: A list of dictionaries, each representing a | |
function in the OpenAI schema format, including details about | |
the function's name, description, and parameters. | |
Raises: | |
ValueError: If an operation in the OpenAPI specification | |
does not have a description or summary. | |
Note: | |
This function assumes that the OpenAPI specification | |
follows the 3.0+ format. | |
Reference: | |
https://swagger.io/specification/ | |
""" | |
result = [] | |
for path, path_item in openapi_spec.get('paths', {}).items(): | |
for method, op in path_item.items(): | |
if op.get('deprecated') is True: | |
continue | |
# Get the function name from the operationId | |
# or construct it from the API method, and path | |
function_name = f"{api_name}" | |
operation_id = op.get('operationId') | |
if operation_id: | |
function_name += f"_{operation_id}" | |
else: | |
function_name += f"{method}{path.replace('/', '_')}" | |
description = op.get('description') or op.get('summary') | |
if not description: | |
raise ValueError( | |
f"{method} {path} Operation from {api_name} " | |
f"does not have a description or summary." | |
) | |
description += " " if description[-1] != " " else "" | |
description += f"This function is from {api_name} API. " | |
# If the OpenAPI spec has a description, | |
# add it to the operation description | |
if 'description' in openapi_spec.get('info', {}): | |
description += f"{openapi_spec['info']['description']}" | |
# Get the parameters for the operation, if any | |
params = op.get('parameters', []) | |
properties: Dict[str, Any] = {} | |
required = [] | |
for param in params: | |
if not param.get('deprecated', False): | |
param_name = param['name'] + '_in_' + param['in'] | |
properties[param_name] = {} | |
if 'description' in param: | |
properties[param_name]['description'] = param[ | |
'description' | |
] | |
if 'schema' in param: | |
if ( | |
properties[param_name].get('description') | |
and 'description' in param['schema'] | |
): | |
param['schema'].pop('description') | |
properties[param_name].update(param['schema']) | |
if param.get('required'): | |
required.append(param_name) | |
# If the property dictionary does not have a | |
# description, use the parameter name as | |
# the description | |
if 'description' not in properties[param_name]: | |
properties[param_name]['description'] = param[ | |
'name' | |
] | |
if 'type' not in properties[param_name]: | |
properties[param_name]['type'] = 'Any' | |
# Process requestBody if present | |
if 'requestBody' in op: | |
properties['requestBody'] = {} | |
requestBody = op['requestBody'] | |
if requestBody.get('required') is True: | |
required.append('requestBody') | |
content = requestBody.get('content', {}) | |
json_content = content.get('application/json', {}) | |
json_schema = json_content.get('schema', {}) | |
if json_schema: | |
properties['requestBody'] = json_schema | |
if 'description' not in properties['requestBody']: | |
properties['requestBody']['description'] = ( | |
"The request body, with parameters specifically " | |
"described under the `properties` key" | |
) | |
function = { | |
"type": "function", | |
"function": { | |
"name": function_name, | |
"description": description, | |
"parameters": { | |
"type": "object", | |
"properties": properties, | |
"required": required, | |
}, | |
}, | |
} | |
result.append(function) | |
return result # Return the result list | |
def openapi_function_decorator( | |
self, | |
api_name: str, | |
base_url: str, | |
path: str, | |
method: str, | |
openapi_security: List[Dict[str, Any]], | |
sec_schemas: Dict[str, Dict[str, Any]], | |
operation: Dict[str, Any], | |
) -> Callable: | |
r"""Decorate a function to make HTTP requests based on OpenAPI | |
specification details. | |
This decorator dynamically constructs and executes an API request based | |
on the provided OpenAPI operation specifications, security | |
requirements, and parameters. It supports operations secured with | |
`apiKey` type security schemes and automatically injects the necessary | |
API keys from environment variables. Parameters in `path`, `query`, | |
`header`, and `cookie` are also supported. | |
Args: | |
api_name (str): The name of the API, used to retrieve API key names | |
and URLs from the configuration. | |
base_url (str): The base URL for the API. | |
path (str): The path for the API endpoint, | |
relative to the base URL. | |
method (str): The HTTP method (e.g., 'get', 'post') | |
for the request. | |
openapi_security (List[Dict[str, Any]]): The global security | |
definitions as specified in the OpenAPI specs. | |
sec_schemas (Dict[str, Dict[str, Any]]): Detailed security schemes. | |
operation (Dict[str, Any]): A dictionary containing the OpenAPI | |
operation details, including parameters and request body | |
definitions. | |
Returns: | |
Callable: A decorator that, when applied to a function, enables the | |
function to make HTTP requests based on the provided OpenAPI | |
operation details. | |
Raises: | |
TypeError: If the security requirements include unsupported types. | |
ValueError: If required API keys are missing from environment | |
variables or if the content type of the request body is | |
unsupported. | |
""" | |
def inner_decorator(openapi_function: Callable) -> Callable: | |
def wrapper(**kwargs): | |
request_url = f"{base_url.rstrip('/')}/{path.lstrip('/')}" | |
headers = {} | |
params = {} | |
cookies = {} | |
# Security definition of operation overrides any declared | |
# top-level security. | |
sec_requirements = operation.get('security', openapi_security) | |
avail_sec_requirement = {} | |
# Write to avaliable_security_requirement only if all the | |
# security_type are "apiKey" | |
for security_requirement in sec_requirements: | |
have_unsupported_type = False | |
for sec_scheme_name, _ in security_requirement.items(): | |
sec_type = sec_schemas.get(sec_scheme_name).get('type') | |
if sec_type != "apiKey": | |
have_unsupported_type = True | |
break | |
if have_unsupported_type is False: | |
avail_sec_requirement = security_requirement | |
break | |
if sec_requirements and not avail_sec_requirement: | |
raise TypeError( | |
"Only security schemas of type `apiKey` are supported." | |
) | |
for sec_scheme_name, _ in avail_sec_requirement.items(): | |
try: | |
API_KEY_NAME = openapi_security_config.get( | |
api_name | |
).get(sec_scheme_name) | |
api_key_value = os.environ[API_KEY_NAME] | |
except Exception: | |
api_key_url = openapi_security_config.get( | |
api_name | |
).get('get_api_key_url') | |
raise ValueError( | |
f"`{API_KEY_NAME}` not found in environment " | |
f"variables. " | |
f"Get `{API_KEY_NAME}` here: {api_key_url}" | |
) | |
request_key_name = sec_schemas.get(sec_scheme_name).get( | |
'name' | |
) | |
request_key_in = sec_schemas.get(sec_scheme_name).get('in') | |
if request_key_in == 'query': | |
params[request_key_name] = api_key_value | |
elif request_key_in == 'header': | |
headers[request_key_name] = api_key_value | |
elif request_key_in == 'coolie': | |
cookies[request_key_name] = api_key_value | |
# Assign parameters to the correct position | |
for param in operation.get('parameters', []): | |
input_param_name = param['name'] + '_in_' + param['in'] | |
# Irrelevant arguments does not affect function operation | |
if input_param_name in kwargs: | |
if param['in'] == 'path': | |
request_url = request_url.replace( | |
f"{{{param['name']}}}", | |
str(kwargs[input_param_name]), | |
) | |
elif param['in'] == 'query': | |
params[param['name']] = kwargs[input_param_name] | |
elif param['in'] == 'header': | |
headers[param['name']] = kwargs[input_param_name] | |
elif param['in'] == 'cookie': | |
cookies[param['name']] = kwargs[input_param_name] | |
if 'requestBody' in operation: | |
request_body = kwargs.get('requestBody', {}) | |
content_type_list = list( | |
operation.get('requestBody', {}) | |
.get('content', {}) | |
.keys() | |
) | |
if content_type_list: | |
content_type = content_type_list[0] | |
headers.update({"Content-Type": content_type}) | |
# send the request body based on the Content-Type | |
if content_type == "application/json": | |
response = requests.request( | |
method.upper(), | |
request_url, | |
params=params, | |
headers=headers, | |
cookies=cookies, | |
json=request_body, | |
) | |
else: | |
raise ValueError( | |
f"Unsupported content type: {content_type}" | |
) | |
else: | |
# If there is no requestBody, no request body is sent | |
response = requests.request( | |
method.upper(), | |
request_url, | |
params=params, | |
headers=headers, | |
cookies=cookies, | |
) | |
try: | |
return response.json() | |
except json.JSONDecodeError: | |
raise ValueError( | |
"Response could not be decoded as JSON. " | |
"Please check the input parameters." | |
) | |
return wrapper | |
return inner_decorator | |
def generate_openapi_funcs( | |
self, api_name: str, openapi_spec: Dict[str, Any] | |
) -> List[Callable]: | |
r"""Generates a list of Python functions based on | |
OpenAPI specification. | |
This function dynamically creates a list of callable functions that | |
represent the API operations defined in an OpenAPI specification | |
document. Each function is designed to perform an HTTP request | |
corresponding to an API operation (e.g., GET, POST) as defined in | |
the specification. The functions are decorated with | |
`openapi_function_decorator`, which configures them to construct and | |
send the HTTP requests with appropriate parameters, headers, and body | |
content. | |
Args: | |
api_name (str): The name of the API, used to prefix generated | |
function names. | |
openapi_spec (Dict[str, Any]): The OpenAPI specification as a | |
dictionary. | |
Returns: | |
List[Callable]: A list containing the generated functions. Each | |
function, when called, will make an HTTP request according to | |
its corresponding API operation defined in the OpenAPI | |
specification. | |
Raises: | |
ValueError: If the OpenAPI specification does not contain server | |
information, which is necessary for determining the base URL | |
for the API requests. | |
""" | |
# Check server information | |
servers = openapi_spec.get('servers', []) | |
if not servers: | |
raise ValueError("No server information found in OpenAPI spec.") | |
base_url = servers[0].get('url') # Use the first server URL | |
# Security requirement objects for all methods | |
openapi_security = openapi_spec.get('security', {}) | |
# Security schemas which can be reused by different methods | |
sec_schemas = openapi_spec.get('components', {}).get( | |
'securitySchemes', {} | |
) | |
functions = [] | |
# Traverse paths and methods | |
for path, methods in openapi_spec.get('paths', {}).items(): | |
for method, operation in methods.items(): | |
# Get the function name from the operationId | |
# or construct it from the API method, and path | |
operation_id = operation.get('operationId') | |
if operation_id: | |
function_name = f"{api_name}_{operation_id}" | |
else: | |
sanitized_path = path.replace('/', '_').strip('_') | |
function_name = f"{api_name}_{method}_{sanitized_path}" | |
def openapi_function(**kwargs): | |
pass | |
openapi_function.__name__ = function_name | |
functions.append(openapi_function) | |
return functions | |
def apinames_filepaths_to_funs_schemas( | |
self, | |
apinames_filepaths: List[Tuple[str, str]], | |
) -> Tuple[List[Callable], List[Dict[str, Any]]]: | |
r"""Combines functions and schemas from multiple OpenAPI | |
specifications, using API names as keys. | |
This function iterates over tuples of API names and OpenAPI spec file | |
paths, parsing each spec to generate callable functions and schema | |
dictionaries, all organized by API name. | |
Args: | |
apinames_filepaths (List[Tuple[str, str]]): A list of tuples, where | |
each tuple consists of: | |
- The API name (str) as the first element. | |
- The file path (str) to the API's OpenAPI specification file as | |
the second element. | |
Returns: | |
Tuple[List[Callable], List[Dict[str, Any]]]:: one of callable | |
functions for API operations, and another of dictionaries | |
representing the schemas from the specifications. | |
""" | |
combined_func_lst = [] | |
combined_schemas_list = [] | |
for api_name, file_path in apinames_filepaths: | |
# Parse the OpenAPI specification for each API | |
current_dir = os.path.dirname(__file__) | |
file_path = os.path.join( | |
current_dir, 'open_api_specs', f'{api_name}', 'openapi.yaml' | |
) | |
openapi_spec = self.parse_openapi_file(file_path) | |
if openapi_spec is None: | |
return [], [] | |
# Generate and merge function schemas | |
openapi_functions_schemas = self.openapi_spec_to_openai_schemas( | |
api_name, openapi_spec | |
) | |
combined_schemas_list.extend(openapi_functions_schemas) | |
# Generate and merge function lists | |
openapi_functions_list = self.generate_openapi_funcs( | |
api_name, openapi_spec | |
) | |
combined_func_lst.extend(openapi_functions_list) | |
return combined_func_lst, combined_schemas_list | |
def generate_apinames_filepaths(self) -> List[Tuple[str, str]]: | |
"""Generates a list of tuples containing API names and their | |
corresponding file paths. | |
This function iterates over the OpenAPIName enum, constructs the file | |
path for each API's OpenAPI specification file, and appends a tuple of | |
the API name and its file path to the list. The file paths are relative | |
to the 'open_api_specs' directory located in the same directory as this | |
script. | |
Returns: | |
List[Tuple[str, str]]: A list of tuples where each tuple contains | |
two elements. The first element of each tuple is a string | |
representing the name of an API, and the second element is a | |
string that specifies the file path to that API's OpenAPI | |
specification file. | |
""" | |
apinames_filepaths = [] | |
current_dir = os.path.dirname(__file__) | |
for api_name in OpenAPIName: | |
file_path = os.path.join( | |
current_dir, | |
'open_api_specs', | |
f'{api_name.value}', | |
'openapi.yaml', | |
) | |
apinames_filepaths.append((api_name.value, file_path)) | |
return apinames_filepaths | |
def get_tools(self) -> List[FunctionTool]: | |
r"""Returns a list of FunctionTool objects representing the | |
functions in the toolkit. | |
Returns: | |
List[FunctionTool]: A list of FunctionTool objects | |
representing the functions in the toolkit. | |
""" | |
apinames_filepaths = self.generate_apinames_filepaths() | |
all_funcs_lst, all_schemas_lst = ( | |
self.apinames_filepaths_to_funs_schemas(apinames_filepaths) | |
) | |
return [ | |
FunctionTool(a_func, a_schema) | |
for a_func, a_schema in zip(all_funcs_lst, all_schemas_lst) | |
] | |