diff --git a/README.md b/README.md index 9b68eb79b2..4994de97e0 100644 --- a/README.md +++ b/README.md @@ -359,6 +359,126 @@ Configure your settings using the table below. |AZURE_COSMOSDB_ENABLE_FEEDBACK|No|False|Whether or not to enable message feedback on chat history messages| +#### Enable Azure OpenAI function calling via Azure Functions + +Refer to this article to learn more about [function calling with Azure OpenAI Service](https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/function-calling). + +1. Update the `AZURE_OPENAI_*` environment variables as described in the [basic chat experience](#basic-chat-experience) above. + +2. Add any additional configuration (described in previous sections) needed for chatting with data, if required. + +3. To enable function calling via remote Azure Functions, you will need to set up an Azure Function resource. Refer to this [instruction guide](https://learn.microsoft.com/azure/azure-functions/functions-create-function-app-portal?pivots=programming-language-python) to create an Azure Function resource. + +4. You will need to create the following Azure Functions to implement function calling logic: + + * Create one function with routing, e.g. /tools, that will return a JSON array with the function definitions. + * Create a second function with routing, e.g. /tool, that will execute the functions with the given arguments. + The request body will be a JSON structure with the function name and arguments of the function to be executed. + Use this sample as function request body to test your function call: + + ``` + { + "tool_name" : "get_current_weather", + "tool_arguments" : {"location":"Lamego"} + } + ``` + + * Create functions without routing to implement all the functions defined in the JSON definition. + + Sample code for the Azure Functions: + + ``` + import azure.functions as func + import logging + import json + import random + + app = func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION) + + azure_openai_tools_json = """[{ + "type": "function", + "function": { + "name": "get_current_weather", + "description": "Get the current weather in a given location", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city name, e.g. San Francisco" + } + }, + "required": ["location"] + } + } + }]""" + + azure_openai_available_tools = ["get_current_weather"] + + @app.route(route="tools") + def tools(req: func.HttpRequest) -> func.HttpResponse: + logging.info('tools function processed a request.') + + return func.HttpResponse( + azure_openai_tools_json, + status_code=200 + ) + + @app.route(route="tool") + def tool(req: func.HttpRequest) -> func.HttpResponse: + logging.info('tool function processed a request.') + + tool_name = req.params.get('tool_name') + if not tool_name: + try: + req_body = req.get_json() + except ValueError: + pass + else: + tool_name = req_body.get('tool_name') + + tool_arguments = req.params.get('tool_arguments') + if not tool_arguments: + try: + req_body = req.get_json() + except ValueError: + pass + else: + tool_arguments = req_body.get('tool_arguments') + + if tool_name and tool_arguments: + if tool_name in azure_openai_available_tools: + logging.info('tool function: tool_name and tool_arguments are valid.') + result = globals()[tool_name](**tool_arguments) + return func.HttpResponse( + result, + status_code = 200 + ) + + logging.info('tool function: tool_name or tool_arguments are invalid.') + return func.HttpResponse( + "The tool function we executed successfully but the tool name or arguments were invalid. ", + status_code=400 + ) + + def get_current_weather(location: str) -> str: + logging.info('get_current_weather function processed a request.') + temperature = random.randint(10, 30) + weather = random.choice(["sunny", "cloudy", "rainy", "windy"]) + return f"The current weather in {location} is {temperature}°C and {weather}." + ``` + +4. Configure data source settings as described in the table below: + + | App Setting | Required? | Default Value | Note | + | ----------- | --------- | ------------- | ---- | + | AZURE_OPENAI_FUNCTION_CALL_AZURE_FUNCTIONS_ENABLED | No | | | + | AZURE_OPENAI_FUNCTION_CALL_AZURE_FUNCTIONS_TOOL_BASE_URL | Only if using function calling | | The base URL of your Azure Function "tool", e.g. [https://.azurewebsites.net/api/tool]() | + | AZURE_OPENAI_FUNCTION_CALL_AZURE_FUNCTIONS_TOOL_KEY | Only if using function calling | | The function key used to access the Azure Function "tool" | + | AZURE_OPENAI_FUNCTION_CALL_AZURE_FUNCTIONS_TOOLS_BASE_URL | Only if using function calling | | The base URL of your Azure Function "tools", e.g. [https://.azurewebsites.net/api/tools]() | + | AZURE_OPENAI_FUNCTION_CALL_AZURE_FUNCTIONS_TOOLS_KEY | Only if using function calling | | The function key used to access the Azure Function "tools" | + + #### Common Customization Scenarios (e.g. updating the default chat logo and headers) The interface allows for easy adaptation of the UI by modifying certain elements, such as the title and logo, through the use of the following environment variables. diff --git a/app.py b/app.py index 7dfa587c88..5ae8a7cd99 100644 --- a/app.py +++ b/app.py @@ -35,6 +35,7 @@ convert_to_pf_format, format_pf_non_streaming_response, ) +import requests bp = Blueprint("routes", __name__, static_folder="static", template_folder="static") @@ -111,6 +112,9 @@ async def assets(path): MS_DEFENDER_ENABLED = os.environ.get("MS_DEFENDER_ENABLED", "true").lower() == "true" +azure_openai_tools = [] +azure_openai_available_tools = [] + # Initialize Azure OpenAI Client async def init_openai_client(): azure_openai_client = None @@ -159,6 +163,19 @@ async def init_openai_client(): # Default Headers default_headers = {"x-ms-useragent": USER_AGENT} + # Remote function calls + if app_settings.azure_openai.function_call_azure_functions_enabled: + azure_functions_tools_url = f"{app_settings.azure_openai.function_call_azure_functions_tools_base_url}?code={app_settings.azure_openai.function_call_azure_functions_tools_key}" + response = requests.get(azure_functions_tools_url) + response_status_code = response.status_code + if response_status_code == requests.codes.ok: + azure_openai_tools.extend(json.loads(response.text)) + for tool in azure_openai_tools: + azure_openai_available_tools.append(tool["function"]["name"]) + else: + logging.error(f"An error occurred while getting OpenAI Function Call tools metadata: {response.status_code}") + + azure_openai_client = AsyncAzureOpenAI( api_version=app_settings.azure_openai.preview_api_version, api_key=aoai_api_key, @@ -173,6 +190,20 @@ async def init_openai_client(): azure_openai_client = None raise e +def openai_remote_azure_function_call(function_name, function_args): + if app_settings.azure_openai.function_call_azure_functions_enabled is not True: + return + + azure_functions_tool_url = f"{app_settings.azure_openai.function_call_azure_functions_tool_base_url}?code={app_settings.azure_openai.function_call_azure_functions_tool_key}" + headers = {'content-type': 'application/json'} + body = { + "tool_name": function_name, + "tool_arguments": json.loads(function_args) + } + response = requests.post(azure_functions_tool_url, data=json.dumps(body), headers=headers) + response.raise_for_status() + + return response.text async def init_cosmosdb_client(): cosmos_conversation_client = None @@ -219,22 +250,28 @@ def prepare_model_args(request_body, request_headers): for message in request_messages: if message: - if message["role"] == "assistant" and "context" in message: - context_obj = json.loads(message["context"]) - messages.append( - { - "role": message["role"], - "content": message["content"], - "context": context_obj - } - ) - else: - messages.append( - { - "role": message["role"], - "content": message["content"] - } - ) + match message["role"]: + case "user": + messages.append( + { + "role": message["role"], + "content": message["content"] + } + ) + case "assistant" | "function" | "tool": + messages_helper = {} + messages_helper["role"] = message["role"] + if "name" in message: + messages_helper["name"] = message["name"] + if "function_call" in message: + messages_helper["function_call"] = message["function_call"] + messages_helper["content"] = message["content"] + if "context" in message: + context_obj = json.loads(message["context"]) + messages_helper["context"] = context_obj + + messages.append(messages_helper) + user_json = None if (MS_DEFENDER_ENABLED): @@ -254,14 +291,18 @@ def prepare_model_args(request_body, request_headers): "user": user_json } - if app_settings.datasource: - model_args["extra_body"] = { - "data_sources": [ - app_settings.datasource.construct_payload_configuration( - request=request - ) - ] - } + if messages[-1]["role"] == "user": + if app_settings.azure_openai.function_call_azure_functions_enabled and len(azure_openai_tools) > 0: + model_args["tools"] = azure_openai_tools + + if app_settings.datasource: + model_args["extra_body"] = { + "data_sources": [ + app_settings.datasource.construct_payload_configuration( + request=request + ) + ] + } model_args_clean = copy.deepcopy(model_args) if model_args_clean.get("extra_body"): @@ -335,6 +376,43 @@ async def promptflow_request(request): logging.error(f"An error occurred while making promptflow_request: {e}") +def process_function_call(response): + response_message = response.choices[0].message + messages = [] + + if response_message.tool_calls: + for tool_call in response_message.tool_calls: + # Check if function exists + if tool_call.function.name not in azure_openai_available_tools: + continue + + function_response = openai_remote_azure_function_call(tool_call.function.name, tool_call.function.arguments) + + # adding assistant response to messages + messages.append( + { + "role": response_message.role, + "function_call": { + "name": tool_call.function.name, + "arguments": tool_call.function.arguments, + }, + "content": None, + } + ) + + # adding function response to messages + messages.append( + { + "role": "function", + "name": tool_call.function.name, + "content": function_response, + } + ) # extend conversation with function response + + return messages + + return None + async def send_chat_request(request_body, request_headers): filtered_messages = [] messages = request_body.get("messages", []) @@ -370,18 +448,113 @@ async def complete_chat_request(request_body, request_headers): else: response, apim_request_id = await send_chat_request(request_body, request_headers) history_metadata = request_body.get("history_metadata", {}) - return format_non_streaming_response(response, history_metadata, apim_request_id) + non_streaming_response = format_non_streaming_response(response, history_metadata, apim_request_id) + + if app_settings.azure_openai.function_call_azure_functions_enabled: + function_response = process_function_call(response) + + if function_response: + request_body["messages"].extend(function_response) + + response, apim_request_id = await send_chat_request(request_body, request_headers) + history_metadata = request_body.get("history_metadata", {}) + non_streaming_response = format_non_streaming_response(response, history_metadata, apim_request_id) + + return non_streaming_response + +class AzureOpenaiFunctionCallStreamState(): + def __init__(self): + self.tool_calls = [] # All tool calls detected in the stream + self.tool_name = "" # Tool name being streamed + self.tool_arguments_stream = "" # Tool arguments being streamed + self.current_tool_call = None # JSON with the tool name and arguments currently being streamed + self.function_messages = [] # All function messages to be appended to the chat history + self.streaming_state = "INITIAL" # Streaming state (INITIAL, STREAMING, COMPLETED) + + +def process_function_call_stream(completionChunk, function_call_stream_state, request_body, request_headers, history_metadata, apim_request_id): + if hasattr(completionChunk, "choices") and len(completionChunk.choices) > 0: + response_message = completionChunk.choices[0].delta + + # Function calling stream processing + if response_message.tool_calls and function_call_stream_state.streaming_state in ["INITIAL", "STREAMING"]: + function_call_stream_state.streaming_state = "STREAMING" + for tool_call_chunk in response_message.tool_calls: + # New tool call + if tool_call_chunk.id: + if function_call_stream_state.current_tool_call: + function_call_stream_state.tool_arguments_stream += tool_call_chunk.function.arguments if tool_call_chunk.function.arguments else "" + function_call_stream_state.current_tool_call["tool_arguments"] = function_call_stream_state.tool_arguments_stream + function_call_stream_state.tool_arguments_stream = "" + function_call_stream_state.tool_name = "" + function_call_stream_state.tool_calls.append(function_call_stream_state.current_tool_call) + + function_call_stream_state.current_tool_call = { + "tool_id": tool_call_chunk.id, + "tool_name": tool_call_chunk.function.name if function_call_stream_state.tool_name == "" else function_call_stream_state.tool_name + } + else: + function_call_stream_state.tool_arguments_stream += tool_call_chunk.function.arguments if tool_call_chunk.function.arguments else "" + + # Function call - Streaming completed + elif response_message.tool_calls is None and function_call_stream_state.streaming_state == "STREAMING": + function_call_stream_state.current_tool_call["tool_arguments"] = function_call_stream_state.tool_arguments_stream + function_call_stream_state.tool_calls.append(function_call_stream_state.current_tool_call) + + for tool_call in function_call_stream_state.tool_calls: + tool_response = openai_remote_azure_function_call(tool_call["tool_name"], tool_call["tool_arguments"]) + + function_call_stream_state.function_messages.append({ + "role": "assistant", + "function_call": { + "name" : tool_call["tool_name"], + "arguments": tool_call["tool_arguments"] + }, + "content": None + }) + function_call_stream_state.function_messages.append({ + "tool_call_id": tool_call["tool_id"], + "role": "function", + "name": tool_call["tool_name"], + "content": tool_response, + }) + + function_call_stream_state.streaming_state = "COMPLETED" + return function_call_stream_state.streaming_state + + else: + return function_call_stream_state.streaming_state async def stream_chat_request(request_body, request_headers): response, apim_request_id = await send_chat_request(request_body, request_headers) history_metadata = request_body.get("history_metadata", {}) - async def generate(): - async for completionChunk in response: - yield format_stream_response(completionChunk, history_metadata, apim_request_id) + async def generate(apim_request_id, history_metadata): + if app_settings.azure_openai.function_call_azure_functions_enabled: + # Maintain state during function call streaming + function_call_stream_state = AzureOpenaiFunctionCallStreamState() + + async for completionChunk in response: + stream_state = process_function_call_stream(completionChunk, function_call_stream_state, request_body, request_headers, history_metadata, apim_request_id) + + # No function call, asistant response + if stream_state == "INITIAL": + yield format_stream_response(completionChunk, history_metadata, apim_request_id) + + # Function call stream completed, functions were executed. + # Append function calls and results to history and send to OpenAI, to stream the final answer. + if stream_state == "COMPLETED": + request_body["messages"].extend(function_call_stream_state.function_messages) + function_response, apim_request_id = await send_chat_request(request_body, request_headers) + async for functionCompletionChunk in function_response: + yield format_stream_response(functionCompletionChunk, history_metadata, apim_request_id) + + else: + async for completionChunk in response: + yield format_stream_response(completionChunk, history_metadata, apim_request_id) - return generate() + return generate(apim_request_id=apim_request_id, history_metadata=history_metadata) async def conversation_internal(request_body, request_headers): diff --git a/backend/settings.py b/backend/settings.py index a360699318..0d6f4b3f2d 100644 --- a/backend/settings.py +++ b/backend/settings.py @@ -123,6 +123,11 @@ class _AzureOpenAISettings(BaseSettings): embedding_endpoint: Optional[str] = None embedding_key: Optional[str] = None embedding_name: Optional[str] = None + function_call_azure_functions_enabled: bool = False + function_call_azure_functions_tools_key: Optional[str] = None + function_call_azure_functions_tools_base_url: Optional[str] = None + function_call_azure_functions_tool_key: Optional[str] = None + function_call_azure_functions_tool_base_url: Optional[str] = None @field_validator('tools', mode='before') @classmethod diff --git a/backend/utils.py b/backend/utils.py index e0634df85c..c5f8a5d913 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -131,6 +131,22 @@ def format_stream_response(chatCompletionChunk, history_metadata, apim_request_i } response_obj["choices"][0]["messages"].append(messageObj) return response_obj + if delta.tool_calls: + messageObj = { + "role": "tool", + "tool_calls": { + "id": delta.tool_calls[0].id, + "function": { + "name" : delta.tool_calls[0].function.name, + "arguments": delta.tool_calls[0].function.arguments + }, + "type": delta.tool_calls[0].type + } + } + if hasattr(delta, "context"): + messageObj["context"] = json.dumps(delta.context) + response_obj["choices"][0]["messages"].append(messageObj) + return response_obj else: if delta.content: messageObj = {