lots of debugging statements. multiple function calls broken

This commit is contained in:
Kwindla Hultman Kramer
2024-10-03 22:36:18 -07:00
parent d1f6d229ca
commit b8898e449e
5 changed files with 244 additions and 93 deletions

View File

@@ -9,11 +9,13 @@ import aiohttp
import os import os
import sys import sys
from pipecat.frames.frames import TranscriptionFrame
from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.task import PipelineParams, PipelineTask from pipecat.pipeline.task import PipelineParams, PipelineTask
from pipecat.services.openai import OpenAILLMContext from pipecat.processors.aggregators.openai_llm_context import (
OpenAILLMContext,
OpenAILLMContextFrame,
)
from pipecat.services.openai_realtime_beta import ( from pipecat.services.openai_realtime_beta import (
OpenAILLMServiceRealtimeBeta, OpenAILLMServiceRealtimeBeta,
OpenAITurnDetection, OpenAITurnDetection,
@@ -100,8 +102,6 @@ You are participating in a voice conversation. Keep your responses concise, shor
unless specifically asked to elaborate on a topic. unless specifically asked to elaborate on a topic.
Remember, your responses should be short. Just one or two sentences, usually. Remember, your responses should be short. Just one or two sentences, usually.
Start by suggesting that you have a conversation about space exploration.
""", """,
) )
@@ -109,7 +109,11 @@ Start by suggesting that you have a conversation about space exploration.
api_key=os.getenv("OPENAI_API_KEY"), session_properties=session_properties api_key=os.getenv("OPENAI_API_KEY"), session_properties=session_properties
) )
llm.register_function(None, fetch_weather_from_api) llm.register_function(None, fetch_weather_from_api)
context = OpenAILLMContext([], tools) context = OpenAILLMContext(
# [{"role": "user", "content": "What's the weather right now in San Francisco?"}], tools
[{"role": "user", "content": "Say 'hello'."}],
tools,
)
context_aggregator = llm.create_context_aggregator(context) context_aggregator = llm.create_context_aggregator(context)
pipeline = Pipeline( pipeline = Pipeline(
@@ -136,15 +140,7 @@ Start by suggesting that you have a conversation about space exploration.
async def on_first_participant_joined(transport, participant): async def on_first_participant_joined(transport, participant):
transport.capture_participant_transcription(participant["id"]) transport.capture_participant_transcription(participant["id"])
# Kick off the conversation. # Kick off the conversation.
await task.queue_frames( await task.queue_frames([OpenAILLMContextFrame(context=context)])
[
TranscriptionFrame(
user_id="foo",
timestamp=0,
text="What's the weather like in San Francisco right now?",
)
]
)
runner = PipelineRunner() runner = PipelineRunner()

View File

@@ -53,7 +53,7 @@ livekit = [ "livekit~=0.13.1", "tenacity~=9.0.0" ]
lmnt = [ "lmnt~=1.1.4" ] lmnt = [ "lmnt~=1.1.4" ]
local = [ "pyaudio~=0.2.14" ] local = [ "pyaudio~=0.2.14" ]
moondream = [ "einops~=0.8.0", "timm~=1.0.8", "transformers~=4.44.0" ] moondream = [ "einops~=0.8.0", "timm~=1.0.8", "transformers~=4.44.0" ]
openai = [ "openai~=1.50.2" ] openai = [ "openai~=1.50.2", "websockets~=12.0", "python-deepcompare~=1.0.1" ]
openpipe = [ "openpipe~=4.24.0" ] openpipe = [ "openpipe~=4.24.0" ]
playht = [ "pyht~=0.0.28" ] playht = [ "pyht~=0.0.28" ]
silero = [ "onnxruntime>=1.16.1" ] silero = [ "onnxruntime>=1.16.1" ]

View File

@@ -168,6 +168,7 @@ class OpenAILLMContext:
llm: FrameProcessor, llm: FrameProcessor,
run_llm: bool = True, run_llm: bool = True,
) -> None: ) -> None:
logger.debug(f"Calling function {function_name} with arguments {arguments}")
# 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
# not need this. But some definitely do (Anthropic, for example). # not need this. But some definitely do (Anthropic, for example).

