diff --git a/docs/docs/integrations/chat/perplexity.ipynb b/docs/docs/integrations/chat/perplexity.ipynb index c28c57fd88a..42f543c8ace 100644 --- a/docs/docs/integrations/chat/perplexity.ipynb +++ b/docs/docs/integrations/chat/perplexity.ipynb @@ -240,6 +240,65 @@ "response.content" ] }, + { + "cell_type": "markdown", + "id": "382335a6", + "metadata": {}, + "source": [ + "### Accessing the search results metadata\n", + "\n", + "Perplexity often provides a list of the web pages it consulted (“search_results”).\n", + "You don't need to pass any special parameter — the list is placed in\n", + "`response.additional_kwargs[\"search_results\"]`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2b09214a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The tallest mountain in South America is Aconcagua. It has a summit elevation of approximately 6,961 meters (22,838 feet), making it not only the highest peak in South America but also the highest mountain in the Americas, the Western Hemisphere, and the Southern Hemisphere[1][2][4].\n", + "\n", + "Aconcagua is located in the Principal Cordillera of the Andes mountain range, in Mendoza Province, Argentina, near the border with Chile[1][2][4]. It is of volcanic origin but is not an active volcano[4]. The mountain is part of Aconcagua Provincial Park and features several glaciers, including the large Ventisquero Horcones Inferior glacier[1].\n", + "\n", + "In summary, Aconcagua stands as the tallest mountain in South America at about 6,961 meters (22,838 feet) in height.\n" + ] + }, + { + "data": { + "text/plain": [ + "[{'title': 'Aconcagua - Wikipedia',\n", + " 'url': 'https://en.wikipedia.org/wiki/Aconcagua',\n", + " 'date': None},\n", + " {'title': 'The 10 Highest Mountains in South America - Much Better Adventures',\n", + " 'url': 'https://www.muchbetteradventures.com/magazine/highest-mountains-south-america/',\n", + " 'date': '2023-07-05'}]" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "chat = ChatPerplexity(temperature=0, model=\"sonar\")\n", + "\n", + "response = chat.invoke(\n", + " \"What is the tallest mountain in South America?\",\n", + ")\n", + "\n", + "# Main answer\n", + "print(response.content)\n", + "\n", + "# First two supporting search results\n", + "response.additional_kwargs[\"search_results\"][:2]" + ] + }, { "cell_type": "markdown", "id": "13d93dc4", diff --git a/docs/scripts/tool_feat_table.py b/docs/scripts/tool_feat_table.py index d0f2a134ff7..d0e2e7a7db0 100644 --- a/docs/scripts/tool_feat_table.py +++ b/docs/scripts/tool_feat_table.py @@ -162,6 +162,11 @@ WEBBROWSING_TOOL_FEAT_TABLE = { "interactions": False, "pricing": "Free trial, with flat rate plans and pre-paid credits after", }, + "Oxylabs Web Scraper API": { + "link": "docs/integrations/tools/oxylabs", + "interactions": False, + "pricing": "Free trial, with flat rate plans and pre-paid credits after", + }, } DATABASE_TOOL_FEAT_TABLE = { diff --git a/libs/partners/deepseek/langchain_deepseek/chat_models.py b/libs/partners/deepseek/langchain_deepseek/chat_models.py index 6ad5a5fee1a..8bf4d0516a7 100644 --- a/libs/partners/deepseek/langchain_deepseek/chat_models.py +++ b/libs/partners/deepseek/langchain_deepseek/chat_models.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from collections.abc import Iterator from json import JSONDecodeError from typing import Any, Literal, Optional, TypeVar, Union @@ -218,6 +219,19 @@ class ChatDeepSeek(BaseChatOpenAI): self.async_client = self.root_async_client.chat.completions return self + def _get_request_payload( + self, + input_: LanguageModelInput, + *, + stop: Optional[list[str]] = None, + **kwargs: Any, + ) -> dict: + payload = super()._get_request_payload(input_, stop=stop, **kwargs) + for message in payload["messages"]: + if message["role"] == "tool" and isinstance(message["content"], list): + message["content"] = json.dumps(message["content"]) + return payload + def _create_chat_result( self, response: Union[dict, openai.BaseModel], diff --git a/libs/partners/deepseek/tests/unit_tests/test_chat_models.py b/libs/partners/deepseek/tests/unit_tests/test_chat_models.py index 15192f46bac..13a83c03ba1 100644 --- a/libs/partners/deepseek/tests/unit_tests/test_chat_models.py +++ b/libs/partners/deepseek/tests/unit_tests/test_chat_models.py @@ -5,7 +5,7 @@ from __future__ import annotations from typing import Any, Literal, Union from unittest.mock import MagicMock -from langchain_core.messages import AIMessageChunk +from langchain_core.messages import AIMessageChunk, ToolMessage from langchain_tests.unit_tests import ChatModelUnitTests from openai import BaseModel from openai.types.chat import ChatCompletionMessage @@ -217,3 +217,19 @@ class TestChatDeepSeekCustomUnit: msg = "Expected chunk_result not to be None" raise AssertionError(msg) assert chunk_result.message.additional_kwargs.get("reasoning_content") is None + + def test_get_request_payload(self) -> None: + """Test that tool message content is converted from list to string.""" + chat_model = ChatDeepSeek(model="deepseek-chat", api_key=SecretStr("api_key")) + + tool_message = ToolMessage(content=[], tool_call_id="test_id") + payload = chat_model._get_request_payload([tool_message]) + assert payload["messages"][0]["content"] == "[]" + + tool_message = ToolMessage(content=["item1", "item2"], tool_call_id="test_id") + payload = chat_model._get_request_payload([tool_message]) + assert payload["messages"][0]["content"] == '["item1", "item2"]' + + tool_message = ToolMessage(content="test string", tool_call_id="test_id") + payload = chat_model._get_request_payload([tool_message]) + assert payload["messages"][0]["content"] == "test string" diff --git a/libs/partners/perplexity/langchain_perplexity/chat_models.py b/libs/partners/perplexity/langchain_perplexity/chat_models.py index 95516724d8b..e97234218ac 100644 --- a/libs/partners/perplexity/langchain_perplexity/chat_models.py +++ b/libs/partners/perplexity/langchain_perplexity/chat_models.py @@ -100,7 +100,7 @@ class ChatPerplexity(BaseChatModel): Key init args - completion params: model: str - Name of the model to use. e.g. "llama-3.1-sonar-small-128k-online" + Name of the model to use. e.g. "sonar" temperature: float Sampling temperature to use. Default is 0.7 max_tokens: Optional[int] @@ -121,11 +121,9 @@ class ChatPerplexity(BaseChatModel): Instantiate: .. code-block:: python - from langchain_community.chat_models import ChatPerplexity + from langchain_perplexity import ChatPerplexity - llm = ChatPerplexity( - model="llama-3.1-sonar-small-128k-online", temperature=0.7 - ) + llm = ChatPerplexity(model="sonar", temperature=0.7) Invoke: .. code-block:: python @@ -173,7 +171,7 @@ class ChatPerplexity(BaseChatModel): """ # noqa: E501 client: Any = None #: :meta private: - model: str = "llama-3.1-sonar-small-128k-online" + model: str = "sonar" """Model name.""" temperature: float = 0.7 """What sampling temperature to use.""" @@ -354,7 +352,7 @@ class ChatPerplexity(BaseChatModel): additional_kwargs = {} if first_chunk: additional_kwargs["citations"] = chunk.get("citations", []) - for attr in ["images", "related_questions"]: + for attr in ["images", "related_questions", "search_results"]: if attr in chunk: additional_kwargs[attr] = chunk[attr] @@ -412,7 +410,7 @@ class ChatPerplexity(BaseChatModel): usage_response_metadata = {} additional_kwargs = {} - for attr in ["citations", "images", "related_questions"]: + for attr in ["citations", "images", "related_questions", "search_results"]: if hasattr(response, attr): additional_kwargs[attr] = getattr(response, attr) 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 a160c5f24c9..8cc85dddb0d 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,7 @@ -from typing import Any, Optional +from typing import Any, Optional, cast from unittest.mock import MagicMock -from langchain_core.messages import AIMessageChunk, BaseMessageChunk +from langchain_core.messages import AIMessageChunk, BaseMessage from pytest_mock import MockerFixture from langchain_perplexity import ChatPerplexity @@ -58,9 +58,9 @@ def test_perplexity_stream_includes_citations(mocker: MockerFixture) -> None: llm.client.chat.completions, "create", return_value=mock_stream ) stream = llm.stream("Hello langchain") - full: Optional[BaseMessageChunk] = None + full: Optional[BaseMessage] = None for i, chunk in enumerate(stream): - full = chunk if full is None else full + chunk + full = chunk if full is None else cast(BaseMessage, full + chunk) assert chunk.content == mock_chunks[i]["choices"][0]["delta"]["content"] if i == 0: assert chunk.additional_kwargs["citations"] == [ @@ -110,9 +110,9 @@ def test_perplexity_stream_includes_citations_and_images(mocker: MockerFixture) llm.client.chat.completions, "create", return_value=mock_stream ) stream = llm.stream("Hello langchain") - full: Optional[BaseMessageChunk] = None + full: Optional[BaseMessage] = None for i, chunk in enumerate(stream): - full = chunk if full is None else full + chunk + full = chunk if full is None else cast(BaseMessage, full + chunk) assert chunk.content == mock_chunks[i]["choices"][0]["delta"]["content"] if i == 0: assert chunk.additional_kwargs["citations"] == [ @@ -169,9 +169,9 @@ def test_perplexity_stream_includes_citations_and_related_questions( llm.client.chat.completions, "create", return_value=mock_stream ) stream = llm.stream("Hello langchain") - full: Optional[BaseMessageChunk] = None + full: Optional[BaseMessage] = None for i, chunk in enumerate(stream): - full = chunk if full is None else full + chunk + full = chunk if full is None else cast(BaseMessage, full + chunk) assert chunk.content == mock_chunks[i]["choices"][0]["delta"]["content"] if i == 0: assert chunk.additional_kwargs["citations"] == [ @@ -193,3 +193,61 @@ def test_perplexity_stream_includes_citations_and_related_questions( } patcher.assert_called_once() + + +def test_perplexity_stream_includes_citations_and_search_results( + mocker: MockerFixture, +) -> None: + """Test that the stream method exposes `search_results` via additional_kwargs.""" + llm = ChatPerplexity(model="test", timeout=30, verbose=True) + + mock_chunk_0 = { + "choices": [{"delta": {"content": "Hello "}, "finish_reason": None}], + "citations": ["example.com/a", "example.com/b"], + "search_results": [ + {"title": "Mock result", "url": "https://example.com/result", "date": None} + ], + } + mock_chunk_1 = { + "choices": [{"delta": {"content": "Perplexity"}, "finish_reason": None}], + "citations": ["example.com/a", "example.com/b"], + "search_results": [ + {"title": "Mock result", "url": "https://example.com/result", "date": None} + ], + } + mock_chunks: list[dict[str, Any]] = [mock_chunk_0, mock_chunk_1] + mock_stream = MagicMock() + mock_stream.__iter__.return_value = mock_chunks + patcher = mocker.patch.object( + llm.client.chat.completions, "create", return_value=mock_stream + ) + stream = llm.stream("Hello langchain") + full: Optional[BaseMessage] = None + for i, chunk in enumerate(stream): + full = chunk if full is None else cast(BaseMessage, full + chunk) + assert chunk.content == mock_chunks[i]["choices"][0]["delta"]["content"] + if i == 0: + assert chunk.additional_kwargs["citations"] == [ + "example.com/a", + "example.com/b", + ] + assert chunk.additional_kwargs["search_results"] == [ + { + "title": "Mock result", + "url": "https://example.com/result", + "date": None, + } + ] + else: + assert "citations" not in chunk.additional_kwargs + assert "search_results" not in chunk.additional_kwargs + assert isinstance(full, AIMessageChunk) + assert full.content == "Hello Perplexity" + assert full.additional_kwargs == { + "citations": ["example.com/a", "example.com/b"], + "search_results": [ + {"title": "Mock result", "url": "https://example.com/result", "date": None} + ], + } + + patcher.assert_called_once()