From 5d059d0fc28d72d60da58525675d2dee722fe984 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Ventura?= Date: Fri, 6 Sep 2024 21:25:34 +0100 Subject: [PATCH 1/6] Add function call support with images --- app.py | 174 ++++++++++++++++++++++++++++++++++++++++------- requirements.txt | 2 +- 2 files changed, 149 insertions(+), 27 deletions(-) diff --git a/app.py b/app.py index 538c3774fc..f85ee96a72 100644 --- a/app.py +++ b/app.py @@ -219,22 +219,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): @@ -250,17 +256,21 @@ def prepare_model_args(request_body, request_headers): "stop": app_settings.azure_openai.stop_sequence, "stream": app_settings.azure_openai.stream, "model": app_settings.azure_openai.model, - "user": user_json + "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": + print("User message add tools") + 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"): @@ -333,6 +343,94 @@ async def promptflow_request(request): except Exception as e: logging.error(f"An error occurred while making promptflow_request: {e}") +def get_current_weather(location): + return json.dumps({ + "Weather": "Sunny", + "Temperature": "25°C", + "Weather Image": "" + }) + +azure_openai_tools = [{ + "type": "function", + "function": { + "name": "get_current_weather", + "description": "Returns the current weather for a given location", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The location to get the weather for" + } + }, + "required": ["location"] + } + } +}] + +azure_openai_available_tools = { + "get_current_weather": get_current_weather +} + +def process_function_call(response): + response_message = response.choices[0].message + messages = [] + + # Step 2: check if GPT wanted to call a function + if response_message.tool_calls: + print("Recommended Function call:") + print(response_message.tool_calls[0]) + print() + + # Step 3: call the function + # Note: the JSON response may not always be valid; be sure to handle errors + + function_name = response_message.tool_calls[0].function.name + + # verify function exists + if function_name not in azure_openai_available_tools: + return "Function " + function_name + " does not exist" + function_to_call = azure_openai_available_tools[function_name] + + # verify function has correct number of arguments + function_args = json.loads(response_message.tool_calls[0].function.arguments) + function_response = function_to_call(**function_args) + + print("Output of function call:") + print(function_response) + print() + + # Step 4: send the info on the function call and function response to GPT + + # adding assistant response to messages + + messages.append( + { + "role": response_message.role, + "function_call": { + "name": function_name, + "arguments": response_message.tool_calls[0].function.arguments, + }, + "content": None, + } + ) + + # adding function response to messages + messages.append( + { + "role": "function", + "name": function_name, + "content": function_response, + } + ) # extend conversation with function response + + print("Messages in second request:") + for message in messages: + print(message) + print() + + return messages + return None async def send_chat_request(request_body, request_headers): filtered_messages = [] @@ -344,6 +442,10 @@ async def send_chat_request(request_body, request_headers): request_body['messages'] = filtered_messages model_args = prepare_model_args(request_body, request_headers) + print("Model args") + print(model_args) + print("Model args") + try: azure_openai_client = await init_openai_client() raw_response = await azure_openai_client.chat.completions.with_raw_response.create(**model_args) @@ -369,7 +471,27 @@ 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) + + # Step 1: check if GPT wanted to call a function + function_response = process_function_call(response) + + if function_response: + request_body["messages"].extend(function_response) + + print("Request body with function response:") + print(request_body) + print("Request body with 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) + + print("Response with function response:") + print(response) + print("Response with function response") + + return non_streaming_response async def stream_chat_request(request_body, request_headers): diff --git a/requirements.txt b/requirements.txt index 7e3233dd06..e7e5f79cdf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ azure-identity==1.15.0 # Flask[async]==2.3.2 -openai==1.6.1 +openai==1.35.15 azure-search-documents==11.4.0b6 azure-storage-blob==12.17.0 python-dotenv==1.0.0 From d9dd9616f1a785715ff15c8a4e7e8c784c8cd9fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Ventura?= Date: Sat, 21 Sep 2024 16:48:18 +0100 Subject: [PATCH 2/6] Add support for OpenAI function calling with streaming --- app.py | 261 ++++++++++++++++++++++++++------------------ backend/settings.py | 5 + backend/utils.py | 17 +++ requirements.txt | 2 +- 4 files changed, 179 insertions(+), 106 deletions(-) diff --git a/app.py b/app.py index f85ee96a72..323acc3728 100644 --- a/app.py +++ b/app.py @@ -36,6 +36,9 @@ format_pf_non_streaming_response, ) +import requests + + bp = Blueprint("routes", __name__, static_folder="static", template_folder="static") cosmos_db_ready = asyncio.Event() @@ -111,6 +114,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 +165,14 @@ 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) + azure_openai_tools.append(json.loads(response.text)) + for tool in azure_openai_tools: + azure_openai_available_tools.append(tool["function"]["name"]) + azure_openai_client = AsyncAzureOpenAI( api_version=app_settings.azure_openai.preview_api_version, api_key=aoai_api_key, @@ -173,6 +187,19 @@ async def init_openai_client(): azure_openai_client = None raise e +def openai_remote_azure_function_call(function_name, function_args): + if not app_settings.azure_openai.function_call_azure_functions_enabled: + 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) + + return response.text async def init_cosmosdb_client(): cosmos_conversation_client = None @@ -256,12 +283,12 @@ def prepare_model_args(request_body, request_headers): "stop": app_settings.azure_openai.stop_sequence, "stream": app_settings.azure_openai.stream, "model": app_settings.azure_openai.model, - "user": user_json, + "user": user_json } if messages[-1]["role"] == "user": - print("User message add tools") - model_args["tools"] = azure_openai_tools + if app_settings.azure_openai.function_call_azure_functions_enabled: + model_args["tools"] = azure_openai_tools if app_settings.datasource: model_args["extra_body"] = { @@ -343,95 +370,44 @@ async def promptflow_request(request): except Exception as e: logging.error(f"An error occurred while making promptflow_request: {e}") -def get_current_weather(location): - return json.dumps({ - "Weather": "Sunny", - "Temperature": "25°C", - "Weather Image": "" - }) - -azure_openai_tools = [{ - "type": "function", - "function": { - "name": "get_current_weather", - "description": "Returns the current weather for a given location", - "parameters": { - "type": "object", - "properties": { - "location": { - "type": "string", - "description": "The location to get the weather for" - } - }, - "required": ["location"] - } - } -}] - -azure_openai_available_tools = { - "get_current_weather": get_current_weather -} def process_function_call(response): response_message = response.choices[0].message messages = [] - # Step 2: check if GPT wanted to call a function if response_message.tool_calls: - print("Recommended Function call:") - print(response_message.tool_calls[0]) - print() - - # Step 3: call the function - # Note: the JSON response may not always be valid; be sure to handle errors - - function_name = response_message.tool_calls[0].function.name - - # verify function exists - if function_name not in azure_openai_available_tools: - return "Function " + function_name + " does not exist" - function_to_call = azure_openai_available_tools[function_name] - - # verify function has correct number of arguments - function_args = json.loads(response_message.tool_calls[0].function.arguments) - function_response = function_to_call(**function_args) - - print("Output of function call:") - print(function_response) - print() - - # Step 4: send the info on the function call and function response to GPT - - # adding assistant response to messages - - messages.append( - { - "role": response_message.role, - "function_call": { - "name": function_name, - "arguments": response_message.tool_calls[0].function.arguments, - }, - "content": None, - } - ) - - # adding function response to messages - messages.append( - { - "role": "function", - "name": function_name, - "content": function_response, - } - ) # extend conversation with function response + 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) - print("Messages in second request:") - for message in messages: - print(message) - print() + # 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", []) @@ -442,10 +418,6 @@ async def send_chat_request(request_body, request_headers): request_body['messages'] = filtered_messages model_args = prepare_model_args(request_body, request_headers) - print("Model args") - print(model_args) - print("Model args") - try: azure_openai_client = await init_openai_client() raw_response = await azure_openai_client.chat.completions.with_raw_response.create(**model_args) @@ -473,46 +445,125 @@ async def complete_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) - # Step 1: check if GPT wanted to call a function - function_response = process_function_call(response) - - if function_response: - request_body["messages"].extend(function_response) + if app_settings.azure_openai.function_call_azure_functions_enabled: + function_response = process_function_call(response) - print("Request body with function response:") - print(request_body) - print("Request body with 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) + if function_response: + request_body["messages"].extend(function_response) - print("Response with function response:") - print(response) - print("Response with 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 async def stream_chat_request(request_body, request_headers): + print("Stream Chat Request") + print(request_body) 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) + messages = [] + + async def generate(apim_request_id, history_metadata): + tool_calls = [] + current_tool_call = None + tool_arguments_stream = "" + function_messages = [] + tool_name = "" + tool_call_streaming_state = "INITIAL" - return generate() + async for completionChunk in response: + if app_settings.azure_openai.function_call_azure_functions_enabled: + print("Completion Chunk: ", completionChunk) + + 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 tool_call_streaming_state in ["INITIAL", "STREAMING"]: + tool_call_streaming_state = "STREAMING" + for tool_call_chunk in response_message.tool_calls: + # New tool call + if tool_call_chunk.id: + #print("New tool call: {0}", tool_call_chunk.id) + if current_tool_call: + tool_arguments_stream += tool_call_chunk.function.arguments if tool_call_chunk.function.arguments else "" + current_tool_call["tool_arguments"] = tool_arguments_stream + tool_arguments_stream = "" + tool_name = "" + tool_calls.append(current_tool_call) + + current_tool_call = { + "tool_id": tool_call_chunk.id, + "tool_name": tool_call_chunk.function.name if tool_name == "" else tool_name + } + else: + 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 tool_call_streaming_state == "STREAMING": + current_tool_call["tool_arguments"] = tool_arguments_stream + tool_calls.append(current_tool_call) + + #print("Tool Calls: ", tool_calls) + + for tool_call in tool_calls: + tool_response = openai_remote_azure_function_call(tool_call["tool_name"], tool_call["tool_arguments"]) + + function_messages.append({ + "role": "assistant", + "function_call": { + "name" : tool_call["tool_name"], + "arguments": tool_call["tool_arguments"] + }, + "content": None + }) + function_messages.append({ + "tool_call_id": tool_call["tool_id"], + "role": "function", + "name": tool_call["tool_name"], + "content": tool_response, + }) + + # Reset for the next tool call + messages = function_messages + function_messages = [] + tool_calls = [] + current_tool_call = None + tool_arguments_stream = "" + tool_name = "" + tool_id = None + tool_call_streaming_state = "COMPLETED" + + request_body["messages"].extend(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: + # No function call, asistant response + yield format_stream_response(completionChunk, history_metadata, apim_request_id) + + else: + yield format_stream_response(completionChunk, history_metadata, apim_request_id) + return generate(apim_request_id=apim_request_id, history_metadata=history_metadata) async def conversation_internal(request_body, request_headers): try: if app_settings.azure_openai.stream and not app_settings.base_settings.use_promptflow: result = await stream_chat_request(request_body, request_headers) + response = await make_response(format_as_ndjson(result)) response.timeout = None response.mimetype = "application/json-lines" + return response + else: result = await complete_chat_request(request_body, request_headers) return jsonify(result) diff --git a/backend/settings.py b/backend/settings.py index 50f34666ee..0e65ca9733 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 2b722dc79d..bca6a5c851 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -119,6 +119,7 @@ def format_stream_response(chatCompletionChunk, history_metadata, apim_request_i if len(chatCompletionChunk.choices) > 0: delta = chatCompletionChunk.choices[0].delta + if delta: if hasattr(delta, "context"): messageObj = {"role": "tool", "content": json.dumps(delta.context)} @@ -131,6 +132,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 = { diff --git a/requirements.txt b/requirements.txt index e7e5f79cdf..04595fc19b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ azure-identity==1.15.0 # Flask[async]==2.3.2 -openai==1.35.15 +openai==1.47.0 azure-search-documents==11.4.0b6 azure-storage-blob==12.17.0 python-dotenv==1.0.0 From fc7cda2a68205aa8a440d7cf284e3279fc40d019 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Ventura?= Date: Sat, 21 Sep 2024 16:54:11 +0100 Subject: [PATCH 3/6] Minor aesthetic fixes (remove empty lines) --- app.py | 5 ----- backend/utils.py | 1 - 2 files changed, 6 deletions(-) diff --git a/app.py b/app.py index 323acc3728..e1f0321c2d 100644 --- a/app.py +++ b/app.py @@ -35,10 +35,8 @@ convert_to_pf_format, format_pf_non_streaming_response, ) - import requests - bp = Blueprint("routes", __name__, static_folder="static", template_folder="static") cosmos_db_ready = asyncio.Event() @@ -557,13 +555,10 @@ async def conversation_internal(request_body, request_headers): try: if app_settings.azure_openai.stream and not app_settings.base_settings.use_promptflow: result = await stream_chat_request(request_body, request_headers) - response = await make_response(format_as_ndjson(result)) response.timeout = None response.mimetype = "application/json-lines" - return response - else: result = await complete_chat_request(request_body, request_headers) return jsonify(result) diff --git a/backend/utils.py b/backend/utils.py index bca6a5c851..1efd090d99 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -119,7 +119,6 @@ def format_stream_response(chatCompletionChunk, history_metadata, apim_request_i if len(chatCompletionChunk.choices) > 0: delta = chatCompletionChunk.choices[0].delta - if delta: if hasattr(delta, "context"): messageObj = {"role": "tool", "content": json.dumps(delta.context)} From c2f89474e8ec8057da2fd1cf5e20be3892880bec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Ventura?= Date: Fri, 11 Oct 2024 16:05:45 +0100 Subject: [PATCH 4/6] Improve error handling on Azure Function call. Remove debug messages. --- app.py | 25 ++++++++++++------------- requirements.txt | 2 +- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/app.py b/app.py index 9831bbf80d..16d2f73b3b 100644 --- a/app.py +++ b/app.py @@ -167,10 +167,15 @@ async def init_openai_client(): 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) - azure_openai_tools.append(json.loads(response.text)) - for tool in azure_openai_tools: - azure_openai_available_tools.append(tool["function"]["name"]) + 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, @@ -186,7 +191,7 @@ async def init_openai_client(): raise e def openai_remote_azure_function_call(function_name, function_args): - if not app_settings.azure_openai.function_call_azure_functions_enabled: + 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}" @@ -196,7 +201,8 @@ def openai_remote_azure_function_call(function_name, function_args): "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(): @@ -286,7 +292,7 @@ def prepare_model_args(request_body, request_headers): } if messages[-1]["role"] == "user": - if app_settings.azure_openai.function_call_azure_functions_enabled: + 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: @@ -458,8 +464,6 @@ async def complete_chat_request(request_body, request_headers): async def stream_chat_request(request_body, request_headers): - print("Stream Chat Request") - print(request_body) response, apim_request_id = await send_chat_request(request_body, request_headers) history_metadata = request_body.get("history_metadata", {}) @@ -475,8 +479,6 @@ async def generate(apim_request_id, history_metadata): async for completionChunk in response: if app_settings.azure_openai.function_call_azure_functions_enabled: - print("Completion Chunk: ", completionChunk) - if hasattr(completionChunk, "choices") and len(completionChunk.choices) > 0: response_message = completionChunk.choices[0].delta @@ -486,7 +488,6 @@ async def generate(apim_request_id, history_metadata): for tool_call_chunk in response_message.tool_calls: # New tool call if tool_call_chunk.id: - #print("New tool call: {0}", tool_call_chunk.id) if current_tool_call: tool_arguments_stream += tool_call_chunk.function.arguments if tool_call_chunk.function.arguments else "" current_tool_call["tool_arguments"] = tool_arguments_stream @@ -506,8 +507,6 @@ async def generate(apim_request_id, history_metadata): current_tool_call["tool_arguments"] = tool_arguments_stream tool_calls.append(current_tool_call) - #print("Tool Calls: ", tool_calls) - for tool_call in tool_calls: tool_response = openai_remote_azure_function_call(tool_call["tool_name"], tool_call["tool_arguments"]) diff --git a/requirements.txt b/requirements.txt index 04595fc19b..7e3233dd06 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ azure-identity==1.15.0 # Flask[async]==2.3.2 -openai==1.47.0 +openai==1.6.1 azure-search-documents==11.4.0b6 azure-storage-blob==12.17.0 python-dotenv==1.0.0 From 59722494b0b84a8e24034b882e60c5c3172e94a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Ventura?= Date: Sat, 12 Oct 2024 22:36:02 +0100 Subject: [PATCH 5/6] Code quality improvements. Added documentation to README file. --- README.md | 120 +++++++++++++++++++++++++++++++++++++++ app.py | 166 ++++++++++++++++++++++++++++-------------------------- 2 files changed, 206 insertions(+), 80 deletions(-) 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 16d2f73b3b..525777d832 100644 --- a/app.py +++ b/app.py @@ -412,7 +412,7 @@ def process_function_call(response): return messages return None - + async def send_chat_request(request_body, request_headers): filtered_messages = [] messages = request_body.get("messages", []) @@ -462,92 +462,98 @@ async def complete_chat_request(request_body, request_headers): return non_streaming_response +class AzureOpenaiFunctionCallStreamState(): + def __init__(self): + self.tool_calls = [] + self.current_tool_call = None + self.tool_arguments_stream = "" + self.function_messages = [] + self.tool_name = "" + self.tool_call_streaming_state = "INITIAL" + + +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.tool_call_streaming_state in ["INITIAL", "STREAMING"]: + function_call_stream_state.tool_call_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.tool_call_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.tool_call_streaming_state = "COMPLETED" + return function_call_stream_state.tool_call_streaming_state + + else: + return function_call_stream_state.tool_call_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", {}) - messages = [] - async def generate(apim_request_id, history_metadata): - tool_calls = [] - current_tool_call = None - tool_arguments_stream = "" - function_messages = [] - tool_name = "" - tool_call_streaming_state = "INITIAL" - - async for completionChunk in response: - if app_settings.azure_openai.function_call_azure_functions_enabled: - 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 tool_call_streaming_state in ["INITIAL", "STREAMING"]: - tool_call_streaming_state = "STREAMING" - for tool_call_chunk in response_message.tool_calls: - # New tool call - if tool_call_chunk.id: - if current_tool_call: - tool_arguments_stream += tool_call_chunk.function.arguments if tool_call_chunk.function.arguments else "" - current_tool_call["tool_arguments"] = tool_arguments_stream - tool_arguments_stream = "" - tool_name = "" - tool_calls.append(current_tool_call) - - current_tool_call = { - "tool_id": tool_call_chunk.id, - "tool_name": tool_call_chunk.function.name if tool_name == "" else tool_name - } - else: - 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 tool_call_streaming_state == "STREAMING": - current_tool_call["tool_arguments"] = tool_arguments_stream - tool_calls.append(current_tool_call) - - for tool_call in tool_calls: - tool_response = openai_remote_azure_function_call(tool_call["tool_name"], tool_call["tool_arguments"]) - - function_messages.append({ - "role": "assistant", - "function_call": { - "name" : tool_call["tool_name"], - "arguments": tool_call["tool_arguments"] - }, - "content": None - }) - function_messages.append({ - "tool_call_id": tool_call["tool_id"], - "role": "function", - "name": tool_call["tool_name"], - "content": tool_response, - }) - - # Reset for the next tool call - messages = function_messages - function_messages = [] - tool_calls = [] - current_tool_call = None - tool_arguments_stream = "" - tool_name = "" - tool_id = None - tool_call_streaming_state = "COMPLETED" - - request_body["messages"].extend(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: - # No function call, asistant response - yield format_stream_response(completionChunk, history_metadata, apim_request_id) - - else: + 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(apim_request_id=apim_request_id, history_metadata=history_metadata) From 212ae0b657fce7ddbb8707f7f22d16ababec505f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Ventura?= Date: Sun, 13 Oct 2024 18:02:23 +0100 Subject: [PATCH 6/6] Improve naming convention. Add comments to improve readability. --- app.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/app.py b/app.py index 525777d832..5ae8a7cd99 100644 --- a/app.py +++ b/app.py @@ -464,12 +464,12 @@ async def complete_chat_request(request_body, request_headers): class AzureOpenaiFunctionCallStreamState(): def __init__(self): - self.tool_calls = [] - self.current_tool_call = None - self.tool_arguments_stream = "" - self.function_messages = [] - self.tool_name = "" - self.tool_call_streaming_state = "INITIAL" + 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): @@ -477,8 +477,8 @@ def process_function_call_stream(completionChunk, function_call_stream_state, re response_message = completionChunk.choices[0].delta # Function calling stream processing - if response_message.tool_calls and function_call_stream_state.tool_call_streaming_state in ["INITIAL", "STREAMING"]: - function_call_stream_state.tool_call_streaming_state = "STREAMING" + 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: @@ -497,7 +497,7 @@ def process_function_call_stream(completionChunk, function_call_stream_state, re 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.tool_call_streaming_state == "STREAMING": + 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) @@ -519,11 +519,11 @@ def process_function_call_stream(completionChunk, function_call_stream_state, re "content": tool_response, }) - function_call_stream_state.tool_call_streaming_state = "COMPLETED" - return function_call_stream_state.tool_call_streaming_state + function_call_stream_state.streaming_state = "COMPLETED" + return function_call_stream_state.streaming_state else: - return function_call_stream_state.tool_call_streaming_state + return function_call_stream_state.streaming_state async def stream_chat_request(request_body, request_headers):