View File

@@ -497,8 +497,10 @@ class OpenAIAssistantContextAggregator(LLMAssistantContextAggregator):
self._function_calls_in_progress.clear() self._function_calls_in_progress.clear()
self._function_call_finished = None self._function_call_finished = None
elif isinstance(frame, FunctionCallInProgressFrame): elif isinstance(frame, FunctionCallInProgressFrame):
logger.debug(f"FunctionCallInProgressFrame: {frame}")
self._function_calls_in_progress[frame.tool_call_id] = frame self._function_calls_in_progress[frame.tool_call_id] = frame
elif isinstance(frame, FunctionCallResultFrame): elif isinstance(frame, FunctionCallResultFrame):
logger.debug(f"FunctionCallResultFrame: {frame}")
if frame.tool_call_id in self._function_calls_in_progress: if frame.tool_call_id in self._function_calls_in_progress:
del self._function_calls_in_progress[frame.tool_call_id] del self._function_calls_in_progress[frame.tool_call_id]
self._function_call_result = frame self._function_call_result = frame
@@ -514,6 +516,7 @@ class OpenAIAssistantContextAggregator(LLMAssistantContextAggregator):
await self._push_aggregation() await self._push_aggregation()
async def _push_aggregation(self): async def _push_aggregation(self):
logger.debug("!!! Pushing aggregation")
if not ( if not (
self._aggregation or self._function_call_result or self._pending_image_frame_message self._aggregation or self._function_call_result or self._pending_image_frame_message
): ):

View File

