mirror of
https://github.com/hwchase17/langchain.git
synced 2026-06-09 10:17:00 +00:00
fix(perplexity): serialize ToolMessage and AIMessage.tool_calls (#37911)
Fixes #37912 `ChatPerplexity._convert_message_to_dict` raises `TypeError` on `ToolMessage` and drops `AIMessage.tool_calls`, which breaks tool-message round-trips through `ChatPerplexity` — a client-side tool-calling loop, or a shared message history across providers via `RunnableWithFallbacks`. Repro: ```python from langchain_perplexity import ChatPerplexity from langchain_core.messages import ToolMessage ChatPerplexity(model="sonar")._convert_message_to_dict( ToolMessage(content="result", tool_call_id="call_1") ) # TypeError: Got unknown type content='result' tool_call_id='call_1' ``` An `AIMessage` carrying `tool_calls` also serializes to `{"role": "assistant", "content": ...}` with the `tool_calls` silently dropped. This brings the converter to parity with `langchain-openai`: serialize `tool_calls` / `invalid_tool_calls`, send `content` as `null` when tool_calls are present, and add a `tool`-role branch for `ToolMessage`. How I verified: added unit tests for the `ToolMessage` and `AIMessage.tool_calls` / `invalid_tool_calls` cases; the perplexity package unit tests, lint, and format all pass. Scope: translating these to the Responses (Agent) API's `function_call` / `function_call_output` input items is a separate follow-up; this PR is the Chat Completions serialization parity fix. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Mason Daugherty <mason@langchain.dev> Co-authored-by: Mason Daugherty <github@mdrxy.com>
This commit is contained in:
@@ -34,6 +34,7 @@ from langchain_core.messages import (
|
||||
HumanMessageChunk,
|
||||
SystemMessage,
|
||||
SystemMessageChunk,
|
||||
ToolMessage,
|
||||
ToolMessageChunk,
|
||||
)
|
||||
from langchain_core.messages.ai import (
|
||||
@@ -770,6 +771,7 @@ class ChatPerplexity(BaseChatModel):
|
||||
return {**params, **self.model_kwargs}
|
||||
|
||||
def _convert_message_to_dict(self, message: BaseMessage) -> dict[str, Any]:
|
||||
message_dict: dict[str, Any]
|
||||
if isinstance(message, ChatMessage):
|
||||
message_dict = {"role": message.role, "content": message.content}
|
||||
elif isinstance(message, SystemMessage):
|
||||
@@ -778,6 +780,39 @@ class ChatPerplexity(BaseChatModel):
|
||||
message_dict = {"role": "user", "content": message.content}
|
||||
elif isinstance(message, AIMessage):
|
||||
message_dict = {"role": "assistant", "content": message.content}
|
||||
if message.tool_calls or message.invalid_tool_calls:
|
||||
message_dict["tool_calls"] = [
|
||||
{
|
||||
"id": tool_call["id"],
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_call["name"],
|
||||
"arguments": json.dumps(
|
||||
tool_call["args"], ensure_ascii=False
|
||||
),
|
||||
},
|
||||
}
|
||||
for tool_call in message.tool_calls
|
||||
] + [
|
||||
{
|
||||
"id": tool_call["id"],
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_call["name"],
|
||||
"arguments": tool_call["args"],
|
||||
},
|
||||
}
|
||||
for tool_call in message.invalid_tool_calls
|
||||
]
|
||||
# OpenAI-compatible APIs reject empty-string content alongside
|
||||
# tool_calls; send null instead.
|
||||
message_dict["content"] = message_dict["content"] or None
|
||||
elif isinstance(message, ToolMessage):
|
||||
message_dict = {
|
||||
"role": "tool",
|
||||
"content": message.content,
|
||||
"tool_call_id": message.tool_call_id,
|
||||
}
|
||||
else:
|
||||
raise TypeError(f"Got unknown type {message}")
|
||||
return message_dict
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import json
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from langchain_core.messages import AIMessageChunk, BaseMessage
|
||||
from langchain_core.messages import (
|
||||
AIMessage,
|
||||
AIMessageChunk,
|
||||
BaseMessage,
|
||||
ToolMessage,
|
||||
)
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from langchain_perplexity import ChatPerplexity, MediaResponse, WebSearchOptions
|
||||
@@ -211,3 +217,129 @@ def test_perplexity_invoke_includes_num_search_queries(mocker: MockerFixture) ->
|
||||
def test_profile() -> None:
|
||||
model = ChatPerplexity(model="sonar")
|
||||
assert model.profile
|
||||
|
||||
|
||||
def test_convert_tool_message_to_dict() -> None:
|
||||
"""A ToolMessage serializes to a ``tool``-role dict so tool results can be
|
||||
fed back to the model in a client-side tool-calling loop."""
|
||||
llm = ChatPerplexity(model="test", api_key="test")
|
||||
message = ToolMessage(content="result text", tool_call_id="call_123")
|
||||
assert llm._convert_message_to_dict(message) == {
|
||||
"role": "tool",
|
||||
"content": "result text",
|
||||
"tool_call_id": "call_123",
|
||||
}
|
||||
|
||||
|
||||
def test_convert_ai_message_with_tool_calls_to_dict() -> None:
|
||||
"""``AIMessage.tool_calls`` are serialized rather than dropped."""
|
||||
llm = ChatPerplexity(model="test", api_key="test")
|
||||
message = AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{
|
||||
"id": "call_123",
|
||||
"name": "search",
|
||||
"args": {"query": "langchain"},
|
||||
"type": "tool_call",
|
||||
}
|
||||
],
|
||||
)
|
||||
result = llm._convert_message_to_dict(message)
|
||||
assert result["role"] == "assistant"
|
||||
# Empty content alongside tool_calls must be sent as null, not "".
|
||||
assert result["content"] is None
|
||||
assert result["tool_calls"] == [
|
||||
{
|
||||
"id": "call_123",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search",
|
||||
"arguments": json.dumps({"query": "langchain"}),
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_convert_ai_message_with_invalid_tool_calls_to_dict() -> None:
|
||||
"""Invalid tool calls are serialized with their raw (unparsed) argument string."""
|
||||
llm = ChatPerplexity(model="test", api_key="test")
|
||||
message = AIMessage(
|
||||
content="",
|
||||
invalid_tool_calls=[
|
||||
{
|
||||
"id": "call_bad",
|
||||
"name": "search",
|
||||
"args": "{not valid json",
|
||||
"error": "could not parse args",
|
||||
"type": "invalid_tool_call",
|
||||
}
|
||||
],
|
||||
)
|
||||
result = llm._convert_message_to_dict(message)
|
||||
assert result["tool_calls"] == [
|
||||
{
|
||||
"id": "call_bad",
|
||||
"type": "function",
|
||||
"function": {"name": "search", "arguments": "{not valid json"},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_convert_ai_message_preserves_content_alongside_tool_calls() -> None:
|
||||
"""Non-empty content is preserved (not nulled) when tool_calls are present."""
|
||||
llm = ChatPerplexity(model="test", api_key="test")
|
||||
message = AIMessage(
|
||||
content="Let me look that up.",
|
||||
tool_calls=[
|
||||
{
|
||||
"id": "call_123",
|
||||
"name": "search",
|
||||
"args": {"query": "weather"},
|
||||
"type": "tool_call",
|
||||
}
|
||||
],
|
||||
)
|
||||
result = llm._convert_message_to_dict(message)
|
||||
assert result["content"] == "Let me look that up."
|
||||
|
||||
|
||||
def test_convert_ai_message_with_valid_and_invalid_tool_calls_to_dict() -> None:
|
||||
"""Valid and invalid tool calls serialize together, valid ones first."""
|
||||
llm = ChatPerplexity(model="test", api_key="test")
|
||||
message = AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{
|
||||
"id": "call_ok",
|
||||
"name": "search",
|
||||
"args": {"query": "weather"},
|
||||
"type": "tool_call",
|
||||
}
|
||||
],
|
||||
invalid_tool_calls=[
|
||||
{
|
||||
"id": "call_bad",
|
||||
"name": "search",
|
||||
"args": "{not valid json",
|
||||
"error": "could not parse args",
|
||||
"type": "invalid_tool_call",
|
||||
}
|
||||
],
|
||||
)
|
||||
result = llm._convert_message_to_dict(message)
|
||||
assert result["tool_calls"] == [
|
||||
{
|
||||
"id": "call_ok",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search",
|
||||
"arguments": json.dumps({"query": "weather"}),
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "call_bad",
|
||||
"type": "function",
|
||||
"function": {"name": "search", "arguments": "{not valid json"},
|
||||
},
|
||||
]
|
||||
|
||||
2
libs/partners/perplexity/uv.lock
generated
2
libs/partners/perplexity/uv.lock
generated
@@ -434,7 +434,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.4.0"
|
||||
version = "1.4.1"
|
||||
source = { editable = "../../core" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
|
||||
Reference in New Issue
Block a user