mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-07-29 09:54:23 +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:
@@ -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"),
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user