Merge pull request #522 from pipecat-ai/rebase-openai-multi-function-call

Handle parallel function calls for OpenAI LLMs
This commit is contained in:
Kwindla Hultman Kramer
2024-09-30 16:23:37 -07:00
committed by GitHub
5 changed files with 65 additions and 30 deletions

View File

@@ -34,7 +34,12 @@ logger.add(sys.stderr, level="DEBUG")
async def start_fetch_weather(function_name, llm, context): async def start_fetch_weather(function_name, llm, context):
await llm.push_frame(TextFrame("Let me check on that.")) # note: we can't push a frame to the LLM here. the bot
# can interrupt itself and/or cause audio overlapping glitches.
# possible question for Aleix and Chad about what the right way
# to trigger speech is, now, with the new queues/async/sync refactors.
# await llm.push_frame(TextFrame("Let me check on that."))
logger.debug(f"Starting fetch_weather_from_api with function_name: {function_name}")
async def fetch_weather_from_api(function_name, tool_call_id, args, llm, context, result_callback): async def fetch_weather_from_api(function_name, tool_call_id, args, llm, context, result_callback):
@@ -106,11 +111,11 @@ async def main():
pipeline = Pipeline( pipeline = Pipeline(
[ [
fl_in, # fl_in,
transport.input(), transport.input(),
context_aggregator.user(), context_aggregator.user(),
llm, llm,
fl_out, # fl_out,
tts, tts,
transport.output(), transport.output(),
context_aggregator.assistant(), context_aggregator.assistant(),

View File

@@ -585,6 +585,7 @@ class FunctionCallResultFrame(DataFrame):
tool_call_id: str tool_call_id: str
arguments: str arguments: str
result: Any result: Any
run_llm: bool = True
@dataclass @dataclass

View File

@@ -133,6 +133,7 @@ class OpenAILLMContext:
tool_call_id: str, tool_call_id: str,
arguments: str, arguments: str,
llm: FrameProcessor, llm: FrameProcessor,
run_llm: bool = True,
) -> None: ) -> None:
# Push a SystemFrame downstream. This frame will let our assistant context aggregator # Push a SystemFrame downstream. This frame will let our assistant context aggregator
# know that we are in the middle of a function call. Some contexts/aggregators may # know that we are in the middle of a function call. Some contexts/aggregators may
@@ -153,6 +154,7 @@ class OpenAILLMContext:
tool_call_id=tool_call_id, tool_call_id=tool_call_id,
arguments=arguments, arguments=arguments,
result=result, result=result,
run_llm=run_llm,
) )
) )

View File

@@ -110,7 +110,13 @@ class LLMService(AIService):
return function_name in self._callbacks.keys() return function_name in self._callbacks.keys()
async def call_function( async def call_function(
self, *, context: OpenAILLMContext, tool_call_id: str, function_name: str, arguments: str self,
*,
context: OpenAILLMContext,
tool_call_id: str,
function_name: str,
arguments: str,
run_llm: bool,
) -> None: ) -> None:
f = None f = None
if function_name in self._callbacks.keys(): if function_name in self._callbacks.keys():
@@ -120,7 +126,12 @@ class LLMService(AIService):
else: else:
return None return None
await context.call_function( await context.call_function(
f, function_name=function_name, tool_call_id=tool_call_id, arguments=arguments, llm=self f,
function_name=function_name,
tool_call_id=tool_call_id,
arguments=arguments,
llm=self,
run_llm=run_llm,
) )
# QUESTION FOR CB: maybe this isn't needed anymore? # QUESTION FOR CB: maybe this isn't needed anymore?

View File

