mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-05-02 15:03:10 +03:00
feat: DeepSeek V4 thinking-mode round-trip via DeepSeekChatOpenAI subclass
Resolves #599: thinking-mode models require reasoning_content to be echoed back across turns; multi-turn agent runs failed with HTTP 400. The fix isolates DeepSeek's quirks (reasoning_content round-trip and the deepseek-reasoner no-tool_choice limitation) into a subclass so the general OpenAI-compatible client stays untouched. Adds DeepSeek V4 Pro/Flash to the catalog. 9 new tests; rationale documented in the class docstrings. Design adapted from #600; #611 closed in favour of this approach.
This commit is contained in:
169
tests/test_deepseek_reasoning.py
Normal file
169
tests/test_deepseek_reasoning.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""Tests for DeepSeekChatOpenAI thinking-mode behaviour.
|
||||
|
||||
Two pieces verified:
|
||||
|
||||
1. ``reasoning_content`` is captured on receive into the AIMessage's
|
||||
``additional_kwargs`` and re-attached on send so DeepSeek's API
|
||||
sees the same value across turns.
|
||||
2. ``with_structured_output`` raises NotImplementedError for
|
||||
``deepseek-reasoner`` so the agent factories' free-text fallback
|
||||
handles the request instead of failing at runtime.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
from langchain_core.prompt_values import ChatPromptValue
|
||||
|
||||
from tradingagents.llm_clients.openai_client import (
|
||||
DeepSeekChatOpenAI,
|
||||
NormalizedChatOpenAI,
|
||||
_input_to_messages,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _input_to_messages — the helper that handles list / ChatPromptValue / other
|
||||
# (Gemini bot review note: non-list inputs must also work)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestInputToMessages:
|
||||
def test_list_input_returned_as_is(self):
|
||||
msgs = [HumanMessage(content="hi")]
|
||||
assert _input_to_messages(msgs) is msgs
|
||||
|
||||
def test_chat_prompt_value_unwrapped(self):
|
||||
msgs = [HumanMessage(content="hi")]
|
||||
prompt_value = ChatPromptValue(messages=msgs)
|
||||
assert _input_to_messages(prompt_value) == msgs
|
||||
|
||||
def test_string_input_yields_empty_list(self):
|
||||
# A bare string isn't a message-bearing input; the caller's normal
|
||||
# langchain conversion happens upstream of _get_request_payload.
|
||||
assert _input_to_messages("hello") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reasoning content propagation across turns
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDeepSeekReasoningContent:
|
||||
def _client(self):
|
||||
os.environ.setdefault("DEEPSEEK_API_KEY", "placeholder")
|
||||
return DeepSeekChatOpenAI(
|
||||
model="deepseek-v4-flash",
|
||||
api_key="placeholder",
|
||||
base_url="https://api.deepseek.com",
|
||||
)
|
||||
|
||||
def test_capture_on_receive(self):
|
||||
"""When the response carries reasoning_content, it lands on the
|
||||
AIMessage's additional_kwargs so the next turn can echo it back."""
|
||||
client = self._client()
|
||||
result = client._create_chat_result(
|
||||
{
|
||||
"model": "deepseek-v4-flash",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "Plan: buy NVDA.",
|
||||
"reasoning_content": "Step 1: trend is up. Step 2: ...",
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
|
||||
}
|
||||
)
|
||||
ai = result.generations[0].message
|
||||
assert ai.additional_kwargs["reasoning_content"] == "Step 1: trend is up. Step 2: ..."
|
||||
|
||||
def test_propagate_on_send(self):
|
||||
"""When an outgoing AIMessage carries reasoning_content, the request
|
||||
payload echoes it on the corresponding message dict."""
|
||||
client = self._client()
|
||||
prior = AIMessage(
|
||||
content="Plan",
|
||||
additional_kwargs={"reasoning_content": "weighed bull case"},
|
||||
)
|
||||
new_user = HumanMessage(content="Refine.")
|
||||
payload = client._get_request_payload([prior, new_user])
|
||||
# Find the assistant message in the payload
|
||||
assistant_dicts = [m for m in payload["messages"] if m.get("role") == "assistant"]
|
||||
assert assistant_dicts, "assistant message missing from outgoing payload"
|
||||
assert assistant_dicts[0]["reasoning_content"] == "weighed bull case"
|
||||
|
||||
def test_propagate_through_chat_prompt_value(self):
|
||||
"""Gemini bot review note: non-list inputs (ChatPromptValue) must
|
||||
also propagate reasoning_content."""
|
||||
client = self._client()
|
||||
prior = AIMessage(
|
||||
content="Plan",
|
||||
additional_kwargs={"reasoning_content": "weighed bull case"},
|
||||
)
|
||||
prompt_value = ChatPromptValue(messages=[prior, HumanMessage(content="Refine.")])
|
||||
payload = client._get_request_payload(prompt_value)
|
||||
assistant_dicts = [m for m in payload["messages"] if m.get("role") == "assistant"]
|
||||
assert assistant_dicts[0]["reasoning_content"] == "weighed bull case"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# deepseek-reasoner: structured output unavailable, falls through to free-text
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDeepSeekReasonerStructuredOutput:
|
||||
def test_with_structured_output_raises_for_reasoner(self):
|
||||
client = DeepSeekChatOpenAI(
|
||||
model="deepseek-reasoner",
|
||||
api_key="placeholder",
|
||||
base_url="https://api.deepseek.com",
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
|
||||
class _Sample(BaseModel):
|
||||
answer: str
|
||||
|
||||
with pytest.raises(NotImplementedError):
|
||||
client.with_structured_output(_Sample)
|
||||
|
||||
def test_with_structured_output_works_for_v4(self):
|
||||
"""V4 models (non-reasoner) accept tool_choice; structured output works."""
|
||||
client = DeepSeekChatOpenAI(
|
||||
model="deepseek-v4-flash",
|
||||
api_key="placeholder",
|
||||
base_url="https://api.deepseek.com",
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
|
||||
class _Sample(BaseModel):
|
||||
answer: str
|
||||
|
||||
# Should return a Runnable, not raise. (The actual API call would
|
||||
# require a real key; we only assert binding succeeds.)
|
||||
wrapped = client.with_structured_output(_Sample)
|
||||
assert wrapped is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Base class isolation: NormalizedChatOpenAI does NOT have DeepSeek behaviour
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestBaseClassIsolation:
|
||||
def test_normalized_does_not_propagate_reasoning_content(self):
|
||||
"""The general-purpose NormalizedChatOpenAI must not carry
|
||||
DeepSeek-specific behaviour. Only the subclass does."""
|
||||
assert not hasattr(NormalizedChatOpenAI, "_get_request_payload") or (
|
||||
NormalizedChatOpenAI._get_request_payload
|
||||
is NormalizedChatOpenAI.__bases__[0]._get_request_payload
|
||||
)
|
||||
Reference in New Issue
Block a user