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:
rbuchmayer-pplx
2026-06-05 12:14:42 -07:00
committed by GitHub
parent 1d09011b1d
commit 1be54cc0e1
3 changed files with 169 additions and 2 deletions

View File

@@ -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

View File

@@ -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"},
},
]

View File

@@ -434,7 +434,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.4.0"
version = "1.4.1"
source = { editable = "../../core" }
dependencies = [
{ name = "jsonpatch" },