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:
Yijia-Xiao
2026-05-01 19:23:23 +00:00
parent 2c97bad45c
commit 7e9e7b83c7
3 changed files with 262 additions and 17 deletions

View File

@@ -65,10 +65,12 @@ MODEL_OPTIONS: ProviderModeOptions = {
},
"deepseek": {
"quick": [
("DeepSeek V4 Flash - Latest V4 fast model", "deepseek-v4-flash"),
("DeepSeek V3.2", "deepseek-chat"),
("Custom model ID", "custom"),
],
"deep": [
("DeepSeek V4 Pro - Latest V4 flagship model", "deepseek-v4-pro"),
("DeepSeek V3.2 (thinking)", "deepseek-reasoner"),
("DeepSeek V3.2", "deepseek-chat"),
("Custom model ID", "custom"),

View File

@@ -1,6 +1,7 @@
import os
from typing import Any, Optional
from langchain_core.messages import AIMessage
from langchain_openai import ChatOpenAI
from .base_client import BaseLLMClient, normalize_content
@@ -11,29 +12,97 @@ class NormalizedChatOpenAI(ChatOpenAI):
"""ChatOpenAI with normalized content output.
The Responses API returns content as a list of typed blocks
(reasoning, text, etc.). This normalizes to string for consistent
downstream handling.
(reasoning, text, etc.). ``invoke`` normalizes to string for
consistent downstream handling. ``with_structured_output`` defaults
to function-calling so the Responses-API parse path is avoided
(langchain-openai's parse path emits noisy
PydanticSerializationUnexpectedValue warnings per call without
affecting correctness).
Provider-specific quirks (e.g. DeepSeek's thinking mode) live in
purpose-built subclasses below so this base class stays small.
"""
def invoke(self, input, config=None, **kwargs):
return normalize_content(super().invoke(input, config, **kwargs))
def with_structured_output(self, schema, *, method=None, **kwargs):
"""Wrap with structured output, defaulting to function_calling for OpenAI.
langchain-openai's Responses-API-parse path (the default for json_schema
when use_responses_api=True) calls response.model_dump(...) on the OpenAI
SDK's union-typed parsed response, which makes Pydantic emit ~20
PydanticSerializationUnexpectedValue warnings per call. The function-calling
path returns a plain tool-call shape that does not trigger that
serialization, so it is the cleaner choice for our combination of
use_responses_api=True + with_structured_output. Both paths use OpenAI's
strict mode and produce the same typed Pydantic instance.
"""
if method is None:
method = "function_calling"
return super().with_structured_output(schema, method=method, **kwargs)
def _input_to_messages(input_: Any) -> list:
"""Normalise a langchain LLM input to a list of message objects.
Accepts a list of messages, a ``ChatPromptValue`` (from a
ChatPromptTemplate), or anything else (treated as no messages).
Used by providers that need to walk the outgoing message history;
in particular DeepSeek thinking-mode propagation must work for
both bare-list invocations and ChatPromptTemplate-driven ones, so
treating only ``list`` here would silently skip half the call sites.
"""
if isinstance(input_, list):
return input_
if hasattr(input_, "to_messages"):
return input_.to_messages()
return []
class DeepSeekChatOpenAI(NormalizedChatOpenAI):
"""DeepSeek-specific overrides on top of the OpenAI-compatible client.
Two quirks that don't apply to other OpenAI-compatible providers:
1. **Thinking-mode round-trip.** When DeepSeek's thinking models return
a response with ``reasoning_content``, that field must be echoed
back as part of the assistant message on the next turn or the API
fails with HTTP 400. ``_create_chat_result`` captures the field on
receive and ``_get_request_payload`` re-attaches it on send.
2. **deepseek-reasoner has no tool_choice.** Structured output via
function-calling is unavailable, so we raise NotImplementedError
and let the agent factories fall back to free-text generation
(see ``tradingagents/agents/utils/structured.py``).
"""
def _get_request_payload(self, input_, *, stop=None, **kwargs):
payload = super()._get_request_payload(input_, stop=stop, **kwargs)
outgoing = payload.get("messages", [])
for message_dict, message in zip(outgoing, _input_to_messages(input_)):
if not isinstance(message, AIMessage):
continue
reasoning = message.additional_kwargs.get("reasoning_content")
if reasoning is not None:
message_dict["reasoning_content"] = reasoning
return payload
def _create_chat_result(self, response, generation_info=None):
chat_result = super()._create_chat_result(response, generation_info)
response_dict = (
response
if isinstance(response, dict)
else response.model_dump(
exclude={"choices": {"__all__": {"message": {"parsed"}}}}
)
)
for generation, choice in zip(
chat_result.generations, response_dict.get("choices", [])
):
reasoning = choice.get("message", {}).get("reasoning_content")
if reasoning is not None:
generation.message.additional_kwargs["reasoning_content"] = reasoning
return chat_result
def with_structured_output(self, schema, *, method=None, **kwargs):
if self.model_name == "deepseek-reasoner":
raise NotImplementedError(
"deepseek-reasoner does not support tool_choice; structured "
"output is unavailable. Agent factories fall back to "
"free-text generation automatically."
)
return super().with_structured_output(schema, method=method, **kwargs)
# Kwargs forwarded from user config to ChatOpenAI
_PASSTHROUGH_KWARGS = (
"timeout", "max_retries", "reasoning_effort",
@@ -75,10 +144,12 @@ class OpenAIClient(BaseLLMClient):
self.warn_if_unknown_model()
llm_kwargs = {"model": self.model}
# Provider-specific base URL and auth
# Provider-specific base URL and auth. An explicit base_url on the
# client (e.g. a corporate proxy) takes precedence over the
# provider default so users can route through their own gateway.
if self.provider in _PROVIDER_CONFIG:
base_url, api_key_env = _PROVIDER_CONFIG[self.provider]
llm_kwargs["base_url"] = base_url
default_base, api_key_env = _PROVIDER_CONFIG[self.provider]
llm_kwargs["base_url"] = self.base_url or default_base
if api_key_env:
api_key = os.environ.get(api_key_env)
if api_key:
@@ -98,7 +169,10 @@ class OpenAIClient(BaseLLMClient):
if self.provider == "openai":
llm_kwargs["use_responses_api"] = True
return NormalizedChatOpenAI(**llm_kwargs)
# DeepSeek's thinking-mode quirks live in their own subclass so the
# base NormalizedChatOpenAI stays free of provider-specific branches.
chat_cls = DeepSeekChatOpenAI if self.provider == "deepseek" else NormalizedChatOpenAI
return chat_cls(**llm_kwargs)
def validate_model(self) -> bool:
"""Validate model for the provider."""