diff --git a/docs/extras/modules/agents/toolkits/amadeus.ipynb b/docs/extras/modules/agents/toolkits/amadeus.ipynb new file mode 100644 index 0000000000000..afcaaccfbb9d0 --- /dev/null +++ b/docs/extras/modules/agents/toolkits/amadeus.ipynb @@ -0,0 +1,242 @@ +{ + "cells": [ + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Amadeus Toolkit\n", + "\n", + "This notebook walks you through connecting LangChain to the Amadeus travel information API\n", + "\n", + "To use this toolkit, you will need to set up your credentials explained in the [Amadeus for developers getting started overview](https://developers.amadeus.com/get-started/get-started-with-self-service-apis-335). Once you've received a AMADEUS_CLIENT_ID and AMADEUS_CLIENT_SECRET, you can input them as environmental variables below." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "!pip install --upgrade amadeus > /dev/null" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Assign Environmental Variables\n", + "\n", + "The toolkit will read the AMADEUS_CLIENT_ID and AMADEUS_CLIENT_SECRET environmental variables to authenticate the user so you need to set them here. You will also need to set your OPENAI_API_KEY to use the agent later." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "# Set environmental variables here\n", + "import os\n", + "\n", + "os.environ[\"AMADEUS_CLIENT_ID\"] = \"CLIENT_ID\"\n", + "os.environ[\"AMADEUS_CLIENT_SECRET\"] = \"CLIENT_SECRET\"\n", + "os.environ[\"OPENAI_API_KEY\"] = \"API_KEY\"" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Create the Amadeus Toolkit and Get Tools\n", + "\n", + "To start, you need to create the toolkit, so you can access its tools later." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "from langchain.agents.agent_toolkits.amadeus.toolkit import AmadeusToolkit\n", + "\n", + "toolkit = AmadeusToolkit()\n", + "tools = toolkit.get_tools()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Use Amadeus Toolkit within an Agent" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "from langchain import OpenAI\n", + "from langchain.agents import initialize_agent, AgentType" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "llm = OpenAI(temperature=0)\n", + "agent = initialize_agent(\n", + " tools=tools,\n", + " llm=llm,\n", + " verbose=False,\n", + " agent=AgentType.STRUCTURED_CHAT_ZERO_SHOT_REACT_DESCRIPTION,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/plain": [ + "'The closest airport to Cali, Colombia is Alfonso Bonilla Aragón International Airport (CLO).'" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "agent.run(\"What is the name of the airport in Cali, Colombia?\")" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/plain": [ + "'The cheapest flight on August 23, 2023 leaving Dallas, Texas before noon to Lincoln, Nebraska has a departure time of 16:42 and a total price of 276.08 EURO.'" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "agent.run(\n", + " \"What is the departure time of the cheapest flight on August 23, 2023 leaving Dallas, Texas before noon to Lincoln, Nebraska?\"\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'The earliest flight on August 23, 2023 leaving Dallas, Texas to Lincoln, Nebraska lands in Lincoln, Nebraska at 16:07.'" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "agent.run(\n", + " \"At what time does earliest flight on August 23, 2023 leaving Dallas, Texas to Lincoln, Nebraska land in Nebraska?\"\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'The cheapest flight between Portland, Oregon to Dallas, TX on October 3, 2023 is a Spirit Airlines flight with a total price of 84.02 EURO and a total travel time of 8 hours and 43 minutes.'" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "agent.run(\n", + " \"What is the full travel time for the cheapest flight between Portland, Oregon to Dallas, TX on October 3, 2023?\"\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'Dear Paul,\\n\\nI am writing to request that you book the earliest flight from DFW to DCA on Aug 28, 2023. The flight details are as follows:\\n\\nFlight 1: DFW to ATL, departing at 7:15 AM, arriving at 10:25 AM, flight number 983, carrier Delta Air Lines\\nFlight 2: ATL to DCA, departing at 12:15 PM, arriving at 2:02 PM, flight number 759, carrier Delta Air Lines\\n\\nThank you for your help.\\n\\nSincerely,\\nSantiago'" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "agent.run(\n", + " \"Please draft a concise email from Santiago to Paul, Santiago's travel agent, asking him to book the earliest flight from DFW to DCA on Aug 28, 2023. Include all flight details in the email.\"\n", + ")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.4" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/langchain/agents/agent_toolkits/__init__.py b/langchain/agents/agent_toolkits/__init__.py index fb582de0e7574..05704a7183047 100644 --- a/langchain/agents/agent_toolkits/__init__.py +++ b/langchain/agents/agent_toolkits/__init__.py @@ -1,5 +1,5 @@ """Agent toolkits.""" - +from langchain.agents.agent_toolkits.amadeus.toolkit import AmadeusToolkit from langchain.agents.agent_toolkits.azure_cognitive_services.toolkit import ( AzureCognitiveServicesToolkit, ) @@ -39,6 +39,7 @@ from langchain.agents.agent_toolkits.zapier.toolkit import ZapierToolkit __all__ = [ + "AmadeusToolkit", "create_json_agent", "create_sql_agent", "create_openapi_agent", diff --git a/langchain/agents/agent_toolkits/amadeus/toolkit.py b/langchain/agents/agent_toolkits/amadeus/toolkit.py new file mode 100644 index 0000000000000..28db53bdcb96d --- /dev/null +++ b/langchain/agents/agent_toolkits/amadeus/toolkit.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, List + +from pydantic import Field + +from langchain.agents.agent_toolkits.base import BaseToolkit +from langchain.tools import BaseTool +from langchain.tools.amadeus.closest_airport import AmadeusClosestAirport +from langchain.tools.amadeus.flight_search import AmadeusFlightSearch +from langchain.tools.amadeus.utils import authenticate + +if TYPE_CHECKING: + from amadeus import Client + + +class AmadeusToolkit(BaseToolkit): + """Toolkit for interacting with Office365.""" + + client: Client = Field(default_factory=authenticate) + + class Config: + """Pydantic config.""" + + arbitrary_types_allowed = True + + def get_tools(self) -> List[BaseTool]: + """Get the tools in the toolkit.""" + return [ + AmadeusClosestAirport(), + AmadeusFlightSearch(), + ] diff --git a/langchain/tools/amadeus/__init__.py b/langchain/tools/amadeus/__init__.py new file mode 100644 index 0000000000000..ee767582ff47d --- /dev/null +++ b/langchain/tools/amadeus/__init__.py @@ -0,0 +1,9 @@ +"""Amadeus tools.""" + +from langchain.tools.amadeus.closest_airport import AmadeusClosestAirport +from langchain.tools.amadeus.flight_search import AmadeusFlightSearch + +__all__ = [ + "AmadeusClosestAirport", + "AmadeusFlightSearch", +] diff --git a/langchain/tools/amadeus/base.py b/langchain/tools/amadeus/base.py new file mode 100644 index 0000000000000..c2df21f0c51f9 --- /dev/null +++ b/langchain/tools/amadeus/base.py @@ -0,0 +1,16 @@ +"""Base class for Amadeus tools.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pydantic import Field + +from langchain.tools.amadeus.utils import authenticate +from langchain.tools.base import BaseTool + +if TYPE_CHECKING: + from amadeus import Client + + +class AmadeusBaseTool(BaseTool): + client: Client = Field(default_factory=authenticate) diff --git a/langchain/tools/amadeus/closest_airport.py b/langchain/tools/amadeus/closest_airport.py new file mode 100644 index 0000000000000..de3b7cc700930 --- /dev/null +++ b/langchain/tools/amadeus/closest_airport.py @@ -0,0 +1,63 @@ +from typing import Optional, Type + +from pydantic import BaseModel, Field + +from langchain.callbacks.manager import ( + AsyncCallbackManagerForToolRun, + CallbackManagerForToolRun, +) +from langchain.chains import LLMChain +from langchain.chat_models import ChatOpenAI +from langchain.tools.amadeus.base import AmadeusBaseTool + + +class ClosestAirportSchema(BaseModel): + location: str = Field( + description=( + " The location for which you would like to find the nearest airport " + " along with optional details such as country, state, region, or " + " province, allowing for easy processing and identification of " + " the closest airport. Examples of the format are the following:\n" + " Cali, Colombia\n " + " Lincoln, Nebraska, United States\n" + " New York, United States\n" + " Sydney, New South Wales, Australia\n" + " Rome, Lazio, Italy\n" + " Toronto, Ontario, Canada\n" + ) + ) + + +class AmadeusClosestAirport(AmadeusBaseTool): + name: str = "closest_airport" + description: str = ( + "Use this tool to find the closest airport to a particular location." + ) + args_schema: Type[ClosestAirportSchema] = ClosestAirportSchema + + def _run( + self, + location: str, + run_manager: Optional[CallbackManagerForToolRun] = None, + ) -> str: + template = ( + " What is the nearest airport to {location}? Please respond with the " + " airport's International Air Transport Association (IATA) Location " + ' Identifier in the following JSON format. JSON: "iataCode": "IATA ' + ' Location Identifier" ' + ) + + llm = ChatOpenAI(temperature=0) + + llm_chain = LLMChain.from_string(llm=llm, template=template) + + output = llm_chain.run(location=location) + + return output + + async def _arun( + self, + location: str, + run_manager: Optional[AsyncCallbackManagerForToolRun] = None, + ) -> str: + raise NotImplementedError(f"The tool {self.name} does not support async yet.") diff --git a/langchain/tools/amadeus/flight_search.py b/langchain/tools/amadeus/flight_search.py new file mode 100644 index 0000000000000..b7bcd3c45a733 --- /dev/null +++ b/langchain/tools/amadeus/flight_search.py @@ -0,0 +1,162 @@ +import logging +from datetime import datetime as dt +from typing import Dict, Optional, Type + +from pydantic import BaseModel, Field + +from langchain.callbacks.manager import ( + AsyncCallbackManagerForToolRun, + CallbackManagerForToolRun, +) +from langchain.tools.amadeus.base import AmadeusBaseTool + +logger = logging.getLogger(__name__) + + +class FlightSearchSchema(BaseModel): + originLocationCode: str = Field( + description=( + " The three letter International Air Transport " + " Association (IATA) Location Identifier for the " + " search's origin airport. " + ) + ) + destinationLocationCode: str = Field( + description=( + " The three letter International Air Transport " + " Association (IATA) Location Identifier for the " + " search's destination airport. " + ) + ) + departureDateTimeEarliest: str = Field( + description=( + " The earliest departure datetime from the origin airport " + " for the flight search in the following format: " + ' "YYYY-MM-DDTHH:MM", where "T" separates the date and time ' + ' components. For example: "2023-06-09T10:30:00" represents ' + " June 9th, 2023, at 10:30 AM. " + ) + ) + departureDateTimeLatest: str = Field( + description=( + " The latest departure datetime from the origin airport " + " for the flight search in the following format: " + ' "YYYY-MM-DDTHH:MM", where "T" separates the date and time ' + ' components. For example: "2023-06-09T10:30:00" represents ' + " June 9th, 2023, at 10:30 AM. " + ) + ) + page_number: int = Field( + default=1, + description="The specific page number of flight results to retrieve", + ) + + +class AmadeusFlightSearch(AmadeusBaseTool): + name: str = "single_flight_search" + description: str = ( + " Use this tool to search for a single flight between the origin and " + " destination airports at a departure between an earliest and " + " latest datetime. " + ) + args_schema: Type[FlightSearchSchema] = FlightSearchSchema + + def _run( + self, + originLocationCode: str, + destinationLocationCode: str, + departureDateTimeEarliest: str, + departureDateTimeLatest: str, + page_number: int = 1, + run_manager: Optional[CallbackManagerForToolRun] = None, + ) -> list: + try: + from amadeus import ResponseError + except ImportError as e: + raise ImportError( + "Unable to import amadeus, please install with `pip install amadeus`." + ) from e + + RESULTS_PER_PAGE = 10 + + # Authenticate and retrieve a client + client = self.client + + # Check that earliest and latest dates are in the same day + earliestDeparture = dt.strptime(departureDateTimeEarliest, "%Y-%m-%dT%H:%M:%S") + latestDeparture = dt.strptime(departureDateTimeLatest, "%Y-%m-%dT%H:%M:%S") + + if earliestDeparture.date() != latestDeparture.date(): + logger.error( + " Error: Earliest and latest departure dates need to be the " + " same date. If you're trying to search for round-trip " + " flights, call this function for the outbound flight first, " + " and then call again for the return flight. " + ) + return [None] + + # Collect all results from the API + try: + response = client.shopping.flight_offers_search.get( + originLocationCode=originLocationCode, + destinationLocationCode=destinationLocationCode, + departureDate=latestDeparture.strftime("%Y-%m-%d"), + adults=1, + ) + except ResponseError as error: + print(error) + + # Generate output dictionary + output = [] + + for offer in response.data: + itinerary: Dict = {} + itinerary["price"] = {} + itinerary["price"]["total"] = offer["price"]["total"] + currency = offer["price"]["currency"] + currency = response.result["dictionaries"]["currencies"][currency] + itinerary["price"]["currency"] = {} + itinerary["price"]["currency"] = currency + + segments = [] + for segment in offer["itineraries"][0]["segments"]: + flight = {} + flight["departure"] = segment["departure"] + flight["arrival"] = segment["arrival"] + flight["flightNumber"] = segment["number"] + carrier = segment["carrierCode"] + carrier = response.result["dictionaries"]["carriers"][carrier] + flight["carrier"] = carrier + + segments.append(flight) + + itinerary["segments"] = [] + itinerary["segments"] = segments + + output.append(itinerary) + + # Filter out flights after latest departure time + for index, offer in enumerate(output): + offerDeparture = dt.strptime( + offer["segments"][0]["departure"]["at"], "%Y-%m-%dT%H:%M:%S" + ) + + if offerDeparture > latestDeparture: + output.pop(index) + + # Return the paginated results + startIndex = (page_number - 1) * RESULTS_PER_PAGE + endIndex = startIndex + RESULTS_PER_PAGE + + return output[startIndex:endIndex] + + async def _arun( + self, + originLocationCode: str, + destinationLocationCode: str, + departureDateTimeEarliest: str, + departureDateTimeLatest: str, + page_number: int = 1, + run_manager: Optional[AsyncCallbackManagerForToolRun] = None, + ) -> list: + raise NotImplementedError(f"The tool {self.name} does not support async yet.") diff --git a/langchain/tools/amadeus/utils.py b/langchain/tools/amadeus/utils.py new file mode 100644 index 0000000000000..51e0740615f92 --- /dev/null +++ b/langchain/tools/amadeus/utils.py @@ -0,0 +1,38 @@ +"""O365 tool utils.""" +from __future__ import annotations + +import logging +import os +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from amadeus import Client + +logger = logging.getLogger(__name__) + + +def authenticate() -> Client: + """Authenticate using the Amadeus API""" + try: + from amadeus import Client + except ImportError as e: + raise ImportError( + "Cannot import amadeus. Please install the package with " + "`pip install amadeus`." + ) from e + + if "AMADEUS_CLIENT_ID" in os.environ and "AMADEUS_CLIENT_SECRET" in os.environ: + client_id = os.environ["AMADEUS_CLIENT_ID"] + client_secret = os.environ["AMADEUS_CLIENT_SECRET"] + else: + logger.error( + "Error: The AMADEUS_CLIENT_ID and AMADEUS_CLIENT_SECRET environmental " + "variables have not been set. Visit the following link on how to " + "acquire these authorization tokens: " + "https://developers.amadeus.com/register" + ) + return None + + client = Client(client_id=client_id, client_secret=client_secret) + + return client diff --git a/poetry.lock b/poetry.lock index 9af5529b276c9..7570d81987024 100644 --- a/poetry.lock +++ b/poetry.lock @@ -339,6 +339,17 @@ toolz = "*" [package.extras] dev = ["black", "docutils", "flake8", "ipython", "m2r", "mistune (<2.0.0)", "pytest", "recommonmark", "sphinx", "vega-datasets"] +[[package]] +name = "amadeus" +version = "8.1.0" +description = "Python module for the Amadeus travel APIs" +category = "main" +optional = true +python-versions = ">=3.4.8" +files = [ + {file = "amadeus-8.1.0.tar.gz", hash = "sha256:df31e7c84383a85ee2dce95b11e7a0774fdf31762229f768519b5cb176bc167d"}, +] + [[package]] name = "anthropic" version = "0.3.2" @@ -13070,7 +13081,7 @@ cffi = {version = ">=1.11", markers = "platform_python_implementation == \"PyPy\ cffi = ["cffi (>=1.11)"] [extras] -all = ["O365", "aleph-alpha-client", "anthropic", "arxiv", "atlassian-python-api", "awadb", "azure-ai-formrecognizer", "azure-ai-vision", "azure-cognitiveservices-speech", "azure-cosmos", "azure-identity", "beautifulsoup4", "clarifai", "clickhouse-connect", "cohere", "deeplake", "docarray", "duckduckgo-search", "elasticsearch", "esprima", "faiss-cpu", "google-api-python-client", "google-auth", "google-search-results", "gptcache", "html2text", "huggingface_hub", "jina", "jinja2", "jq", "lancedb", "langkit", "lark", "libdeeplake", "lxml", "manifest-ml", "marqo", "momento", "nebula3-python", "neo4j", "networkx", "nlpcloud", "nltk", "nomic", "octoai-sdk", "openai", "openlm", "opensearch-py", "pdfminer-six", "pexpect", "pgvector", "pinecone-client", "pinecone-text", "psycopg2-binary", "pymongo", "pyowm", "pypdf", "pytesseract", "pyvespa", "qdrant-client", "rdflib", "redis", "requests-toolbelt", "sentence-transformers", "singlestoredb", "spacy", "steamship", "tensorflow-text", "tigrisdb", "tiktoken", "torch", "transformers", "weaviate-client", "wikipedia", "wolframalpha"] +all = ["O365", "aleph-alpha-client", "amadeus", "anthropic", "arxiv", "atlassian-python-api", "awadb", "azure-ai-formrecognizer", "azure-ai-vision", "azure-cognitiveservices-speech", "azure-cosmos", "azure-identity", "beautifulsoup4", "clarifai", "clickhouse-connect", "cohere", "deeplake", "docarray", "duckduckgo-search", "elasticsearch", "esprima", "faiss-cpu", "google-api-python-client", "google-auth", "google-search-results", "gptcache", "html2text", "huggingface_hub", "jina", "jinja2", "jq", "lancedb", "langkit", "lark", "libdeeplake", "lxml", "manifest-ml", "marqo", "momento", "nebula3-python", "neo4j", "networkx", "nlpcloud", "nltk", "nomic", "octoai-sdk", "openai", "openlm", "opensearch-py", "pdfminer-six", "pexpect", "pgvector", "pinecone-client", "pinecone-text", "psycopg2-binary", "pymongo", "pyowm", "pypdf", "pytesseract", "pyvespa", "qdrant-client", "rdflib", "redis", "requests-toolbelt", "sentence-transformers", "singlestoredb", "spacy", "steamship", "tensorflow-text", "tigrisdb", "tiktoken", "torch", "transformers", "weaviate-client", "wikipedia", "wolframalpha"] azure = ["azure-ai-formrecognizer", "azure-ai-vision", "azure-cognitiveservices-speech", "azure-core", "azure-cosmos", "azure-identity", "azure-search-documents", "openai"] clarifai = ["clarifai"] cohere = ["cohere"] @@ -13086,4 +13097,4 @@ text-helpers = ["chardet"] [metadata] lock-version = "2.0" python-versions = ">=3.8.1,<4.0" -content-hash = "d631df3257527078121e16d471f9e58c3ea60a5f68a07452e7f30fe110e784b0" +content-hash = "4991cc709361500a231d7a2bdb1ad43d39e6002fc8baea4f4d7429dbc486bdd5" diff --git a/pyproject.toml b/pyproject.toml index f102c2820c188..feeaa62af1f6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -122,6 +122,7 @@ sympy = {version = "^1.12", optional = true} rapidfuzz = {version = "^3.1.1", optional = true} langsmith = "~0.0.11" rank-bm25 = {version = "^0.2.2", optional = true} +amadeus = {version = ">=8.1.0", optional = true} geopandas = {version = "^0.13.1", optional = true} [tool.poetry.group.docs.dependencies] @@ -327,6 +328,7 @@ all = [ "esprima", "octoai-sdk", "rdflib", + "amadeus", ] # An extra used to be able to add extended testing. diff --git a/tests/unit_tests/tools/test_signatures.py b/tests/unit_tests/tools/test_signatures.py index c0930ee99f38d..4c1da32e5ea74 100644 --- a/tests/unit_tests/tools/test_signatures.py +++ b/tests/unit_tests/tools/test_signatures.py @@ -7,6 +7,7 @@ import pytest +from langchain.tools.amadeus.base import AmadeusBaseTool from langchain.tools.base import BaseTool from langchain.tools.gmail.base import GmailBaseTool from langchain.tools.office365.base import O365BaseTool @@ -15,6 +16,7 @@ def get_non_abstract_subclasses(cls: Type[BaseTool]) -> List[Type[BaseTool]]: to_skip = { + AmadeusBaseTool, BaseBrowserTool, GmailBaseTool, O365BaseTool,