@@ -1,11 +1,14 @@
import asyncio import asyncio
import base64 import base64
import random
import json import json
import websockets import websockets
from copy import deepcopy
from typing import List, Optional from typing import List, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from pipecat.frames.frames import ( from pipecat.frames.frames import (
CancelFrame, CancelFrame,
LLMFullResponseStartFrame, LLMFullResponseStartFrame,
@@ -21,7 +24,15 @@ from pipecat.frames.frames import (
) )
from pipecat.processors.frame_processor import FrameDirection from pipecat.processors.frame_processor import FrameDirection
from pipecat.services.ai_services import LLMService from pipecat.services.ai_services import LLMService
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext from pipecat.services.openai import (
OpenAIAssistantContextAggregator,
OpenAIUserContextAggregator,
OpenAIContextAggregatorPair,
)
from pipecat.processors.aggregators.openai_llm_context import (
OpenAILLMContext,
OpenAILLMContextFrame,
)
from loguru import logger from loguru import logger
@@ -33,6 +44,52 @@ from loguru import logger
# ) # )
class OpenAIUnhandledFunctionException(Exception):
pass
class OpenAIRealtimeLLMContext(OpenAILLMContext):
@staticmethod
def upgrade_to_realtime(obj: OpenAILLMContext) -> "OpenAIRealtimeLLMContext":
if isinstance(obj, OpenAILLMContext) and not isinstance(obj, OpenAIRealtimeLLMContext):
obj.__class__ = OpenAIRealtimeLLMContext
obj._unsent_messages = deepcopy(obj._messages)
obj._marker = random.randint(1, 1000)
return obj
def add_message(self, message):
super().add_message(message)
if message.get("role") == "tool":
self._unsent_messages.append(message)
def set_messages(self, messages):
super().set_messages(messages)
self._unsent_messages = deepcopy(self._messages)
def get_unsent_messages(self):
return self._unsent_messages
def update_all_messages_sent(self):
logger.debug("!!! Updating all messages sent")
self._unsent_messages = []
class OpenAIRealtimeUserContextAggregator(OpenAIUserContextAggregator):
async def _push_aggregation(self):
pass
# idx = len(self._context.messages)
# logger.debug(f"!!! 1 {idx}")
# await super()._push_aggregation()
# self._context._unsent_messages.extend(self._context.messages[idx:])
# logger.debug(f"!!! 2 {self._context._unsent_messages}")
class OpenAIRealtimeAssistantContextAggregator(OpenAIAssistantContextAggregator):
async def _push_aggregation(self):
await super()._push_aggregation()
class OpenAIInputTranscription(BaseModel): class OpenAIInputTranscription(BaseModel):
# enabled: bool = Field(description="Whether to enable input audio transcription.", default=True) # enabled: bool = Field(description="Whether to enable input audio transcription.", default=True)
model: str = Field( model: str = Field(
@@ -67,7 +124,7 @@ class RealtimeSessionProperties(BaseModel):
default=OpenAIInputTranscription() default=OpenAIInputTranscription()
) )
turn_detection: Optional[OpenAITurnDetection] = Field(default=None) turn_detection: Optional[OpenAITurnDetection] = Field(default=None)
tools: List[str] = Field(default=[]) tools: List[dict] = Field(default=[])
tool_choice: str = Field(default="auto") tool_choice: str = Field(default="auto")
temperature: float = Field(default=0.8) temperature: float = Field(default=0.8)
max_response_output_tokens: int = Field(default=4096) max_response_output_tokens: int = Field(default=4096)
@@ -89,7 +146,7 @@ class OpenAILLMServiceRealtimeBeta(LLMService):
self._receive_task = None self._receive_task = None
self._session_properties = session_properties self._session_properties = session_properties
self._responses_in_flight = {} self._context = None
async def start(self, frame: StartFrame): async def start(self, frame: StartFrame):
await super().start(frame) await super().start(frame)
@@ -103,15 +160,22 @@ class OpenAILLMServiceRealtimeBeta(LLMService):
await super().cancel(frame) await super().cancel(frame)
await self._disconnect() await self._disconnect()
async def _ws_send(self, realtime_message):
try:
if realtime_message.get("type") != "input_audio_buffer.append":
logger.debug(f"!!! Sending message to websocket: {realtime_message}")
await self._websocket.send(json.dumps(realtime_message))
except Exception as e:
logger.error(f"Error sending message to websocket: {e}")
async def update_session_properties(self): async def update_session_properties(self):
logger.debug(f"Updating session properties: {self._session_properties.dict()}") logger.debug(f"Updating session properties: {self._session_properties.dict()}")
await self._websocket.send( await self._ws_send(
json.dumps( {
{ "type": "session.update",
"type": "session.update", "session": self._session_properties.dict(),
"session": self._session_properties.dict(), }
}
)
) )
async def _connect(self): async def _connect(self):
@@ -158,14 +222,39 @@ class OpenAILLMServiceRealtimeBeta(LLMService):
if not msg: if not msg:
continue continue
if msg["type"] == "session.created": if msg["type"] == "session.created":
logger.debug(f"Received session.created: {msg}")
await self.update_session_properties() await self.update_session_properties()
elif msg["type"] == "session.updated": elif msg["type"] == "session.updated":
logger.debug(f"Received session configuration: {msg}") logger.debug(f"Received session configuration: {msg}")
self._session_properties = msg["session"] self._session_properties = msg["session"]
elif msg["type"] == "response.created": elif msg["type"] == "input_audio_buffer.speech_started":
# user started speaking
pass pass
elif msg["type"] == "input_audio_buffer.speech_stopped":
# user stopped speaking
pass
elif msg["type"] == "conversation.item.created":
# for input, this will get sent from the server whether the
# conversation item is created by audio transcription or by
# sending a client conversation.item.create message.
# for function calls
logger.debug(f"Received {msg}")
pass
elif msg["type"] == "response.created":
# could use for processing metrics
pass
elif msg["type"] == "conversation.item.input_audio_transcription.completed":
# or here maybe (possible send upstream to user context aggregator)
logger.debug(f"Received {msg}")
if msg.get("transcript"):
self._context.add_message({"role": "user", "content": msg["transcript"]})
elif msg["type"] == "response.output_item.added": elif msg["type"] == "response.output_item.added":
# maybe ignore for now but could be useful for UI updates
pass
elif msg["type"] == "response.content_part.added":
# same thing, ignore for now until we think more about UI updates
pass
elif msg["type"] == "response.audio_transcript.delta":
# openai playground app uses this, not "text"
pass pass
elif msg["type"] == "response.audio.delta": elif msg["type"] == "response.audio.delta":
frame = TTSAudioRawFrame( frame = TTSAudioRawFrame(
@@ -174,17 +263,36 @@ class OpenAILLMServiceRealtimeBeta(LLMService):
num_channels=1, num_channels=1,
) )
await self.push_frame(frame) await self.push_frame(frame)
elif msg["type"] == "response.text.delta": elif msg["type"] == "response.audio.done":
logger.debug(f"!!! {msg['delta']}") # bot stopped speaking - or do that at the end of the response?
pass
elif msg["type"] == "response.audio_transcript.done":
# probably ignore for now
pass
elif msg["type"] == "response.content_part.done":
pass pass
elif msg["type"] == "response.output_item.done": elif msg["type"] == "response.output_item.done":
if msg["item"]["type"] == "message": logger.debug(f"Received response item done: {msg}")
for item in msg["item"]["content"]: item = msg["item"]
if item["type"] == "text": if item["type"] == "message" and item["status"] == "completed":
await self.push_frame(TextFrame(item["text"])) for item in item["content"]:
# output text
if item["type"] == "audio" and item["transcript"] is not None:
logger.debug(f"!!! >{item['transcript']}")
await self.push_frame(TextFrame(item["transcript"]))
elif msg["type"] == "response.done": elif msg["type"] == "response.done":
logger.debug(f"Received response done: {msg}")
await self.stop_processing_metrics() await self.stop_processing_metrics()
# send usage metrics from here
# ...
# function calls - do all calls here to support parallel function calls
items = msg["response"]["output"]
function_calls = [item for item in items if item.get("type") == "function_call"]
if function_calls:
await self._handle_function_call_items(function_calls)
await self.push_frame(LLMFullResponseEndFrame()) await self.push_frame(LLMFullResponseEndFrame())
elif msg["type"] == "rate_limits.updated":
pass
elif msg["type"] == "error": elif msg["type"] == "error":
raise Exception(f"Error: {msg}") raise Exception(f"Error: {msg}")
@@ -193,74 +301,121 @@ class OpenAILLMServiceRealtimeBeta(LLMService):
except Exception as e: except Exception as e:
logger.error(f"{self} exception: {e}") logger.error(f"{self} exception: {e}")
async def _create_response(self, context: OpenAILLMContext, messages: list): async def _handle_function_call_items(self, items):
logger.debug(f"Handling function call items: {items}")
total_items = len(items)
logger.debug("!!!!!")
for index, item in enumerate(items):
logger.debug(f"!!! function call item: {item}")
function_name = item["name"]
tool_id = item["call_id"]
arguments = json.loads(item["arguments"])
if self.has_function(function_name):
run_llm = index == total_items - 1
if function_name in self._callbacks.keys():
f = self._callbacks[function_name]
elif None in self._callbacks.keys():
await self.call_function(
context=self._context,
tool_call_id=tool_id,
function_name=function_name,
arguments=arguments,
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 _reset_conversation(self):
# need to think about how to implement this, and how to think about interop with messages lists
# used with the HTTP API
pass
async def _create_response(self, context: OpenAIRealtimeLLMContext):
try: try:
messages = context.get_unsent_messages()
context.update_all_messages_sent()
logger.debug(
f"Creating response: {context._marker} {context.get_messages_for_logging()}"
)
items = []
for m in messages:
if m and m.get("role") == "user":
content = m.get("content")
if isinstance(content, str):
items.append(
{
"type": "message",
"status": "completed",
"role": "user",
"content": [{"type": "input_text", "text": content}],
}
)
else:
raise Exception(f"Invalid message content {m}")
elif m and m.get("role") == "tool":
items.append(
{
"type": "function_call_output",
"call_id": m.get("tool_call_id"),
"output": m["content"],
}
)
await self.push_frame(LLMFullResponseStartFrame()) await self.push_frame(LLMFullResponseStartFrame())
await self.start_processing_metrics() await self.start_processing_metrics()
await self._websocket.send( for item in items:
json.dumps( logger.debug(f"||| {item}")
{ await self._ws_send({"type": "conversation.item.create", "item": item})
"type": "conversation.item.create", logger.debug("||| RESPONSE.CREATE")
"item": { await self._ws_send(
"type": "message", {
"status": "completed", "type": "response.create",
"role": "user", "response": {
"content": [{"type": "input_text", "text": messages[0]["content"]}], "modalities": ["audio", "text"],
},
}
)
)
await self._websocket.send(
json.dumps(
{
"type": "response.create",
"response": {
"modalities": ["audio", "text"],
},
}, },
) },
) )
except Exception as e: except Exception as e:
logger.error(f"{self} exception: {e}") logger.error(f"{self} exception: {e}")
async def _send_user_audio(self, frame): async def _send_user_audio(self, frame):
payload = base64.b64encode(frame.audio).decode("utf-8") payload = base64.b64encode(frame.audio).decode("utf-8")
await self._websocket.send( await self._ws_send(
json.dumps( {
{ "type": "input_audio_buffer.append",
"type": "input_audio_buffer.append", "audio": payload,
"audio": payload, },
},
)
) )
# await self._websocket.send(json.dumps(({"type": "input_audio_buffer.commit"})))
async def _handle_interruption(self, frame): async def _handle_interruption(self, frame):
logger.debug(f"Handling interruption: {frame}") logger.debug(f"Handling interruption: {frame}")
await self.stop_all_metrics() await self.stop_all_metrics()
await self.push_frame(LLMFullResponseEndFrame()) await self.push_frame(LLMFullResponseEndFrame())
await self._websocket.send( # await self._ws_send(
json.dumps( # {
{ # "type": "response.cancel",
"type": "response.cancel", # },
}, # )
) # await self._ws_send(
) # {
await self._websocket.send( # "type": "input_audio_buffer.clear",
json.dumps( # },
{ # )
"type": "input_audio_buffer.clear",
},
)
)
async def process_frame(self, frame: Frame, direction: FrameDirection): async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction) await super().process_frame(frame, direction)
if isinstance(frame, TranscriptionFrame): if isinstance(frame, TranscriptionFrame):
messages = [{"role": "user", "content": frame.text}] pass
context = OpenAILLMContext(messages) elif isinstance(frame, OpenAILLMContextFrame):
# await self._create_response(context, messages) context: OpenAIRealtimeLLMContext = OpenAIRealtimeLLMContext.upgrade_to_realtime(
frame.context
)
self._context = context
await self._create_response(context)
elif isinstance(frame, InputAudioRawFrame): elif isinstance(frame, InputAudioRawFrame):
await self._send_user_audio(frame) await self._send_user_audio(frame)
elif isinstance(frame, StartInterruptionFrame): elif isinstance(frame, StartInterruptionFrame):
@@ -268,16 +423,12 @@ class OpenAILLMServiceRealtimeBeta(LLMService):
await self.push_frame(frame, direction) await self.push_frame(frame, direction)
# async def get_chat_completions( def create_context_aggregator(
# self, context: OpenAILLMContext, messages: List[ChatCompletionMessageParam] self, context: OpenAILLMContext, *, assistant_expect_stripped_words: bool = False
# ) -> AsyncStream[ChatCompletionChunk]: ) -> OpenAIContextAggregatorPair:
# async def _empty_async_generator() -> AsyncGenerator[str, None]: OpenAIRealtimeLLMContext.upgrade_to_realtime(context)
# try: user = OpenAIRealtimeUserContextAggregator(context)
# if False: assistant = OpenAIRealtimeAssistantContextAggregator(
# yield "" user, expect_stripped_words=assistant_expect_stripped_words
# except asyncio.CancelledError: )
# return return OpenAIContextAggregatorPair(_user=user, _assistant=assistant)
# except Exception as e:
# logger.error(f"{self} exception: {e}")
# return _empty_async_generator()