@@ -205,6 +205,10 @@ class BaseOpenAILLMService(LLMService):
return chunks return chunks
async def _process_context(self, context: OpenAILLMContext): async def _process_context(self, context: OpenAILLMContext):
functions_list = []
arguments_list = []
tool_id_list = []
func_idx = 0
function_name = "" function_name = ""
arguments = "" arguments = ""
tool_call_id = "" tool_call_id = ""
@@ -242,6 +246,14 @@ class BaseOpenAILLMService(LLMService):
# yield a frame containing the function name and the arguments. # yield a frame containing the function name and the arguments.
tool_call = chunk.choices[0].delta.tool_calls[0] tool_call = chunk.choices[0].delta.tool_calls[0]
if tool_call.index != func_idx:
functions_list.append(function_name)
arguments_list.append(arguments)
tool_id_list.append(tool_call_id)
function_name = ""
arguments = ""
tool_call_id = ""
func_idx += 1
if tool_call.function and tool_call.function.name: if tool_call.function and tool_call.function.name:
function_name += tool_call.function.name function_name += tool_call.function.name
tool_call_id = tool_call.id tool_call_id = tool_call.id
@@ -257,21 +269,29 @@ class BaseOpenAILLMService(LLMService):
# the context, and re-prompt to get a chat answer. If we don't have a registered # the context, and re-prompt to get a chat answer. If we don't have a registered
# handler, raise an exception. # handler, raise an exception.
if function_name and arguments: if function_name and arguments:
if self.has_function(function_name): # added to the list as last function name and arguments not added to the list
await self._handle_function_call(context, tool_call_id, function_name, arguments) functions_list.append(function_name)
else: arguments_list.append(arguments)
raise OpenAIUnhandledFunctionException( tool_id_list.append(tool_call_id)
f"The LLM tried to call a function named '{function_name}', but there isn't a callback registered for that function."
)
async def _handle_function_call(self, context, tool_call_id, function_name, arguments): total_items = len(functions_list)
arguments = json.loads(arguments) for index, (function_name, arguments, tool_id) in enumerate(
await self.call_function( zip(functions_list, arguments_list, tool_id_list), start=1
context=context, ):
tool_call_id=tool_call_id, if self.has_function(function_name):
function_name=function_name, run_llm = index == total_items
arguments=arguments, arguments = json.loads(arguments)
) await self.call_function(
context=context,
function_name=function_name,
arguments=arguments,
tool_call_id=tool_id,
run_llm=run_llm,
)
else:
raise OpenAIUnhandledFunctionException(
f"The LLM tried to call a function named '{function_name}', but there isn't a callback registered for that function."
)
async def _update_settings(self, frame: LLMUpdateSettingsFrame): async def _update_settings(self, frame: LLMUpdateSettingsFrame):
if frame.model is not None: if frame.model is not None:
@@ -465,31 +485,27 @@ class OpenAIAssistantContextAggregator(LLMAssistantContextAggregator):
def __init__(self, user_context_aggregator: OpenAIUserContextAggregator, **kwargs): def __init__(self, user_context_aggregator: OpenAIUserContextAggregator, **kwargs):
super().__init__(context=user_context_aggregator._context, **kwargs) super().__init__(context=user_context_aggregator._context, **kwargs)
self._user_context_aggregator = user_context_aggregator self._user_context_aggregator = user_context_aggregator
self._function_call_in_progress = None self._function_calls_in_progress = {}
self._function_call_result = None self._function_call_result = None
async def process_frame(self, frame, direction): async def process_frame(self, frame, direction):
await super().process_frame(frame, direction) await super().process_frame(frame, direction)
# See note above about not calling push_frame() here. # See note above about not calling push_frame() here.
if isinstance(frame, StartInterruptionFrame): if isinstance(frame, StartInterruptionFrame):
self._function_call_in_progress = None self._function_calls_in_progress.clear()
self._function_call_finished = None self._function_call_finished = None
elif isinstance(frame, FunctionCallInProgressFrame): elif isinstance(frame, FunctionCallInProgressFrame):
self._function_call_in_progress = frame self._function_calls_in_progress[frame.tool_call_id] = frame
elif isinstance(frame, FunctionCallResultFrame): elif isinstance(frame, FunctionCallResultFrame):
if ( if frame.tool_call_id in self._function_calls_in_progress:
self._function_call_in_progress del self._function_calls_in_progress[frame.tool_call_id]
and self._function_call_in_progress.tool_call_id == frame.tool_call_id
):
self._function_call_in_progress = None
self._function_call_result = frame self._function_call_result = frame
# TODO-CB: Kwin wants us to refactor this out of here but I REFUSE # TODO-CB: Kwin wants us to refactor this out of here but I REFUSE
await self._push_aggregation() await self._push_aggregation()
else: else:
logger.warning( logger.warning(
"FunctionCallResultFrame tool_call_id does not match FunctionCallInProgressFrame tool_call_id" "FunctionCallResultFrame tool_call_id does not match any function call in progress"
) )
self._function_call_in_progress = None
self._function_call_result = None self._function_call_result = None
async def _push_aggregation(self): async def _push_aggregation(self):
@@ -528,7 +544,7 @@ class OpenAIAssistantContextAggregator(LLMAssistantContextAggregator):
"tool_call_id": frame.tool_call_id, "tool_call_id": frame.tool_call_id,
} }
) )
run_llm = True run_llm = frame.run_llm
else: else:
self._context.add_message({"role": "assistant", "content": aggregation}) self._context.add_message({"role": "assistant", "content": aggregation})