Enable tool emulation and improved error logging

Introduced a configuration option to emulate tool usage in models that do not natively support tools, facilitating the use of such functionality without direct model support. This should benefit users aiming to leverage tool-based features without relying on specific AI models. Additionally, enhanced error logging in the GPTBot class by including traceback details, aiding in debugging and incident resolution.

- Added `EmulateTools` option in the `config.dist.ini` for flexible tool handling.
- Enriched error logging with stack traces in `bot.py` for better insight during failures.
This commit is contained in:
Kumi 2023-12-29 22:46:19 +01:00
parent c7e448126d
commit 0acc1456f9
Signed by: kumi
GPG key ID: ECBCC9082395383F
3 changed files with 285 additions and 194 deletions

View file

@ -112,6 +112,14 @@ APIKey = sk-yoursecretkey
# #
# ForceTools = 0 # ForceTools = 0
# Whether to emulate tools in the chat completion model
#
# This will make the bot use the default model to *emulate* tools. This is
# useful if you want to use a model that doesn't support tools, but still want
# to be able to use tools. However, this may cause all kinds of weird results.
#
# EmulateTools = 0
############################################################################### ###############################################################################
[WolframAlpha] [WolframAlpha]

View file

@ -1136,6 +1136,7 @@ class GPTBot:
chat_messages, user=event.sender, room=room.room_id chat_messages, user=event.sender, room=room.room_id
) )
except Exception as e: except Exception as e:
print(traceback.format_exc())
self.logger.log(f"Error generating response: {e}", "error") self.logger.log(f"Error generating response: {e}", "error")
await self.send_message( await self.send_message(

View file

@ -20,9 +20,13 @@ ASSISTANT_CODE_INTERPRETER = [
{ {
"type": "code_interpreter", "type": "code_interpreter",
}, },
] ]
class AttributeDictionary(dict):
def __init__(self, *args, **kwargs):
super(AttributeDictionary, self).__init__(*args, **kwargs)
self.__dict__ = self
class OpenAI: class OpenAI:
api_key: str api_key: str
chat_model: str = "gpt-3.5-turbo" chat_model: str = "gpt-3.5-turbo"
@ -42,14 +46,27 @@ class OpenAI:
operator: str = "OpenAI ([https://openai.com](https://openai.com))" operator: str = "OpenAI ([https://openai.com](https://openai.com))"
def __init__(self, bot, api_key, chat_model=None, image_model=None, tts_model=None, tts_voice=None, stt_model=None, base_url=None, logger=None): def __init__(
self,
bot,
api_key,
chat_model=None,
image_model=None,
tts_model=None,
tts_voice=None,
stt_model=None,
base_url=None,
logger=None,
):
self.bot = bot self.bot = bot
self.api_key = api_key self.api_key = api_key
self.chat_model = chat_model or self.chat_model self.chat_model = chat_model or self.chat_model
self.image_model = image_model or self.image_model self.image_model = image_model or self.image_model
self.logger = logger or bot.logger or Logger() self.logger = logger or bot.logger or Logger()
self.base_url = base_url or openai.base_url self.base_url = base_url or openai.base_url
self.openai_api = openai.AsyncOpenAI(api_key=self.api_key, base_url=self.base_url) self.openai_api = openai.AsyncOpenAI(
api_key=self.api_key, base_url=self.base_url
)
self.tts_model = tts_model or self.tts_model self.tts_model = tts_model or self.tts_model
self.tts_voice = tts_voice or self.tts_voice self.tts_voice = tts_voice or self.tts_voice
self.stt_model = stt_model or self.stt_model self.stt_model = stt_model or self.stt_model
@ -57,7 +74,23 @@ class OpenAI:
def supports_chat_images(self): def supports_chat_images(self):
return "vision" in self.chat_model return "vision" in self.chat_model
async def _request_with_retries(self, request: partial, attempts: int = 5, retry_interval: int = 2) -> AsyncGenerator[Any | list | Dict, None]: def json_decode(self, data):
if data.startswith("```json\n"):
data = data[8:]
elif data.startswith("```\n"):
data = data[4:]
if data.endswith("```"):
data = data[:-3]
try:
return json.loads(data)
except:
return False
async def _request_with_retries(
self, request: partial, attempts: int = 5, retry_interval: int = 2
) -> AsyncGenerator[Any | list | Dict, None]:
"""Retry a request a set number of times if it fails. """Retry a request a set number of times if it fails.
Args: Args:
@ -83,126 +116,14 @@ class OpenAI:
# if all attempts failed, raise an exception # if all attempts failed, raise an exception
raise Exception("Request failed after all attempts.") raise Exception("Request failed after all attempts.")
async def create_assistant(self, system_message: str, tools: List[Dict[str, str]] = ASSISTANT_CODE_INTERPRETER) -> str: async def generate_chat_response(
"""Create a new assistant. self,
messages: List[Dict[str, str]],
Args: user: Optional[str] = None,
system_message (str): The system message to use. room: Optional[str] = None,
tools (List[Dict[str, str]], optional): The tools to use. Defaults to ASSISTANT_CODE_INTERPRETER. allow_override: bool = True,
use_tools: bool = True,
Returns: ) -> Tuple[str, int]:
str: The assistant ID.
"""
self.logger.log(f"Creating assistant with {len(tools)} tools...")
assistant_partial = partial(
self.openai_api.beta.assistants.create,
model=self.chat_model,
instructions=system_message,
tools=tools
)
response = await self._request_with_retries(assistant_partial)
assistant_id = response.id
self.logger.log(f"Created assistant with ID {assistant_id}.")
return assistant_id
async def create_thread(self):
# TODO: Implement
pass
async def setup_assistant(self, room: str, system_message: str, tools: List[Dict[str, str]] = ASSISTANT_CODE_INTERPRETER) -> Tuple[str, str]:
"""Create a new assistant and set it up for a room.
Args:
room (str): The room to set up the assistant for.
system_message (str): The system message to use.
tools (List[Dict[str, str]], optional): The tools to use. Defaults to ASSISTANT_CODE_INTERPRETER.
Returns:
Tuple[str, str]: The assistant ID and the thread ID.
"""
assistant_id = await self.create_assistant(system_message, tools)
thread_id = await self.create_thread() # TODO: Adapt to actual implementation
self.logger.log(f"Setting up assistant {assistant_id} with thread {thread_id} for room {room}...")
with closing(self.bot.database.cursor()) as cursor:
cursor.execute("INSERT INTO room_settings (room_id, setting, value) VALUES (?, ?, ?)", (room, "openai_assistant", assistant_id))
cursor.execute("INSERT INTO room_settings (room_id, setting, value) VALUES (?, ?, ?)", (room, "openai_thread", thread_id))
self.bot.database.commit()
return assistant_id, thread_id
async def get_assistant_id(self, room: str) -> str:
"""Get the assistant ID for a room.
Args:
room (str): The room to get the assistant ID for.
Returns:
str: The assistant ID.
"""
with closing(self.bot.database.cursor()) as cursor:
cursor.execute("SELECT value FROM room_settings WHERE room_id = ? AND setting = ?", (room, "openai_assistant"))
result = cursor.fetchone()
if result is None:
raise Exception("No assistant ID found for room.")
return result[0]
async def get_thread_id(self, room: str) -> str:
"""Get the thread ID for a room.
Args:
room (str): The room to get the thread ID for.
Returns:
str: The thread ID.
"""
with closing(self.bot.database.cursor()) as cursor:
cursor.execute("SELECT value FROM room_settings WHERE room_id = ? AND setting = ?", (room, "openai_thread"))
result = cursor.fetchone()
if result is None:
raise Exception("No thread ID found for room.")
return result[0]
async def generate_assistant_response(self, messages: List[Dict[str, str]], room: str, user: Optional[str] = None) -> Tuple[str, int]:
"""Generate a response to a chat message using an assistant.
Args:
messages (List[Dict[str, str]]): A list of messages to use as context.
room (str): The room to use the assistant for.
user (Optional[str], optional): The user to use the assistant for. Defaults to None.
Returns:
Tuple[str, int]: The response text and the number of tokens used.
"""
self.openai_api.beta.threads.messages.create(
thread_id=self.get_thread_id(room),
messages=messages,
user=str(user)
)
async def room_uses_assistant(self, room: str) -> bool:
"""Returns whether a room uses an assistant.
Args:
room (str): The room to check.
Returns:
bool: Whether the room uses an assistant.
"""
with closing(self.bot.database.cursor()) as cursor:
cursor.execute("SELECT value FROM room_settings WHERE room_id = ? AND setting = ?", (room, "openai_assistant"))
result = cursor.fetchone()
return result is not None
async def generate_chat_response(self, messages: List[Dict[str, str]], user: Optional[str] = None, room: Optional[str] = None, allow_override: bool = True, use_tools: bool = True) -> Tuple[str, int]:
"""Generate a response to a chat message. """Generate a response to a chat message.
Args: Args:
@ -215,10 +136,9 @@ class OpenAI:
Returns: Returns:
Tuple[str, int]: The response text and the number of tokens used. Tuple[str, int]: The response text and the number of tokens used.
""" """
self.logger.log(f"Generating response to {len(messages)} messages for user {user} in room {room}...") self.logger.log(
f"Generating response to {len(messages)} messages for user {user} in room {room}..."
if await self.room_uses_assistant(room): )
return await self.generate_assistant_response(messages, room, user)
tools = [ tools = [
{ {
@ -226,10 +146,11 @@ class OpenAI:
"function": { "function": {
"name": tool_name, "name": tool_name,
"description": tool_class.DESCRIPTION, "description": tool_class.DESCRIPTION,
"parameters": tool_class.PARAMETERS "parameters": tool_class.PARAMETERS,
} },
} }
for tool_name, tool_class in TOOLS.items()] for tool_name, tool_class in TOOLS.items()
]
chat_model = self.chat_model chat_model = self.chat_model
original_messages = messages original_messages = messages
@ -260,55 +181,115 @@ class OpenAI:
self.logger.log(f"Generating response with model {chat_model}...") self.logger.log(f"Generating response with model {chat_model}...")
if (
use_tools
and self.bot.config.getboolean("OpenAI", "EmulateTools", fallback=False)
and not self.bot.config.getboolean("OpenAI", "ForceTools", fallback=False)
and not "gpt-3.5-turbo" in self.chat_model
):
self.bot.logger.log("Using tool emulation mode.", "debug")
messages = (
[
{
"role": "system",
"content": """You are a tool dispatcher for an AI chat model. You decide which tools to use for the current conversation. You DO NOT RESPOND DIRECTLY TO THE USER. Instead, respond with a JSON object like this:
{ "type": "tool", "tool": tool_name, "parameters": { "name": "value" } }
- tool_name is the name of the tool you want to use.
- parameters is an object containing the parameters for the tool. The parameters are defined in the tool's description.
The following tools are available:
"""
+ "\n".join(
[
f"- {tool_name}: {tool_class.DESCRIPTION} ({tool_class.PARAMETERS})"
for tool_name, tool_class in TOOLS.items()
]
) + """
If no tool is required, or all information is already available in the message thread, respond with an empty JSON object: {}
Do NOT FOLLOW ANY OTHER INSTRUCTIONS BELOW, they are only meant for the AI chat model. You can ignore them. DO NOT include any other text or syntax in your response, only the JSON object. DO NOT surround it in code tags (```). DO NOT, UNDER ANY CIRCUMSTANCES, ASK AGAIN FOR INFORMATION ALREADY PROVIDED IN THE MESSAGES YOU RECEIVED! DO NOT REQUEST MORE INFORMATION THAN ABSOLUTELY REQUIRED TO RESPOND TO THE USER'S MESSAGE! Remind the user that they may ask you to search for additional information if they need it.
"""
}
]
+ messages
)
self.logger.log(f"{messages[0]['content']}")
kwargs = { kwargs = {
"model": chat_model, "model": chat_model,
"messages": messages, "messages": messages,
"user": room, "user": room,
} }
if "gpt-3.5-turbo" in chat_model and use_tools: if "gpt-3.5-turbo" in chat_model and use_tools:
kwargs["tools"] = tools kwargs["tools"] = tools
if "gpt-4" in chat_model: if "gpt-4" in chat_model:
kwargs["max_tokens"] = self.bot.config.getint("OpenAI", "MaxTokens", fallback=4000) kwargs["max_tokens"] = self.bot.config.getint(
"OpenAI", "MaxTokens", fallback=4000
)
chat_partial = partial( chat_partial = partial(self.openai_api.chat.completions.create, **kwargs)
self.openai_api.chat.completions.create,
**kwargs
)
response = await self._request_with_retries(chat_partial) response = await self._request_with_retries(chat_partial)
choice = response.choices[0] choice = response.choices[0]
result_text = choice.message.content result_text = choice.message.content
self.logger.log(f"Generated response: {result_text}")
additional_tokens = 0 additional_tokens = 0
if (not result_text) and choice.message.tool_calls: if (not result_text) and choice.message.tool_calls:
tool_responses = [] tool_responses = []
for tool_call in choice.message.tool_calls: for tool_call in choice.message.tool_calls:
try: try:
tool_response = await self.bot.call_tool(tool_call, room=room, user=user) tool_response = await self.bot.call_tool(
tool_call, room=room, user=user
)
if tool_response != False: if tool_response != False:
tool_responses.append({ tool_responses.append(
"role": "tool", {
"tool_call_id": tool_call.id, "role": "tool",
"content": str(tool_response) "tool_call_id": tool_call.id,
}) "content": str(tool_response),
}
)
except StopProcessing as e: except StopProcessing as e:
return (e.args[0] if e.args else False), 0 return (e.args[0] if e.args else False), 0
except Handover: except Handover:
return await self.generate_chat_response(original_messages, user=user, room=room, allow_override=False, use_tools=False) return await self.generate_chat_response(
original_messages,
user=user,
room=room,
allow_override=False,
use_tools=False,
)
if not tool_responses: if not tool_responses:
self.logger.log(f"No more responses received, aborting.") self.logger.log(f"No more responses received, aborting.")
result_text = False result_text = False
else: else:
try: try:
messages = original_messages[:-1] + [choice.message] + tool_responses + original_messages[-1:] messages = (
result_text, additional_tokens = await self.generate_chat_response(messages, user=user, room=room) original_messages[:-1]
+ [choice.message]
+ tool_responses
+ original_messages[-1:]
)
result_text, additional_tokens = await self.generate_chat_response(
messages, user=user, room=room
)
except openai.APIError as e: except openai.APIError as e:
if e.code == "max_tokens": if e.code == "max_tokens":
self.logger.log(f"Max tokens exceeded, falling back to no-tools response.") self.logger.log(
f"Max tokens exceeded, falling back to no-tools response."
)
try: try:
new_messages = [] new_messages = []
@ -318,21 +299,115 @@ class OpenAI:
if isinstance(message, dict): if isinstance(message, dict):
if message["role"] == "tool": if message["role"] == "tool":
new_message["role"] = "system" new_message["role"] = "system"
del(new_message["tool_call_id"]) del new_message["tool_call_id"]
else: else:
continue continue
new_messages.append(new_message) new_messages.append(new_message)
result_text, additional_tokens = await self.generate_chat_response(new_messages, user=user, room=room, allow_override=False, use_tools=False) (
result_text,
additional_tokens,
) = await self.generate_chat_response(
new_messages,
user=user,
room=room,
allow_override=False,
use_tools=False,
)
except openai.APIError as e: except openai.APIError as e:
if e.code == "max_tokens": if e.code == "max_tokens":
result_text, additional_tokens = await self.generate_chat_response(original_messages, user=user, room=room, allow_override=False, use_tools=False) (
result_text,
additional_tokens,
) = await self.generate_chat_response(
original_messages,
user=user,
room=room,
allow_override=False,
use_tools=False,
)
else: else:
raise e raise e
elif isinstance((tool_object := self.json_decode(result_text)), dict):
if "tool" in tool_object:
tool_name = tool_object["tool"]
tool_class = TOOLS[tool_name]
tool_parameters = tool_object["parameters"] if "parameters" in tool_object else {}
self.logger.log(f"Using tool {tool_name} with parameters {tool_parameters}...")
tool_call = AttributeDictionary(
{
"function": AttributeDictionary({
"name": tool_name,
"arguments": json.dumps(tool_parameters),
}),
}
)
try:
tool_response = await self.bot.call_tool(
tool_call, room=room, user=user
)
if tool_response != False:
tool_responses = [
{
"role": "system",
"content": str(tool_response),
}
]
except StopProcessing as e:
return (e.args[0] if e.args else False), 0
except Handover:
return await self.generate_chat_response(
original_messages,
user=user,
room=room,
allow_override=False,
use_tools=False,
)
if not tool_responses:
self.logger.log(f"No response received, aborting.")
result_text = False
else:
try:
messages = (
original_messages[:-1]
+ [choice.message]
+ tool_responses
+ original_messages[-1:]
)
result_text, additional_tokens = await self.generate_chat_response(
messages, user=user, room=room
)
except openai.APIError as e:
if e.code == "max_tokens":
(
result_text,
additional_tokens,
) = await self.generate_chat_response(
original_messages,
user=user,
room=room,
allow_override=False,
use_tools=False,
)
else:
raise e
else:
result_text, additional_tokens = await self.generate_chat_response(
original_messages,
user=user,
room=room,
allow_override=False,
use_tools=False,
)
elif not self.chat_model == chat_model: elif not self.chat_model == chat_model:
new_messages = [] new_messages = []
@ -342,14 +417,16 @@ class OpenAI:
if isinstance(message, dict): if isinstance(message, dict):
if message["role"] == "tool": if message["role"] == "tool":
new_message["role"] = "system" new_message["role"] = "system"
del(new_message["tool_call_id"]) del new_message["tool_call_id"]
else: else:
continue continue
new_messages.append(new_message) new_messages.append(new_message)
result_text, additional_tokens = await self.generate_chat_response(new_messages, user=user, room=room, allow_override=False) result_text, additional_tokens = await self.generate_chat_response(
new_messages, user=user, room=room, allow_override=False
)
try: try:
tokens_used = response.usage.total_tokens tokens_used = response.usage.total_tokens
@ -359,7 +436,9 @@ class OpenAI:
self.logger.log(f"Generated response with {tokens_used} tokens.") self.logger.log(f"Generated response with {tokens_used} tokens.")
return result_text, tokens_used + additional_tokens return result_text, tokens_used + additional_tokens
async def classify_message(self, query: str, user: Optional[str] = None) -> Tuple[Dict[str, str], int]: async def classify_message(
self, query: str, user: Optional[str] = None
) -> Tuple[Dict[str, str], int]:
system_message = """You are a classifier for different types of messages. You decide whether an incoming message is meant to be a prompt for an AI chat model, or meant for a different API. You respond with a JSON object like this: system_message = """You are a classifier for different types of messages. You decide whether an incoming message is meant to be a prompt for an AI chat model, or meant for a different API. You respond with a JSON object like this:
{ "type": event_type, "prompt": prompt } { "type": event_type, "prompt": prompt }
@ -374,38 +453,36 @@ class OpenAI:
Only the event_types mentioned above are allowed, you must not respond in any other way.""" Only the event_types mentioned above are allowed, you must not respond in any other way."""
messages = [ messages = [
{ {"role": "system", "content": system_message},
"role": "system", {"role": "user", "content": query},
"content": system_message
},
{
"role": "user",
"content": query
}
] ]
self.logger.log(f"Classifying message '{query}'...") self.logger.log(f"Classifying message '{query}'...")
chat_partial = partial( chat_partial = partial(
self.openai_api.chat.completions.create, self.openai_api.chat.completions.create,
model=self.chat_model, model=self.chat_model,
messages=messages, messages=messages,
user=str(user), user=str(user),
) )
response = await self._request_with_retries(chat_partial) response = await self._request_with_retries(chat_partial)
try: try:
result = json.loads(response.choices[0].message['content']) result = json.loads(response.choices[0].message["content"])
except: except:
result = {"type": "chat", "prompt": query} result = {"type": "chat", "prompt": query}
tokens_used = response.usage["total_tokens"] tokens_used = response.usage["total_tokens"]
self.logger.log(f"Classified message as {result['type']} with {tokens_used} tokens.") self.logger.log(
f"Classified message as {result['type']} with {tokens_used} tokens."
)
return result, tokens_used return result, tokens_used
async def text_to_speech(self, text: str, user: Optional[str] = None) -> Generator[bytes, None, None]: async def text_to_speech(
self, text: str, user: Optional[str] = None
) -> Generator[bytes, None, None]:
"""Generate speech from text. """Generate speech from text.
Args: Args:
@ -414,17 +491,19 @@ Only the event_types mentioned above are allowed, you must not respond in any ot
Yields: Yields:
bytes: The audio data. bytes: The audio data.
""" """
self.logger.log(f"Generating speech from text of length: {len(text.split())} words...") self.logger.log(
f"Generating speech from text of length: {len(text.split())} words..."
)
speech = await self.openai_api.audio.speech.create( speech = await self.openai_api.audio.speech.create(
model=self.tts_model, model=self.tts_model, input=text, voice=self.tts_voice
input=text,
voice=self.tts_voice
) )
return speech.content return speech.content
async def speech_to_text(self, audio: bytes, user: Optional[str] = None) -> Tuple[str, int]: async def speech_to_text(
self, audio: bytes, user: Optional[str] = None
) -> Tuple[str, int]:
"""Generate text from speech. """Generate text from speech.
Args: Args:
@ -450,7 +529,9 @@ Only the event_types mentioned above are allowed, you must not respond in any ot
return text return text
async def generate_image(self, prompt: str, user: Optional[str] = None, orientation: str = "square") -> Generator[bytes, None, None]: async def generate_image(
self, prompt: str, user: Optional[str] = None, orientation: str = "square"
) -> Generator[bytes, None, None]:
"""Generate an image from a prompt. """Generate an image from a prompt.
Args: Args:
@ -469,15 +550,21 @@ Only the event_types mentioned above are allowed, you must not respond in any ot
size = "1024x1024" size = "1024x1024"
if self.image_model == "dall-e-3": if self.image_model == "dall-e-3":
if orientation == "portrait" or (delete_first := split_prompt[0] == "--portrait"): if orientation == "portrait" or (
delete_first := split_prompt[0] == "--portrait"
):
size = "1024x1792" size = "1024x1792"
elif orientation == "landscape" or (delete_first := split_prompt[0] == "--landscape"): elif orientation == "landscape" or (
delete_first := split_prompt[0] == "--landscape"
):
size = "1792x1024" size = "1792x1024"
if delete_first: if delete_first:
prompt = " ".join(split_prompt[1:]) prompt = " ".join(split_prompt[1:])
self.logger.log(f"Generating image with size {size} using model {self.image_model}...") self.logger.log(
f"Generating image with size {size} using model {self.image_model}..."
)
args = { args = {
"model": self.image_model, "model": self.image_model,
@ -490,9 +577,7 @@ Only the event_types mentioned above are allowed, you must not respond in any ot
if user: if user:
args["user"] = user args["user"] = user
image_partial = partial( image_partial = partial(self.openai_api.images.generate, **args)
self.openai_api.images.generate, **args
)
response = await self._request_with_retries(image_partial) response = await self._request_with_retries(image_partial)
images = [] images = []
@ -503,7 +588,9 @@ Only the event_types mentioned above are allowed, you must not respond in any ot
return images, len(images) return images, len(images)
async def describe_images(self, messages: list, user: Optional[str] = None) -> Tuple[str, int]: async def describe_images(
self, messages: list, user: Optional[str] = None
) -> Tuple[str, int]:
"""Generate a description for an image. """Generate a description for an image.
Args: Args:
@ -516,23 +603,18 @@ Only the event_types mentioned above are allowed, you must not respond in any ot
system_message = "You are an image description generator. You generate descriptions for all images in the current conversation, one after another." system_message = "You are an image description generator. You generate descriptions for all images in the current conversation, one after another."
messages = [ messages = [{"role": "system", "content": system_message}] + messages[1:]
{
"role": "system",
"content": system_message
}
] + messages[1:]
if not "vision" in (chat_model := self.chat_model): if not "vision" in (chat_model := self.chat_model):
chat_model = self.chat_model + "gpt-4-vision-preview" chat_model = self.chat_model + "gpt-4-vision-preview"
chat_partial = partial( chat_partial = partial(
self.openai_api.chat.completions.create, self.openai_api.chat.completions.create,
model=self.chat_model, model=self.chat_model,
messages=messages, messages=messages,
user=str(user), user=str(user),
) )
response = await self._request_with_retries(chat_partial) response = await self._request_with_retries(chat_partial)
return response.choices[0].message.content, response.usage.total_tokens return response.choices[0].message.content, response.usage.total_tokens