Compare commits

..
2 Commits
Author SHA1 Message Date
Radu RaiceaandGitHub 09b9b5dc88 bug(llmo): fix anthropic tool call response (#297)
* bug(llmo): fix anthropic's tool call response

* bug(llmo): fix tool calls response handling for anthropic

* bug(llmo): run formatter

* bug(llmo): bump version

* bug(llmo): add date to changelog
2025-07-31 17:13:44 -04:00
Georgiy TarasovandGitHub 5a52af66a9 fix(ai): capture tool calls in reasoning models (#292)
* fix: capture tool calls in reasoning models

* fix: check for empty tool calls
2025-07-23 11:51:58 +02:00
6 changed files with 164 additions and 5 deletions
+4
View File
@@ -1,3 +1,7 @@
# 6.3.2 - 2025-07-31
- fix: Anthropic's tool calls are now handled properly
# 6.3.0 - 2025-07-22
- feat: Enhanced `send_feature_flags` parameter to accept `SendFeatureFlagsOptions` object for declarative control over local/remote evaluation and custom properties
+29 -1
View File
@@ -5,6 +5,7 @@ except ImportError:
"Please install LangChain to use this feature: 'pip install langchain'"
)
import json
import logging
import time
from dataclasses import dataclass
@@ -29,6 +30,7 @@ from langchain_core.messages import (
HumanMessage,
SystemMessage,
ToolMessage,
ToolCall,
)
from langchain_core.outputs import ChatGeneration, LLMResult
from pydantic import BaseModel
@@ -629,12 +631,35 @@ def _extract_raw_esponse(last_response):
return ""
def _convert_message_to_dict(message: BaseMessage) -> Dict[str, Any]:
def _convert_lc_tool_calls_to_oai(
tool_calls: list[ToolCall],
) -> list[dict[str, Any]]:
try:
return [
{
"type": "function",
"id": tool_call["id"],
"function": {
"name": tool_call["name"],
"arguments": json.dumps(tool_call["args"]),
},
}
for tool_call in tool_calls
]
except KeyError:
return tool_calls
def _convert_message_to_dict(message: BaseMessage) -> dict[str, Any]:
# assistant message
if isinstance(message, HumanMessage):
message_dict = {"role": "user", "content": message.content}
elif isinstance(message, AIMessage):
message_dict = {"role": "assistant", "content": message.content}
if message.tool_calls:
message_dict["tool_calls"] = _convert_lc_tool_calls_to_oai(
message.tool_calls
)
elif isinstance(message, SystemMessage):
message_dict = {"role": "system", "content": message.content}
elif isinstance(message, ToolMessage):
@@ -647,6 +672,9 @@ def _convert_message_to_dict(message: BaseMessage) -> Dict[str, Any]:
if message.additional_kwargs:
message_dict.update(message.additional_kwargs)
if "content" in message_dict and not message_dict["content"]:
message_dict["content"] = ""
return message_dict
+22 -3
View File
@@ -118,7 +118,12 @@ def format_response(response, provider: str):
def format_response_anthropic(response):
output = []
for choice in response.content:
if choice.text:
if (
hasattr(choice, "type")
and choice.type == "text"
and hasattr(choice, "text")
and choice.text
):
output.append(
{
"role": "assistant",
@@ -225,8 +230,21 @@ def format_response_gemini(response):
def format_tool_calls(response, provider: str):
if provider == "anthropic":
if hasattr(response, "tools") and response.tools and len(response.tools) > 0:
return response.tools
if hasattr(response, "content") and response.content:
tool_calls = []
for content_item in response.content:
if hasattr(content_item, "type") and content_item.type == "tool_use":
tool_calls.append(
{
"type": content_item.type,
"id": content_item.id,
"name": content_item.name,
"input": content_item.input,
}
)
return tool_calls if tool_calls else None
elif provider == "openai":
# Handle both Chat Completions and Responses API
if hasattr(response, "choices") and response.choices:
@@ -378,6 +396,7 @@ def call_llm_and_track_usage(
}
tool_calls = format_tool_calls(response, provider)
if tool_calls:
event_properties["$ai_tools"] = with_privacy_mode(
ph_client, posthog_privacy_mode, tool_calls
@@ -88,6 +88,31 @@ def mock_anthropic_response_with_cached_tokens():
)
@pytest.fixture
def mock_anthropic_response_with_tool_use():
return Message(
id="msg_123",
type="message",
role="assistant",
content=[
{"type": "text", "text": "I'll help you with that."},
{
"type": "tool_use",
"id": "tool_1",
"name": "get_weather",
"input": {"location": "New York"},
},
],
model="claude-3-opus-20240229",
usage=Usage(
input_tokens=20,
output_tokens=10,
),
stop_reason="end_turn",
stop_sequence=None,
)
def test_basic_completion(mock_client, mock_anthropic_response):
with patch(
"anthropic.resources.Messages.create", return_value=mock_anthropic_response
@@ -434,3 +459,49 @@ def test_cached_tokens(mock_client, mock_anthropic_response_with_cached_tokens):
assert props["$ai_http_status"] == 200
assert props["foo"] == "bar"
assert isinstance(props["$ai_latency"], float)
def test_tool_use_response(mock_client, mock_anthropic_response_with_tool_use):
with patch(
"anthropic.resources.Messages.create",
return_value=mock_anthropic_response_with_tool_use,
):
client = Anthropic(api_key="test-key", posthog_client=mock_client)
response = client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "What's the weather like?"}],
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_anthropic_response_with_tool_use
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "anthropic"
assert props["$ai_model"] == "claude-3-opus-20240229"
assert props["$ai_input"] == [
{"role": "user", "content": "What's the weather like?"}
]
# Should only include text content, not tool_use content
assert props["$ai_output_choices"] == [
{"role": "assistant", "content": "I'll help you with that."}
]
assert props["$ai_input_tokens"] == 20
assert props["$ai_output_tokens"] == 10
assert props["$ai_http_status"] == 200
assert props["foo"] == "bar"
assert isinstance(props["$ai_latency"], float)
# Verify that tools are captured separately
assert props["$ai_tools"] == [
{
"type": "tool_use",
"id": "tool_1",
"name": "get_weather",
"input": {"location": "New York"},
}
]
@@ -1753,3 +1753,40 @@ def test_callback_handler_without_client():
# Verify that the mock client was used for capturing events
assert mock_client.capture.call_count == 3
def test_convert_message_to_dict_tool_calls():
"""Test that _convert_message_to_dict properly converts tool calls in AIMessage."""
from posthog.ai.langchain.callbacks import _convert_message_to_dict
from langchain_core.messages import AIMessage
from langchain_core.messages.tool import ToolCall
# Create an AIMessage with tool calls
tool_calls = [
ToolCall(
id="call_123",
name="get_weather",
args={"city": "San Francisco", "units": "celsius"},
)
]
ai_message = AIMessage(
content="I'll check the weather for you.", tool_calls=tool_calls
)
# Convert to dict
result = _convert_message_to_dict(ai_message)
# Verify the conversion
assert result["role"] == "assistant"
assert result["content"] == "I'll check the weather for you."
assert result["tool_calls"] == [
{
"type": "function",
"id": "call_123",
"function": {
"name": "get_weather",
"arguments": '{"city": "San Francisco", "units": "celsius"}',
},
}
]
+1 -1
View File
@@ -1,4 +1,4 @@
VERSION = "6.3.0"
VERSION = "6.3.2"
if __name__ == "__main__":
print(VERSION, end="") # noqa: T201