diff --git a/README.md b/README.md index 15692d92a..9fe36460c 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,12 @@ git clone git@github.com:openchatai/OpenCopilot.git OPENAI_API_KEY=YOUR_TOKEN_HERE ``` +- gpt-4: Ideal for more complex tasks, but may have slower processing times. +- gpt-3.5-turbo-16k: This model is significantly faster compared to GPT-4. +```env +PLAN_AND_EXECUTE_MODEL=gpt-3.5-turbo-16k +``` + - After updating your API key, navigate to the repository folder and run the following command (for macOS or Linux): ``` diff --git a/docker-compose.yml b/docker-compose.yml index 03f91bbb4..d8304ccc2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,8 @@ services: - opencopilot_network env_file: - llm-server/.env + ports: + - 8002:8002 depends_on: - mongodb - qdrant diff --git a/llm-server/.env.example b/llm-server/.env.example index 41c9a9aa1..959d3706c 100644 --- a/llm-server/.env.example +++ b/llm-server/.env.example @@ -12,4 +12,10 @@ STORE=QDRANT LANGCHAIN_TRACING_V2=true LANGCHAIN_ENDPOINT="https://api.smith.langchain.com" LANGCHAIN_API_KEY="TOKEN_GOES_HERE" -LANGCHAIN_PROJECT="PROJECT_NAME_GOES_HERE" \ No newline at end of file +LANGCHAIN_PROJECT="PROJECT_NAME_GOES_HERE" + + +# use gpt-4 for more complicated tasks, but it is usually much slower than 3.5-turbo family +# gpt-3.5-turbo-16k - this model is many times faster than gpt-4, gpt-4 is more accurate with self observation +PLAN_AND_EXECUTE_MODEL=gpt-3.5-turbo-16k +VECTOR_DB_THRESHOLD=0.88 \ No newline at end of file diff --git a/llm-server/Dockerfile b/llm-server/Dockerfile index 8a3afbaed..c35324c3b 100644 --- a/llm-server/Dockerfile +++ b/llm-server/Dockerfile @@ -15,5 +15,5 @@ COPY . /app/ EXPOSE 8002 -# Run app.py when the container launches -CMD ["python", "app.py"] +# -u To prevent log accumulation, execute app.py with unbuffered output as soon as the container starts up. +CMD ["python", "-u" ,"app.py"] diff --git a/llm-server/api_caller/planner.py b/llm-server/api_caller/planner.py new file mode 100644 index 000000000..d218265b2 --- /dev/null +++ b/llm-server/api_caller/planner.py @@ -0,0 +1,363 @@ +# Till this is merged / fixed we will be using our custom planner + +"""Agent that interacts with OpenAPI APIs via a hierarchical planning approach.""" +import json +import re +from functools import partial +from typing import Any, Callable, Dict, List, Optional + +import yaml + +from langchain.agents.agent import AgentExecutor +from langchain.agents.agent_toolkits.openapi.planner_prompt import ( + API_CONTROLLER_PROMPT, + API_CONTROLLER_TOOL_DESCRIPTION, + API_CONTROLLER_TOOL_NAME, + API_ORCHESTRATOR_PROMPT, + API_PLANNER_PROMPT, + API_PLANNER_TOOL_DESCRIPTION, + API_PLANNER_TOOL_NAME, + PARSING_DELETE_PROMPT, + PARSING_GET_PROMPT, + PARSING_PATCH_PROMPT, + PARSING_POST_PROMPT, + PARSING_PUT_PROMPT, + REQUESTS_DELETE_TOOL_DESCRIPTION, + REQUESTS_GET_TOOL_DESCRIPTION, + REQUESTS_PATCH_TOOL_DESCRIPTION, + REQUESTS_POST_TOOL_DESCRIPTION, + REQUESTS_PUT_TOOL_DESCRIPTION, +) +from langchain.agents.agent_toolkits.openapi.spec import ReducedOpenAPISpec +from langchain.agents.mrkl.base import ZeroShotAgent +from langchain.agents.tools import Tool +from langchain.callbacks.base import BaseCallbackManager +from langchain.chains.llm import LLMChain +from langchain.llms.openai import OpenAI +from langchain.memory import ReadOnlySharedMemory +from langchain.prompts import PromptTemplate +from langchain.pydantic_v1 import Field +from langchain.schema import BasePromptTemplate +from langchain.schema.language_model import BaseLanguageModel +from langchain.tools.base import BaseTool +from langchain.tools.requests.tool import BaseRequestsTool +from langchain.utilities.requests import RequestsWrapper + +# +# Requests tools with LLM-instructed extraction of truncated responses. +# +# Of course, truncating so bluntly may lose a lot of valuable +# information in the response. +# However, the goal for now is to have only a single inference step. +MAX_RESPONSE_LENGTH = 5000 +"""Maximum length of the response to be returned.""" + + +def _get_default_llm_chain(prompt: BasePromptTemplate) -> LLMChain: + return LLMChain( + llm=OpenAI(), + prompt=prompt, + ) + + +def _get_default_llm_chain_factory( + prompt: BasePromptTemplate, +) -> Callable[[], LLMChain]: + """Returns a default LLMChain factory.""" + return partial(_get_default_llm_chain, prompt) + + +class RequestsGetToolWithParsing(BaseRequestsTool, BaseTool): + """Requests GET tool with LLM-instructed extraction of truncated responses.""" + + name: str = "requests_get" + """Tool name.""" + description = REQUESTS_GET_TOOL_DESCRIPTION + """Tool description.""" + response_length: Optional[int] = MAX_RESPONSE_LENGTH + """Maximum length of the response to be returned.""" + llm_chain: LLMChain = Field( + default_factory=_get_default_llm_chain_factory(PARSING_GET_PROMPT) + ) + """LLMChain used to extract the response.""" + + def _run(self, text: str) -> str: + try: + data = json.loads(text) + except json.JSONDecodeError as e: + raise e + data_params = data.get("params") + response = self.requests_wrapper.get(data["url"], params=data_params) + response = response[: self.response_length] + return self.llm_chain.predict( + response=response, instructions=data["output_instructions"] + ).strip() + + async def _arun(self, text: str) -> str: + raise NotImplementedError() + + +class RequestsPostToolWithParsing(BaseRequestsTool, BaseTool): + """Requests POST tool with LLM-instructed extraction of truncated responses.""" + + name: str = "requests_post" + """Tool name.""" + description = REQUESTS_POST_TOOL_DESCRIPTION + """Tool description.""" + response_length: Optional[int] = MAX_RESPONSE_LENGTH + """Maximum length of the response to be returned.""" + llm_chain: LLMChain = Field( + default_factory=_get_default_llm_chain_factory(PARSING_POST_PROMPT) + ) + """LLMChain used to extract the response.""" + + def _run(self, text: str) -> str: + try: + data = json.loads(text) + except json.JSONDecodeError as e: + raise e + response = self.requests_wrapper.post(data["url"], data["data"]) + response = response[: self.response_length] + return self.llm_chain.predict( + response=response, instructions=data["output_instructions"] + ).strip() + + async def _arun(self, text: str) -> str: + raise NotImplementedError() + + +class RequestsPatchToolWithParsing(BaseRequestsTool, BaseTool): + """Requests PATCH tool with LLM-instructed extraction of truncated responses.""" + + name: str = "requests_patch" + """Tool name.""" + description = REQUESTS_PATCH_TOOL_DESCRIPTION + """Tool description.""" + response_length: Optional[int] = MAX_RESPONSE_LENGTH + """Maximum length of the response to be returned.""" + llm_chain: LLMChain = Field( + default_factory=_get_default_llm_chain_factory(PARSING_PATCH_PROMPT) + ) + """LLMChain used to extract the response.""" + + def _run(self, text: str) -> str: + try: + data = json.loads(text) + except json.JSONDecodeError as e: + raise e + response = self.requests_wrapper.patch(data["url"], data["data"]) + response = response[: self.response_length] + return self.llm_chain.predict( + response=response, instructions=data["output_instructions"] + ).strip() + + async def _arun(self, text: str) -> str: + raise NotImplementedError() + + +class RequestsPutToolWithParsing(BaseRequestsTool, BaseTool): + """Requests PUT tool with LLM-instructed extraction of truncated responses.""" + + name: str = "requests_put" + """Tool name.""" + description = REQUESTS_PUT_TOOL_DESCRIPTION + """Tool description.""" + response_length: Optional[int] = MAX_RESPONSE_LENGTH + """Maximum length of the response to be returned.""" + llm_chain: LLMChain = Field( + default_factory=_get_default_llm_chain_factory(PARSING_PUT_PROMPT) + ) + """LLMChain used to extract the response.""" + + def _run(self, text: str) -> str: + try: + data = json.loads(text) + except json.JSONDecodeError as e: + raise e + response = self.requests_wrapper.put(data["url"], data["data"]) + response = response[: self.response_length] + return self.llm_chain.predict( + response=response, instructions=data["output_instructions"] + ).strip() + + async def _arun(self, text: str) -> str: + raise NotImplementedError() + + +class RequestsDeleteToolWithParsing(BaseRequestsTool, BaseTool): + """A tool that sends a DELETE request and parses the response.""" + + name: str = "requests_delete" + """The name of the tool.""" + description = REQUESTS_DELETE_TOOL_DESCRIPTION + """The description of the tool.""" + + response_length: Optional[int] = MAX_RESPONSE_LENGTH + """The maximum length of the response.""" + llm_chain: LLMChain = Field( + default_factory=_get_default_llm_chain_factory(PARSING_DELETE_PROMPT) + ) + """The LLM chain used to parse the response.""" + + def _run(self, text: str) -> str: + try: + data = json.loads(text) + except json.JSONDecodeError as e: + raise e + response = self.requests_wrapper.delete(data["url"]) + response = response[: self.response_length] + return self.llm_chain.predict( + response=response, instructions=data["output_instructions"] + ).strip() + + async def _arun(self, text: str) -> str: + raise NotImplementedError() + + +# +# Orchestrator, planner, controller. +# +def _create_api_planner_tool( + api_spec: ReducedOpenAPISpec, llm: BaseLanguageModel +) -> Tool: + endpoint_descriptions = [ + f"{name} {description}" for name, description, _ in api_spec.endpoints + ] + prompt = PromptTemplate( + template=API_PLANNER_PROMPT, + input_variables=["query"], + partial_variables={"endpoints": "- " + "- ".join(endpoint_descriptions)}, + ) + chain = LLMChain(llm=llm, prompt=prompt) + tool = Tool( + name=API_PLANNER_TOOL_NAME, + description=API_PLANNER_TOOL_DESCRIPTION, + func=chain.run, + ) + return tool + + +def _create_api_controller_agent( + api_url: str, + api_docs: str, + requests_wrapper: RequestsWrapper, + llm: BaseLanguageModel, +) -> AgentExecutor: + get_llm_chain = LLMChain(llm=llm, prompt=PARSING_GET_PROMPT) + post_llm_chain = LLMChain(llm=llm, prompt=PARSING_POST_PROMPT) + tools: List[BaseTool] = [ + RequestsGetToolWithParsing( + requests_wrapper=requests_wrapper, llm_chain=get_llm_chain + ), + RequestsPostToolWithParsing( + requests_wrapper=requests_wrapper, llm_chain=post_llm_chain + ), + ] + prompt = PromptTemplate( + template=API_CONTROLLER_PROMPT, + input_variables=["input", "agent_scratchpad"], + partial_variables={ + "api_url": api_url, + "api_docs": api_docs, + "tool_names": ", ".join([tool.name for tool in tools]), + "tool_descriptions": "\n".join( + [f"{tool.name}: {tool.description}" for tool in tools] + ), + }, + ) + agent = ZeroShotAgent( + llm_chain=LLMChain(llm=llm, prompt=prompt), + allowed_tools=[tool.name for tool in tools], + ) + return AgentExecutor.from_agent_and_tools(agent=agent, tools=tools, verbose=True) + + +def _create_api_controller_tool( + api_spec: ReducedOpenAPISpec, + requests_wrapper: RequestsWrapper, + llm: BaseLanguageModel, +) -> Tool: + """Expose controller as a tool. + + The tool is invoked with a plan from the planner, and dynamically + creates a controller agent with relevant documentation only to + constrain the context. + """ + + base_url = api_spec.servers[0]["url"] # TODO: do better. + + def _create_and_run_api_controller_agent(plan_str: str) -> str: + pattern = r"\b(GET|POST|PATCH|DELETE)\s+(/\S+)*" + matches = re.findall(pattern, plan_str) + endpoint_names = [ + "{method} {route}".format(method=method, route=route.split("?")[0]) + for method, route in matches + ] + endpoint_docs_by_name = {name: docs for name, _, docs in api_spec.endpoints} + docs_str = "" + for endpoint_name in endpoint_names: + found_match = False + for name, _, docs in api_spec.endpoints: + regex_name = re.compile(re.sub("\{.*?\}", ".*", name)) + if regex_name.match(endpoint_name): + found_match = True + docs_str += f"== Docs for {endpoint_name} == \n{yaml.dump(docs)}\n" + if not found_match: + raise ValueError(f"{endpoint_name} endpoint does not exist.") + docs_str += f"== Docs for {endpoint_name} == \n{yaml.dump(docs)}\n" + + agent = _create_api_controller_agent(base_url, docs_str, requests_wrapper, llm) + return agent.run(plan_str) + + return Tool( + name=API_CONTROLLER_TOOL_NAME, + func=_create_and_run_api_controller_agent, + description=API_CONTROLLER_TOOL_DESCRIPTION, + ) + + +def create_openapi_agent( + api_spec: ReducedOpenAPISpec, + requests_wrapper: RequestsWrapper, + llm: BaseLanguageModel, + shared_memory: Optional[ReadOnlySharedMemory] = None, + callback_manager: Optional[BaseCallbackManager] = None, + verbose: bool = True, + agent_executor_kwargs: Optional[Dict[str, Any]] = None, + **kwargs: Dict[str, Any], +) -> AgentExecutor: + """Instantiate OpenAI API planner and controller for a given spec. + + Inject credentials via requests_wrapper. + + We use a top-level "orchestrator" agent to invoke the planner and controller, + rather than a top-level planner + that invokes a controller with its plan. This is to keep the planner simple. + """ + tools = [ + _create_api_planner_tool(api_spec, llm), + _create_api_controller_tool(api_spec, requests_wrapper, llm), + ] + prompt = PromptTemplate( + template=API_ORCHESTRATOR_PROMPT, + input_variables=["input", "agent_scratchpad"], + partial_variables={ + "tool_names": ", ".join([tool.name for tool in tools]), + "tool_descriptions": "\n".join( + [f"{tool.name}: {tool.description}" for tool in tools] + ), + }, + ) + agent = ZeroShotAgent( + llm_chain=LLMChain(llm=llm, prompt=prompt, memory=shared_memory), + allowed_tools=[tool.name for tool in tools], + **kwargs, + ) + return AgentExecutor.from_agent_and_tools( + agent=agent, + tools=tools, + callback_manager=callback_manager, + verbose=verbose, + maxIterations=2, + **(agent_executor_kwargs or {}), + ) diff --git a/llm-server/app.py b/llm-server/app.py index 13f406cce..83af2b667 100644 --- a/llm-server/app.py +++ b/llm-server/app.py @@ -16,15 +16,20 @@ from prompts.base import api_base_prompt, non_api_base_prompt from routes.workflow.workflow_service import run_workflow from routes.workflow.typings.run_workflow_input import WorkflowData -from utils.detect_multiple_intents import hasMultipleIntents, hasSingleIntent +from utils.detect_multiple_intents import hasSingleIntent, hasMultipleIntents +import os +from dotenv import load_dotenv + +load_dotenv() +shared_folder = os.getenv("SHARED_FOLDER", "/app/shared_data/") logging.basicConfig(level=logging.DEBUG) + app = Flask(__name__) app.register_blueprint(workflow, url_prefix="/workflow") - ## TODO: Implement caching for the swagger file content (no need to load it everytime) @app.route("/handle", methods=["POST", "OPTIONS"]) def handle(): @@ -44,25 +49,31 @@ def handle(): if not swagger_url: return json.dumps({"error": "swagger_url is required"}), 400 + if swagger_url.startswith("https://"): + pass + else: + swagger_url = shared_folder + swagger_url + + print(f"swagger_url::{swagger_url}") try: - if not hasSingleIntent(swagger_url, text): - return run_workflow( + if hasMultipleIntents(text): + result = run_workflow( WorkflowData(text, swagger_url, headers, server_base_url) ) + + return result except Exception as e: - print(f"Using agent: {e}") + raise e if swagger_url.startswith("https://"): - full_url = swagger_url - response = requests.get(full_url) + response = requests.get(swagger_url) if response.status_code == 200: swagger_text = response.text else: return json.dumps({"error": "Failed to fetch Swagger content"}), 500 else: - full_url = "/app/shared_data/" + swagger_url try: - with open(full_url, "r") as file: + with open(swagger_url, "r") as file: swagger_text = file.read() except FileNotFoundError: return json.dumps({"error": "File not found"}), 404 diff --git a/llm-server/docs/petstore_fixed.json b/llm-server/docs/petstore_fixed.json new file mode 100644 index 000000000..c2c870519 --- /dev/null +++ b/llm-server/docs/petstore_fixed.json @@ -0,0 +1,1225 @@ +{ + "openapi": "3.0.2", + "info": { + "title": "Swagger Petstore - OpenAPI 3.0", + "description": "This is a sample Pet Store Server based on the OpenAPI 3.0 specification. You can find out more about\nSwagger at [http://swagger.io](http://swagger.io). In the third iteration of the pet store, we've switched to the design first approach!\nYou can now help us improve the API whether it's by making changes to the definition itself or to the code.\nThat way, with time, we can improve the API in general, and expose some of the new features in OAS3.\n\nSome useful links:\n- [The Pet Store repository](https://github.com/swagger-api/swagger-petstore)\n- [The source API definition for the Pet Store](https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml)", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "email": "apiteam@swagger.io" + }, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "1.0.17" + }, + "externalDocs": { + "description": "Find out more about Swagger", + "url": "http://swagger.io" + }, + "servers": [ + { + "url": "https://petstore3.swagger.io/api/v3" + } + ], + "tags": [ + { + "name": "pet", + "description": "Everything about your Pets", + "externalDocs": { + "description": "Find out more", + "url": "http://swagger.io" + } + }, + { + "name": "store", + "description": "Access to Petstore orders", + "externalDocs": { + "description": "Find out more about our store", + "url": "http://swagger.io" + } + }, + { + "name": "user", + "description": "Operations about user" + } + ], + "paths": { + "/pet": { + "put": { + "tags": [ + "pet" + ], + "summary": "Update an existing pet", + "description": "Update an existing pet by Id", + "operationId": "updatePet", + "requestBody": { + "description": "Update an existent pet in the store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful operation", + "content": { + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "400": { + "description": "Invalid ID supplied" + }, + "404": { + "description": "Pet not found" + }, + "405": { + "description": "Validation exception" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + }, + "post": { + "tags": [ + "pet" + ], + "summary": "Add a new pet to the store", + "description": "Add a new pet to the store", + "operationId": "addPet", + "requestBody": { + "description": "Create a new pet in the store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful operation", + "content": { + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "405": { + "description": "Invalid input" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + } + }, + "/pet/findByStatus": { + "get": { + "tags": [ + "pet" + ], + "summary": "Finds Pets by status", + "description": "Multiple status values can be provided with comma separated strings", + "operationId": "findPetsByStatus", + "parameters": [ + { + "name": "status", + "in": "query", + "description": "Status values that need to be considered for filter", + "required": false, + "explode": true, + "schema": { + "type": "string", + "default": "available", + "enum": [ + "available", + "pending", + "sold" + ] + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/xml": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + } + } + } + }, + "400": { + "description": "Invalid status value" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + } + }, + "/pet/findByTags": { + "get": { + "tags": [ + "pet" + ], + "summary": "Finds Pets by tags", + "description": "Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing.", + "operationId": "findPetsByTags", + "parameters": [ + { + "name": "tags", + "in": "query", + "description": "Tags to filter by", + "required": false, + "explode": true, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/xml": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + } + } + } + }, + "400": { + "description": "Invalid tag value" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + } + }, + "/pet/{petId}": { + "get": { + "tags": [ + "pet" + ], + "summary": "Find pet by ID", + "description": "Returns a single pet", + "operationId": "getPetById", + "parameters": [ + { + "name": "petId", + "in": "path", + "description": "ID of pet to return", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "400": { + "description": "Invalid ID supplied" + }, + "404": { + "description": "Pet not found" + } + }, + "security": [ + { + "api_key": [] + }, + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + }, + "post": { + "tags": [ + "pet" + ], + "summary": "Updates a pet in the store with form data", + "description": "", + "operationId": "updatePetWithForm", + "parameters": [ + { + "name": "petId", + "in": "path", + "description": "ID of pet that needs to be updated", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "name", + "in": "query", + "description": "Name of pet that needs to be updated", + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "description": "Status of pet that needs to be updated", + "schema": { + "type": "string" + } + } + ], + "responses": { + "405": { + "description": "Invalid input" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + }, + "delete": { + "tags": [ + "pet" + ], + "summary": "Deletes a pet", + "description": "", + "operationId": "deletePet", + "parameters": [ + { + "name": "api_key", + "in": "header", + "description": "", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "petId", + "in": "path", + "description": "Pet id to delete", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "400": { + "description": "Invalid pet value" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + } + }, + "/pet/{petId}/uploadImage": { + "post": { + "tags": [ + "pet" + ], + "summary": "uploads an image", + "description": "", + "operationId": "uploadFile", + "parameters": [ + { + "name": "petId", + "in": "path", + "description": "ID of pet to update", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "additionalMetadata", + "in": "query", + "description": "Additional Metadata", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + } + }, + "/store/inventory": { + "get": { + "tags": [ + "store" + ], + "summary": "Returns pet inventories by status", + "description": "Returns a map of status codes to quantities", + "operationId": "getInventory", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + } + } + } + } + } + }, + "security": [ + { + "api_key": [] + } + ] + } + }, + "/store/order": { + "post": { + "tags": [ + "store" + ], + "summary": "Place an order for a pet", + "description": "Place a new order in the store", + "operationId": "placeOrder", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Order" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + } + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + } + }, + "405": { + "description": "Invalid input" + } + } + } + }, + "/store/order/{orderId}": { + "get": { + "tags": [ + "store" + ], + "summary": "Find purchase order by ID", + "description": "For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions.", + "operationId": "getOrderById", + "parameters": [ + { + "name": "orderId", + "in": "path", + "description": "ID of order that needs to be fetched", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Order" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + } + }, + "400": { + "description": "Invalid ID supplied" + }, + "404": { + "description": "Order not found" + } + } + }, + "delete": { + "tags": [ + "store" + ], + "summary": "Delete purchase order by ID", + "description": "For valid response try integer IDs with value < 1000. Anything above 1000 or nonintegers will generate API errors", + "operationId": "deleteOrder", + "parameters": [ + { + "name": "orderId", + "in": "path", + "description": "ID of the order that needs to be deleted", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "400": { + "description": "Invalid ID supplied" + }, + "404": { + "description": "Order not found" + } + } + } + }, + "/user": { + "post": { + "tags": [ + "user" + ], + "summary": "Create user", + "description": "This can only be done by the logged in user.", + "operationId": "createUser", + "requestBody": { + "description": "Created user object", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "responses": { + "default": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + } + }, + "/user/createWithList": { + "post": { + "tags": [ + "user" + ], + "summary": "Creates list of users with given input array", + "description": "Creates list of users with given input array", + "operationId": "createUsersWithListInput", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + } + } + } + }, + "responses": { + "200": { + "description": "Successful operation", + "content": { + "application/xml": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "default": { + "description": "successful operation" + } + } + } + }, + "/user/login": { + "get": { + "tags": [ + "user" + ], + "summary": "Logs user into the system", + "description": "", + "operationId": "loginUser", + "parameters": [ + { + "name": "username", + "in": "query", + "description": "The user name for login", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "password", + "in": "query", + "description": "The password for login in clear text", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "headers": { + "X-Rate-Limit": { + "description": "calls per hour allowed by the user", + "schema": { + "type": "integer", + "format": "int32" + } + }, + "X-Expires-After": { + "description": "date in UTC when token expires", + "schema": { + "type": "string", + "format": "date-time" + } + } + }, + "content": { + "application/xml": { + "schema": { + "type": "string" + } + }, + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "Invalid username/password supplied" + } + } + } + }, + "/user/logout": { + "get": { + "tags": [ + "user" + ], + "summary": "Logs out current logged in user session", + "description": "", + "operationId": "logoutUser", + "parameters": [], + "responses": { + "default": { + "description": "successful operation" + } + } + } + }, + "/user/{username}": { + "get": { + "tags": [ + "user" + ], + "summary": "Get user by user name", + "description": "", + "operationId": "getUserByName", + "parameters": [ + { + "name": "username", + "in": "path", + "description": "The name that needs to be fetched. Use user1 for testing. ", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/xml": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "400": { + "description": "Invalid username supplied" + }, + "404": { + "description": "User not found" + } + } + }, + "put": { + "tags": [ + "user" + ], + "summary": "Update user", + "description": "This can only be done by the logged in user.", + "operationId": "updateUser", + "parameters": [ + { + "name": "username", + "in": "path", + "description": "name that need to be deleted", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Update an existent user in the store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "responses": { + "default": { + "description": "successful operation" + } + } + }, + "delete": { + "tags": [ + "user" + ], + "summary": "Delete user", + "description": "This can only be done by the logged in user.", + "operationId": "deleteUser", + "parameters": [ + { + "name": "username", + "in": "path", + "description": "The name that needs to be deleted", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "description": "Invalid username supplied" + }, + "404": { + "description": "User not found" + } + } + } + } + }, + "components": { + "schemas": { + "Order": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 10 + }, + "petId": { + "type": "integer", + "format": "int64", + "example": 198772 + }, + "quantity": { + "type": "integer", + "format": "int32", + "example": 7 + }, + "shipDate": { + "type": "string", + "format": "date-time" + }, + "status": { + "type": "string", + "description": "Order Status", + "example": "approved", + "enum": [ + "placed", + "approved", + "delivered" + ] + }, + "complete": { + "type": "boolean" + } + }, + "xml": { + "name": "order" + } + }, + "Customer": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 100000 + }, + "username": { + "type": "string", + "example": "fehguy" + }, + "address": { + "type": "array", + "xml": { + "name": "addresses", + "wrapped": true + }, + "items": { + "$ref": "#/components/schemas/Address" + } + } + }, + "xml": { + "name": "customer" + } + }, + "Address": { + "type": "object", + "properties": { + "street": { + "type": "string", + "example": "437 Lytton" + }, + "city": { + "type": "string", + "example": "Palo Alto" + }, + "state": { + "type": "string", + "example": "CA" + }, + "zip": { + "type": "string", + "example": "94301" + } + }, + "xml": { + "name": "address" + } + }, + "Category": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 1 + }, + "name": { + "type": "string", + "example": "Dogs" + } + }, + "xml": { + "name": "category" + } + }, + "User": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 10 + }, + "username": { + "type": "string", + "example": "theUser" + }, + "firstName": { + "type": "string", + "example": "John" + }, + "lastName": { + "type": "string", + "example": "James" + }, + "email": { + "type": "string", + "example": "john@email.com" + }, + "password": { + "type": "string", + "example": "12345" + }, + "phone": { + "type": "string", + "example": "12345" + }, + "userStatus": { + "type": "integer", + "description": "User Status", + "format": "int32", + "example": 1 + } + }, + "xml": { + "name": "user" + } + }, + "Tag": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + } + }, + "xml": { + "name": "tag" + } + }, + "Pet": { + "required": [ + "name", + "photoUrls" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 10 + }, + "name": { + "type": "string", + "example": "doggie" + }, + "category": { + "$ref": "#/components/schemas/Category" + }, + "photoUrls": { + "type": "array", + "xml": { + "wrapped": true + }, + "items": { + "type": "string", + "xml": { + "name": "photoUrl" + } + } + }, + "tags": { + "type": "array", + "xml": { + "wrapped": true + }, + "items": { + "$ref": "#/components/schemas/Tag" + } + }, + "status": { + "type": "string", + "description": "pet status in the store", + "enum": [ + "available", + "pending", + "sold" + ] + } + }, + "xml": { + "name": "pet" + } + }, + "ApiResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "xml": { + "name": "##default" + } + } + }, + "requestBodies": { + "Pet": { + "description": "Pet object that needs to be added to the store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "UserArray": { + "description": "List of user object", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + }, + "securitySchemes": { + "petstore_auth": { + "type": "oauth2", + "flows": { + "implicit": { + "authorizationUrl": "https://petstore3.swagger.io/oauth/authorize", + "scopes": { + "write:pets": "modify pets in your account", + "read:pets": "read your pets" + } + } + } + }, + "api_key": { + "type": "apiKey", + "name": "api_key", + "in": "header" + } + } + } +} \ No newline at end of file diff --git a/llm-server/requirements.txt b/llm-server/requirements.txt index 36539297c..12b1d7bf6 100644 --- a/llm-server/requirements.txt +++ b/llm-server/requirements.txt @@ -1,6 +1,6 @@ aiohttp==3.8.5 aiosignal==1.3.1 -anyio==4.0.0 +anyio==3.7.1 asgiref==3.7.2 async-timeout==4.0.2 attrs==23.1.0 @@ -9,11 +9,13 @@ blinker==1.6.2 blis==0.7.9 catalogue==2.0.8 certifi==2023.7.22 +chardet==5.2.0 charset-normalizer==3.2.0 click==8.1.5 confection==0.1.0 cymem==2.0.7 dataclasses-json==0.5.9 +deptry==0.12.0 dill==0.3.6 dnspython==2.4.2 elastic-transport==8.4.0 @@ -39,20 +41,26 @@ huggingface-hub==0.16.4 hyperframe==6.0.1 idna==3.4 importlib-metadata==6.8.0 +isodate==0.6.1 itsdangerous==2.1.2 Jinja2==3.1.2 joblib==1.3.1 +jsonpatch==1.33 +jsonpointer==2.4 jsonschema==4.19.0 +jsonschema-spec==0.2.4 jsonschema-specifications==2023.7.1 -langchain==0.0.232 +langchain==0.0.300 langcodes==3.3.0 -litellm>=0.1.574 -langsmith==0.0.5 +langsmith==0.0.40 +lazy-object-proxy==1.9.0 +litellm==0.1.729 loguru==0.7.1 manifest-ml==0.0.1 MarkupSafe==2.1.3 marshmallow==3.19.0 marshmallow-enum==1.5.1 +more-itertools==10.1.0 multidict==6.0.4 murmurhash==1.0.9 mypy-extensions==0.4.3 @@ -61,7 +69,12 @@ numexpr==2.8.4 numpy==1.25.1 openai==0.27.8 openapi-schema-pydantic==1.2.4 +openapi-schema-validator==0.6.0 +openapi-spec-validator==0.6.0 packaging==23.1 +parse==1.19.1 +pathable==0.4.3 +pathspec==0.11.2 pathy==0.10.2 pinecone-client==2.2.2 portalocker==2.7.0 @@ -77,6 +90,7 @@ redis==4.6.0 referencing==0.30.2 regex==2023.6.3 requests==2.31.0 +rfc3339-validator==0.1.4 rpds-py==0.10.2 safetensors==0.3.1 six==1.16.0 diff --git a/llm-server/routes/workflow/hierarchical_planner.py b/llm-server/routes/workflow/hierarchical_planner.py new file mode 100644 index 000000000..6162b2cf0 --- /dev/null +++ b/llm-server/routes/workflow/hierarchical_planner.py @@ -0,0 +1,36 @@ +from typing import Dict, Any + +from langchain.agents.agent_toolkits.openapi.spec import reduce_openapi_spec +from routes.workflow.load_openapi_spec import load_openapi_spec +from langchain.requests import RequestsWrapper +from langchain.llms.openai import OpenAI + +# from langchain.agents.agent_toolkits.openapi import planner # This is a custom planner, because of issue in langchains current implementation of planner, we will track this +from api_caller import planner + + +import os + +PLAN_AND_EXECUTE_MODEL = os.getenv("PLAN_AND_EXECUTE_MODEL", "gpt-4") + + +def create_and_run_openapi_agent( + spec_path: str, user_query: str, headers: Dict[str, str] = {} +) -> Any: + # Load OpenAPI spec + raw_spec = load_openapi_spec(spec_path) + spec = reduce_openapi_spec(raw_spec) + + # Create RequestsWrapper with auth + requests_wrapper: RequestsWrapper = RequestsWrapper(headers=headers) + + print( + f"Using {PLAN_AND_EXECUTE_MODEL} for plan and execute agent, you can change it by setting PLAN_AND_EXECUTE_MODEL variable" + ) + # Create OpenAPI agent + llm: OpenAI = OpenAI(model_name=PLAN_AND_EXECUTE_MODEL, temperature=0.0) + agent = planner.create_openapi_agent(spec, requests_wrapper, llm) + + # Run agent on user query + response = agent.run(user_query) + return response diff --git a/llm-server/routes/workflow/load_openapi_spec.py b/llm-server/routes/workflow/load_openapi_spec.py index a244f4459..625149335 100644 --- a/llm-server/routes/workflow/load_openapi_spec.py +++ b/llm-server/routes/workflow/load_openapi_spec.py @@ -37,7 +37,6 @@ def load_spec_from_url(url: str) -> Any: def load_spec_from_file(file_path: str) -> Any: file_extension = os.path.splitext(file_path)[1].lower() - file_path = "/app/shared_data/" + file_path if file_extension == ".json": with open(file_path, "r") as file: return json.load(file) diff --git a/llm-server/routes/workflow/typings/run_workflow_input.py b/llm-server/routes/workflow/typings/run_workflow_input.py index c611479be..9c2ed50f7 100644 --- a/llm-server/routes/workflow/typings/run_workflow_input.py +++ b/llm-server/routes/workflow/typings/run_workflow_input.py @@ -1,11 +1,9 @@ -class Headers: - def __init__(self) -> None: - self.data: dict[str, str] = {} +from typing import Dict class WorkflowData: def __init__( - self, text: str, swagger_url: str, headers: Headers, server_base_url: str + self, text: str, swagger_url: str, headers: Dict[str, str], server_base_url: str ) -> None: self.text = text self.swagger_url = swagger_url diff --git a/llm-server/routes/workflow/workflow_service.py b/llm-server/routes/workflow/workflow_service.py index f7f73b71d..d49d83c54 100644 --- a/llm-server/routes/workflow/workflow_service.py +++ b/llm-server/routes/workflow/workflow_service.py @@ -10,6 +10,7 @@ from routes.workflow.typings.run_workflow_input import WorkflowData from langchain.tools.json.tool import JsonSpec from typing import List +from routes.workflow.hierarchical_planner import create_and_run_openapi_agent import json from typing import Any, Dict, Optional, cast, Union @@ -17,6 +18,10 @@ db_instance = Database() mongo = db_instance.get_db() +import os + +VECTOR_DB_THRESHOLD = float(os.getenv("VECTOR_DB_THRESHOLD", 0.88)) + def get_valid_url( api_payload: Dict[str, Union[str, None]], server_base_url: Optional[str] @@ -39,35 +44,41 @@ def get_valid_url( def run_workflow(data: WorkflowData) -> Any: text = data.text swagger_src = data.swagger_url - headers = data.headers - # This will come from request payload later on when implementing multi-tenancy + headers = data.headers or {} + # This will come from the request payload later on when implementing multi-tenancy namespace = "workflows" server_base_url = data.server_base_url if not text: return json.dumps({"error": "text is required"}), 400 - vector_store = get_vector_store(StoreOptions(namespace)) - # documents = vector_store.similarity_search(text) + try: + vector_store = get_vector_store(StoreOptions(namespace)) + (document, score) = vector_store.similarity_search_with_relevance_scores(text)[ + 0 + ] - (document, score) = vector_store.similarity_search_with_relevance_scores(text)[0] + if score > VECTOR_DB_THRESHOLD: + print( + f"Record '{document}' is highly similar with a similarity score of {score}" + ) + first_document_id = ( + ObjectId(document.metadata["workflow_id"]) if document else None + ) + record = mongo.workflows.find_one({"_id": first_document_id}) - if score > 0.9: - print( - f"Record '{document}' is highly similar with a similarity score of {document}" - ) - first_document_id = ( - ObjectId(document.metadata["workflow_id"]) if document else None - ) - record = mongo.workflows.find_one({"_id": first_document_id}) + result = run_openapi_operations( + record, swagger_src, text, headers, server_base_url + ) + return result - result = run_openapi_operations( - record, swagger_src, text, headers, server_base_url - ) - return result, 200, {"Content-Type": "application/json"} - else: - # call openapi spec - raise Exception("Workflow not defined for this request, try using an agent") + except Exception as e: + # Log the error, but continue with the rest of the code + print(f"Error fetching data from namespace '{namespace}': {str(e)}") + + # Call openapi spec even if an error occurred with Qdrant + result = create_and_run_openapi_agent(swagger_src, text, headers) + return {"response": result} def run_openapi_operations( diff --git a/llm-server/utils/detect_multiple_intents.py b/llm-server/utils/detect_multiple_intents.py index d1d1f4dbb..2449e8272 100644 --- a/llm-server/utils/detect_multiple_intents.py +++ b/llm-server/utils/detect_multiple_intents.py @@ -71,7 +71,7 @@ def getSummaries(spec_source: str): operation = paths[path] for field in operation: if "summary" in operation[field]: - summaries.append(operation[field]["summary"]) + summaries.append(operation[field]["operationId"]) return summaries @@ -82,7 +82,7 @@ def hasSingleIntent(spec_source: str, user_requirement: str) -> bool: User: Here is a list of API summaries: {summaries} - Considering the user's request outlined below, is it possible to fulfill their requirement with just one of the API calls listed above? Please reply with either "YES" or "NO" + Can one of these api's suffice the users request? Please reply with either "YES" or "NO" with explanation User requirement: {user_requirement} diff --git a/llm-server/utils/vector_db/get_vector_store.py b/llm-server/utils/vector_db/get_vector_store.py index 8570255e8..5c02cd899 100644 --- a/llm-server/utils/vector_db/get_vector_store.py +++ b/llm-server/utils/vector_db/get_vector_store.py @@ -30,6 +30,7 @@ def get_vector_store(options: StoreOptions) -> VectorStore: client = qdrant_client.QdrantClient( url=os.environ["QDRANT_URL"], prefer_grpc=True ) + vector_store = Qdrant( client, collection_name=options.namespace, embeddings=embedding )