From 1be54cc0e12bd45cfb43c6d31077a139989797b1 Mon Sep 17 00:00:00 2001 From: rbuchmayer-pplx Date: Fri, 5 Jun 2026 12:14:42 -0700 Subject: [PATCH] fix(perplexity): serialize `ToolMessage` and `AIMessage.tool_calls` (#37911) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Mason Daugherty Co-authored-by: Mason Daugherty --- .../langchain_perplexity/chat_models.py | 35 +++++ .../tests/unit_tests/test_chat_models.py | 134 +++++++++++++++++- libs/partners/perplexity/uv.lock | 2 +- 3 files changed, 169 insertions(+), 2 deletions(-) diff --git a/libs/partners/perplexity/langchain_perplexity/chat_models.py b/libs/partners/perplexity/langchain_perplexity/chat_models.py index 9ad6cf0b306..c2135433c94 100644 --- a/libs/partners/perplexity/langchain_perplexity/chat_models.py +++ b/libs/partners/perplexity/langchain_perplexity/chat_models.py @@ -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 diff --git a/libs/partners/perplexity/tests/unit_tests/test_chat_models.py b/libs/partners/perplexity/tests/unit_tests/test_chat_models.py index 5198ff0a012..db48c171e8f 100644 --- a/libs/partners/perplexity/tests/unit_tests/test_chat_models.py +++ b/libs/partners/perplexity/tests/unit_tests/test_chat_models.py @@ -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"}, + }, + ] diff --git a/libs/partners/perplexity/uv.lock b/libs/partners/perplexity/uv.lock index 5fdf609ba5d..8a6c5ad3bca 100644 --- a/libs/partners/perplexity/uv.lock +++ b/libs/partners/perplexity/uv.lock @@ -434,7 +434,7 @@ wheels = [ [[package]] name = "langchain-core" -version = "1.4.0" +version = "1.4.1" source = { editable = "../../core" } dependencies = [ { name = "jsonpatch" },