diff --git a/litellm/llms/prompt_templates/factory.py b/litellm/llms/prompt_templates/factory.py
index 1e514fa0c93..0347e5fd6fa 100644
--- a/litellm/llms/prompt_templates/factory.py
+++ b/litellm/llms/prompt_templates/factory.py
@@ -551,6 +551,81 @@ def convert_to_anthropic_image_obj(openai_image_url: str):
)
+def convert_to_anthropic_tool_result(message: dict) -> str:
+ """
+ OpenAI message with a tool result looks like:
+ {
+ "tool_call_id": "tool_1",
+ "role": "tool",
+ "name": "get_current_weather",
+ "content": "function result goes here",
+ },
+ """
+
+ """
+ Anthropic tool_results look like:
+
+ [Successful results]
+
+
+ get_current_weather
+
+ function result goes here
+
+
+
+
+ [Error results]
+
+
+ error message goes here
+
+
+ """
+ name = message.get("name")
+ content = message.get("content")
+
+ # We can't determine from openai message format whether it's a successful or
+ # error call result so default to the successful result template
+ anthropic_tool_result = (
+ "\n"
+ "\n"
+ f"{name}\n"
+ "\n"
+ f"{content}\n"
+ "\n"
+ "\n"
+ ""
+ )
+
+ return anthropic_tool_result
+
+
+def convert_to_anthropic_tool_invoke(tool_calls: list) -> str:
+ invokes = ""
+ for tool in tool_calls:
+ if tool["type"] != "function":
+ continue
+
+ tool_name = tool["function"]["name"]
+ parameters = "".join(
+ f"<{param}>{val}{param}>\n"
+ for param, val in json.loads(tool["function"]["arguments"]).items()
+ )
+ invokes += (
+ "\n"
+ f"{tool_name}\n"
+ "\n"
+ f"{parameters}"
+ "\n"
+ "\n"
+ )
+
+ anthropic_tool_invoke = f"\n{invokes}"
+
+ return anthropic_tool_invoke
+
+
def anthropic_messages_pt(messages: list):
"""
format messages for anthropic
@@ -561,82 +636,75 @@ def anthropic_messages_pt(messages: list):
5. System messages are a separate param to the Messages API (used for tool calling)
6. Ensure we only accept role, content. (message.name is not supported)
"""
- ## Ensure final assistant message has no trailing whitespace
- last_assistant_message_idx: Optional[int] = None
+ # add role=tool support to allow function call result/error submission
+ user_message_types = {"user", "tool"}
# reformat messages to ensure user/assistant are alternating, if there's either 2 consecutive 'user' messages or 2 consecutive 'assistant' message, add a blank 'user' or 'assistant' message to ensure compatibility
new_messages = []
- if len(messages) == 1:
- # check if the message is a user message
- if messages[0]["role"] == "assistant":
- new_messages.append({"role": "user", "content": ""})
-
- # check if content is a list (vision)
- if isinstance(messages[0]["content"], list): # vision input
- new_content = []
- for m in messages[0]["content"]:
- if m.get("type", "") == "image_url":
- new_content.append(
- {
- "type": "image",
- "source": convert_to_anthropic_image_obj(
- m["image_url"]["url"]
- ),
- }
- )
- elif m.get("type", "") == "text":
- new_content.append({"type": "text", "text": m["text"]})
- new_messages.append({"role": messages[0]["role"], "content": new_content}) # type: ignore
- else:
- new_messages.append(
- {"role": messages[0]["role"], "content": messages[0]["content"]}
- )
-
- return new_messages
-
- for i in range(len(messages) - 1): # type: ignore
- if i == 0 and messages[i]["role"] == "assistant":
- new_messages.append({"role": "user", "content": ""})
- if isinstance(messages[i]["content"], list): # vision input
- new_content = []
- for m in messages[i]["content"]:
- if m.get("type", "") == "image_url":
- new_content.append(
- {
- "type": "image",
- "source": convert_to_anthropic_image_obj(
- m["image_url"]["url"]
- ),
- }
- )
- elif m.get("type", "") == "text":
- new_content.append({"type": "text", "content": m["text"]})
- new_messages.append({"role": messages[i]["role"], "content": new_content}) # type: ignore
- else:
- new_messages.append(
- {"role": messages[i]["role"], "content": messages[i]["content"]}
- )
-
- if messages[i]["role"] == messages[i + 1]["role"]:
- if messages[i]["role"] == "user":
- new_messages.append({"role": "assistant", "content": ""})
+ msg_i = 0
+ while msg_i < len(messages):
+ user_content = []
+ while msg_i < len(messages) and messages[msg_i]["role"] in user_message_types:
+ if isinstance(messages[msg_i]["content"], list):
+ for m in messages[msg_i]["content"]:
+ if m.get("type", "") == "image_url":
+ user_content.append(
+ {
+ "type": "image",
+ "source": convert_to_anthropic_image_obj(
+ m["image_url"]["url"]
+ ),
+ }
+ )
+ elif m.get("type", "") == "text":
+ user_content.append({"type": "text", "text": m["text"]})
else:
- new_messages.append({"role": "user", "content": ""})
+ # Tool message content will always be a string
+ user_content.append(
+ {
+ "type": "text",
+ "text": (
+ convert_to_anthropic_tool_result(messages[msg_i])
+ if messages[msg_i]["role"] == "tool"
+ else messages[msg_i]["content"]
+ ),
+ }
+ )
- if messages[i]["role"] == "assistant":
- last_assistant_message_idx = i
+ msg_i += 1
+
+ if user_content:
+ new_messages.append({"role": "user", "content": user_content})
+
+ assistant_content = []
+ while msg_i < len(messages) and messages[msg_i]["role"] == "assistant":
+ assistant_text = (
+ messages[msg_i].get("content") or ""
+ ) # either string or none
+ if messages[msg_i].get(
+ "tool_calls", []
+ ): # support assistant tool invoke convertion
+ assistant_text += convert_to_anthropic_tool_invoke(
+ messages[msg_i]["tool_calls"]
+ )
+
+ assistant_content.append({"type": "text", "text": assistant_text})
+ msg_i += 1
+
+ if assistant_content:
+ new_messages.append({"role": "assistant", "content": assistant_content})
+
+ if new_messages[0]["role"] != "user":
+ new_messages.insert(
+ 0, {"role": "user", "content": [{"type": "text", "text": "."}]}
+ )
+
+ if new_messages[-1]["role"] == "assistant":
+ for content in new_messages[-1]["content"]:
+ if content["type"] == "text":
+ content["text"] = content[
+ "text"
+ ].rstrip() # no trailing whitespace for final assistant message
- new_messages.append(messages[-1])
- if last_assistant_message_idx is not None:
- try:
- new_messages[last_assistant_message_idx]["content"] = new_messages[
- last_assistant_message_idx
- ][
- "content"
- ].strip() # no trailing whitespace for final assistant message
- except Exception as e:
- raise ValueError(
- f"LiteLLMException: Invalid final assistant message passed in. Messages={messages}"
- )
return new_messages
diff --git a/litellm/tests/test_completion.py b/litellm/tests/test_completion.py
index 3d95e66e7e2..44e2f7af62d 100644
--- a/litellm/tests/test_completion.py
+++ b/litellm/tests/test_completion.py
@@ -152,6 +152,52 @@ def test_completion_claude_3_function_call():
assert isinstance(
response.choices[0].message.tool_calls[0].function.arguments, str
)
+
+ messages.append(
+ response.choices[0].message.model_dump()
+ ) # Add assistant tool invokes
+ tool_result = (
+ '{"location": "Boston", "temperature": "72", "unit": "fahrenheit"}'
+ )
+ # Add user submitted tool results in OpenAI format
+ messages.append(
+ {
+ "tool_call_id": response.choices[0].message.tool_calls[0].id,
+ "role": "tool",
+ "name": response.choices[0].message.tool_calls[0].function.name,
+ "content": tool_result,
+ }
+ )
+ # In the second response, Claude should deduce answer from tool results
+ second_response = completion(
+ model="anthropic/claude-3-opus-20240229",
+ messages=messages,
+ tools=tools,
+ tool_choice="auto",
+ )
+ print(second_response)
+ except Exception as e:
+ pytest.fail(f"Error occurred: {e}")
+
+
+def test_completion_claude_3_multi_turn_conversations():
+ litellm.set_verbose = True
+ messages = [
+ {"role": "assistant", "content": "?"}, # test first user message auto injection
+ {"role": "user", "content": "Hi!"},
+ {
+ "role": "user",
+ "content": [{"type": "text", "text": "What is the weather like today?"}],
+ },
+ {"role": "assistant", "content": "Hi! I am Claude. "},
+ {"role": "assistant", "content": "Today is a sunny "},
+ ]
+ try:
+ response = completion(
+ model="anthropic/claude-3-opus-20240229",
+ messages=messages,
+ )
+ print(response)
except Exception as e:
pytest.fail(f"Error occurred: {e}")