From b5f49df86a64a7cc80ed68ca44b3e7b07621d4d0 Mon Sep 17 00:00:00 2001 From: Andras L Ferenczi Date: Fri, 21 Mar 2025 09:35:37 -0700 Subject: [PATCH] partner: ChatDeepSeek on openrouter not returning reasoning (#30240) Deepseek model does not return reasoning when hosted on openrouter (Issue [30067](https://github.com/langchain-ai/langchain/issues/30067)) the following code did not return reasoning: ```python llm = ChatDeepSeek( model = 'deepseek/deepseek-r1:nitro', api_base="https://openrouter.ai/api/v1", api_key=os.getenv("OPENROUTER_API_KEY")) messages = [ {"role": "system", "content": "You are an assistant."}, {"role": "user", "content": "9.11 and 9.8, which is greater? Explain the reasoning behind this decision."} ] response = llm.invoke(messages, extra_body={"include_reasoning": True}) print(response.content) print(f"REASONING: {response.additional_kwargs.get('reasoning_content', '')}") print(response) ``` The fix is to extract reasoning from response.choices[0].message["model_extra"] and from choices[0].delta["reasoning"]. and place in response additional_kwargs. Change is really just the addition of a couple one-sentence if statements. --------- Co-authored-by: andrasfe Co-authored-by: Chester Curme --- .../langchain_deepseek/chat_models.py | 19 +- .../integration_tests/test_chat_models.py | 4 +- .../tests/unit_tests/test_chat_models.py | 169 +++++++++++++++++- 3 files changed, 187 insertions(+), 5 deletions(-) diff --git a/libs/partners/deepseek/langchain_deepseek/chat_models.py b/libs/partners/deepseek/langchain_deepseek/chat_models.py index 4714c6454bf..be52b82285d 100644 --- a/libs/partners/deepseek/langchain_deepseek/chat_models.py +++ b/libs/partners/deepseek/langchain_deepseek/chat_models.py @@ -228,6 +228,15 @@ class ChatDeepSeek(BaseChatOpenAI): rtn.generations[0].message.additional_kwargs["reasoning_content"] = ( response.choices[0].message.reasoning_content # type: ignore ) + # Handle use via OpenRouter + elif hasattr(response.choices[0].message, "model_extra"): # type: ignore + model_extra = response.choices[0].message.model_extra # type: ignore + if isinstance(model_extra, dict) and ( + reasoning := model_extra.get("reasoning") + ): + rtn.generations[0].message.additional_kwargs["reasoning_content"] = ( + reasoning + ) return rtn @@ -244,11 +253,17 @@ class ChatDeepSeek(BaseChatOpenAI): ) if (choices := chunk.get("choices")) and generation_chunk: top = choices[0] - if reasoning_content := top.get("delta", {}).get("reasoning_content"): - if isinstance(generation_chunk.message, AIMessageChunk): + if isinstance(generation_chunk.message, AIMessageChunk): + if reasoning_content := top.get("delta", {}).get("reasoning_content"): generation_chunk.message.additional_kwargs["reasoning_content"] = ( reasoning_content ) + # Handle use via OpenRouter + elif reasoning := top.get("delta", {}).get("reasoning"): + generation_chunk.message.additional_kwargs["reasoning_content"] = ( + reasoning + ) + return generation_chunk def _stream( diff --git a/libs/partners/deepseek/tests/integration_tests/test_chat_models.py b/libs/partners/deepseek/tests/integration_tests/test_chat_models.py index 3ead8786e8b..43dc2de4047 100644 --- a/libs/partners/deepseek/tests/integration_tests/test_chat_models.py +++ b/libs/partners/deepseek/tests/integration_tests/test_chat_models.py @@ -40,7 +40,7 @@ class TestChatDeepSeek(ChatModelIntegrationTests): def test_reasoning_content() -> None: """Test reasoning content.""" chat_model = ChatDeepSeek(model="deepseek-reasoner") - response = chat_model.invoke("What is the square root of 256256?") + response = chat_model.invoke("What is 3^3?") assert response.content assert response.additional_kwargs["reasoning_content"] raise ValueError() @@ -50,7 +50,7 @@ def test_reasoning_content() -> None: def test_reasoning_content_streaming() -> None: chat_model = ChatDeepSeek(model="deepseek-reasoner") full: Optional[BaseMessageChunk] = None - for chunk in chat_model.stream("What is the square root of 256256?"): + for chunk in chat_model.stream("What is 3^3?"): full = chunk if full is None else full + chunk assert isinstance(full, AIMessageChunk) assert full.additional_kwargs["reasoning_content"] 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 9eeff17bbae..94135a14a1f 100644 --- a/libs/partners/deepseek/tests/unit_tests/test_chat_models.py +++ b/libs/partners/deepseek/tests/unit_tests/test_chat_models.py @@ -1,12 +1,60 @@ """Test chat model integration.""" -from typing import Type +from typing import Any, Dict, Literal, Type, Union +from unittest.mock import MagicMock +from langchain_core.messages import AIMessageChunk from langchain_tests.unit_tests import ChatModelUnitTests +from openai import BaseModel +from openai.types.chat import ChatCompletionMessage +from pydantic import SecretStr from langchain_deepseek.chat_models import ChatDeepSeek +class MockOpenAIResponse(BaseModel): + choices: list + error: None = None + + def model_dump( + self, + *, + mode: Union[Literal["json", "python"], str] = "python", + include: Any = None, + exclude: Any = None, + by_alias: bool = False, + exclude_unset: bool = False, + exclude_defaults: bool = False, + exclude_none: bool = False, + round_trip: bool = False, + warnings: Union[Literal["none", "warn", "error"], bool] = True, + context: Union[Dict[str, Any], None] = None, + serialize_as_any: bool = False, + ) -> Dict[str, Any]: + choices_list = [] + for choice in self.choices: + if isinstance(choice.message, ChatCompletionMessage): + message_dict = choice.message.model_dump() + # Ensure model_extra fields are at top level + if "model_extra" in message_dict: + message_dict.update(message_dict["model_extra"]) + else: + message_dict = { + "role": "assistant", + "content": choice.message.content, + } + # Add reasoning_content if present + if hasattr(choice.message, "reasoning_content"): + message_dict["reasoning_content"] = choice.message.reasoning_content + # Add model_extra fields at the top level if present + if hasattr(choice.message, "model_extra"): + message_dict.update(choice.message.model_extra) + message_dict["model_extra"] = choice.message.model_extra + choices_list.append({"message": message_dict}) + + return {"choices": choices_list, "error": self.error} + + class TestChatDeepSeekUnit(ChatModelUnitTests): @property def chat_model_class(self) -> Type[ChatDeepSeek]: @@ -35,3 +83,122 @@ class TestChatDeepSeekUnit(ChatModelUnitTests): "model": "deepseek-chat", "api_key": "api_key", } + + def get_chat_model(self) -> ChatDeepSeek: + """Get a chat model instance for testing.""" + return ChatDeepSeek(**self.chat_model_params) + + +class TestChatDeepSeekCustomUnit: + """Custom tests specific to DeepSeek chat model.""" + + def test_create_chat_result_with_reasoning_content(self) -> None: + """Test that reasoning_content is properly extracted from response.""" + chat_model = ChatDeepSeek(model="deepseek-chat", api_key=SecretStr("api_key")) + mock_message = MagicMock() + mock_message.content = "Main content" + mock_message.reasoning_content = "This is the reasoning content" + mock_message.role = "assistant" + mock_response = MockOpenAIResponse( + choices=[MagicMock(message=mock_message)], error=None + ) + + result = chat_model._create_chat_result(mock_response) + assert ( + result.generations[0].message.additional_kwargs.get("reasoning_content") + == "This is the reasoning content" + ) + + def test_create_chat_result_with_model_extra_reasoning(self) -> None: + """Test that reasoning is properly extracted from model_extra.""" + chat_model = ChatDeepSeek(model="deepseek-chat", api_key=SecretStr("api_key")) + mock_message = MagicMock(spec=ChatCompletionMessage) + mock_message.content = "Main content" + mock_message.role = "assistant" + mock_message.model_extra = {"reasoning": "This is the reasoning"} + mock_message.model_dump.return_value = { + "role": "assistant", + "content": "Main content", + "model_extra": {"reasoning": "This is the reasoning"}, + } + mock_choice = MagicMock() + mock_choice.message = mock_message + mock_response = MockOpenAIResponse(choices=[mock_choice], error=None) + + result = chat_model._create_chat_result(mock_response) + assert ( + result.generations[0].message.additional_kwargs.get("reasoning_content") + == "This is the reasoning" + ) + + def test_convert_chunk_with_reasoning_content(self) -> None: + """Test that reasoning_content is properly extracted from streaming chunk.""" + chat_model = ChatDeepSeek(model="deepseek-chat", api_key=SecretStr("api_key")) + chunk: Dict[str, Any] = { + "choices": [ + { + "delta": { + "content": "Main content", + "reasoning_content": "Streaming reasoning content", + } + } + ] + } + + chunk_result = chat_model._convert_chunk_to_generation_chunk( + chunk, AIMessageChunk, None + ) + if chunk_result is None: + raise AssertionError("Expected chunk_result not to be None") + assert ( + chunk_result.message.additional_kwargs.get("reasoning_content") + == "Streaming reasoning content" + ) + + def test_convert_chunk_with_reasoning(self) -> None: + """Test that reasoning is properly extracted from streaming chunk.""" + chat_model = ChatDeepSeek(model="deepseek-chat", api_key=SecretStr("api_key")) + chunk: Dict[str, Any] = { + "choices": [ + { + "delta": { + "content": "Main content", + "reasoning": "Streaming reasoning", + } + } + ] + } + + chunk_result = chat_model._convert_chunk_to_generation_chunk( + chunk, AIMessageChunk, None + ) + if chunk_result is None: + raise AssertionError("Expected chunk_result not to be None") + assert ( + chunk_result.message.additional_kwargs.get("reasoning_content") + == "Streaming reasoning" + ) + + def test_convert_chunk_without_reasoning(self) -> None: + """Test that chunk without reasoning fields works correctly.""" + chat_model = ChatDeepSeek(model="deepseek-chat", api_key=SecretStr("api_key")) + chunk: Dict[str, Any] = {"choices": [{"delta": {"content": "Main content"}}]} + + chunk_result = chat_model._convert_chunk_to_generation_chunk( + chunk, AIMessageChunk, None + ) + if chunk_result is None: + raise AssertionError("Expected chunk_result not to be None") + assert chunk_result.message.additional_kwargs.get("reasoning_content") is None + + def test_convert_chunk_with_empty_delta(self) -> None: + """Test that chunk with empty delta works correctly.""" + chat_model = ChatDeepSeek(model="deepseek-chat", api_key=SecretStr("api_key")) + chunk: Dict[str, Any] = {"choices": [{"delta": {}}]} + + chunk_result = chat_model._convert_chunk_to_generation_chunk( + chunk, AIMessageChunk, None + ) + if chunk_result is None: + raise AssertionError("Expected chunk_result not to be None") + assert chunk_result.message.additional_kwargs.get("reasoning_content") is None