mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-06-17 05:16:14 +03:00
fix(llm): structured output for DeepSeek V4 and reasoner
DeepSeek V4 and reasoner reject tool_choice but accept tools. Route via a per-model capability table that suppresses tool_choice for thinking-mode models. #678 #689
This commit is contained in:
95
tradingagents/llm_clients/capabilities.py
Normal file
95
tradingagents/llm_clients/capabilities.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Declarative per-model capability table for OpenAI-compatible providers.
|
||||
|
||||
This is the single place that knows which model IDs reject which API
|
||||
parameters or require which structured-output method. The LLM client
|
||||
subclasses consult ``get_capabilities(model_name)`` instead of hardcoding
|
||||
model-name ``if`` ladders, so adding a new model (or a new provider quirk)
|
||||
means editing this table — not the client code.
|
||||
|
||||
Pattern adapted from the per-model ``compat:`` flags DeepSeek themselves
|
||||
publish in their integration guides (e.g. the Oh My Pi config schema
|
||||
documents ``supportsToolChoice``, ``requiresReasoningContentForToolCalls``
|
||||
as declarative per-model fields).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
|
||||
StructuredMethod = Literal[
|
||||
"function_calling", # uses tools; respects supports_tool_choice
|
||||
"json_mode", # uses response_format={"type":"json_object"}
|
||||
"json_schema", # uses response_format={"type":"json_schema",...}
|
||||
"none", # no structured output available; caller falls back to free-text
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelCapabilities:
|
||||
"""What an OpenAI-compatible model accepts at the API level."""
|
||||
|
||||
supports_tool_choice: bool
|
||||
supports_json_mode: bool
|
||||
supports_json_schema: bool
|
||||
preferred_structured_method: StructuredMethod
|
||||
# DeepSeek thinking-mode models 400 if reasoning_content from prior
|
||||
# assistant turns is not echoed back on the next request.
|
||||
requires_reasoning_content_roundtrip: bool = False
|
||||
|
||||
|
||||
# DeepSeek's thinking models accept the ``tools`` array but reject the
|
||||
# ``tool_choice`` parameter (official Oh My Pi integration guide and the
|
||||
# 400 response in issue #678). Their official tool-calling examples
|
||||
# (api-docs.deepseek.com/guides/tool_calls) pass ``tools=[...]`` without
|
||||
# ``tool_choice`` — we mirror that pattern by setting supports_tool_choice
|
||||
# to False and letting the client suppress the kwarg.
|
||||
_DEEPSEEK_THINKING = ModelCapabilities(
|
||||
supports_tool_choice=False,
|
||||
supports_json_mode=True,
|
||||
supports_json_schema=False,
|
||||
preferred_structured_method="function_calling",
|
||||
requires_reasoning_content_roundtrip=True,
|
||||
)
|
||||
|
||||
_DEEPSEEK_CHAT = ModelCapabilities(
|
||||
supports_tool_choice=True,
|
||||
supports_json_mode=True,
|
||||
supports_json_schema=False,
|
||||
preferred_structured_method="function_calling",
|
||||
)
|
||||
|
||||
_DEFAULT = ModelCapabilities(
|
||||
supports_tool_choice=True,
|
||||
supports_json_mode=True,
|
||||
supports_json_schema=True,
|
||||
preferred_structured_method="function_calling",
|
||||
)
|
||||
|
||||
|
||||
# Exact-ID matches take precedence over pattern matches.
|
||||
_BY_ID: dict[str, ModelCapabilities] = {
|
||||
"deepseek-chat": _DEEPSEEK_CHAT,
|
||||
"deepseek-reasoner": _DEEPSEEK_THINKING,
|
||||
"deepseek-v4-flash": _DEEPSEEK_THINKING,
|
||||
"deepseek-v4-pro": _DEEPSEEK_THINKING,
|
||||
}
|
||||
|
||||
# Forward-compat patterns. A new ``deepseek-v5-*`` or ``deepseek-reasoner-*``
|
||||
# variant inherits the thinking-mode quirks automatically.
|
||||
_BY_PATTERN: list[tuple[re.Pattern[str], ModelCapabilities]] = [
|
||||
(re.compile(r"^deepseek-v\d"), _DEEPSEEK_THINKING),
|
||||
(re.compile(r"^deepseek-reasoner"), _DEEPSEEK_THINKING),
|
||||
]
|
||||
|
||||
|
||||
def get_capabilities(model_name: str) -> ModelCapabilities:
|
||||
"""Resolve capabilities by exact ID, then pattern, then default."""
|
||||
if model_name in _BY_ID:
|
||||
return _BY_ID[model_name]
|
||||
for pattern, caps in _BY_PATTERN:
|
||||
if pattern.match(model_name):
|
||||
return caps
|
||||
return _DEFAULT
|
||||
@@ -5,30 +5,45 @@ from langchain_core.messages import AIMessage
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
from .base_client import BaseLLMClient, normalize_content
|
||||
from .capabilities import get_capabilities
|
||||
from .validators import validate_model
|
||||
|
||||
|
||||
class NormalizedChatOpenAI(ChatOpenAI):
|
||||
"""ChatOpenAI with normalized content output.
|
||||
"""ChatOpenAI with normalized content output and capability-aware binding.
|
||||
|
||||
The Responses API returns content as a list of typed blocks
|
||||
(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).
|
||||
consistent downstream handling.
|
||||
|
||||
Provider-specific quirks (e.g. DeepSeek's thinking mode) live in
|
||||
purpose-built subclasses below so this base class stays small.
|
||||
``with_structured_output`` consults the per-model capability table
|
||||
(``capabilities.get_capabilities``) to pick the method and to decide
|
||||
whether ``tool_choice`` may be sent. Models that reject ``tool_choice``
|
||||
(e.g. DeepSeek V4 and reasoner — per their official tool-calling
|
||||
guide) still bind the schema as a tool, but no ``tool_choice``
|
||||
parameter is sent.
|
||||
|
||||
Provider-specific quirks beyond structured-output (e.g. DeepSeek's
|
||||
reasoning_content roundtrip) live in subclasses 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):
|
||||
if method is None:
|
||||
method = "function_calling"
|
||||
caps = get_capabilities(self.model_name)
|
||||
if caps.preferred_structured_method == "none":
|
||||
raise NotImplementedError(
|
||||
f"{self.model_name} has no structured-output method available; "
|
||||
f"agent factories will fall back to free-text generation."
|
||||
)
|
||||
method = method or caps.preferred_structured_method
|
||||
# When the model rejects tool_choice, suppress langchain's hardcoded
|
||||
# value. The schema is still bound as a tool — exactly what
|
||||
# DeepSeek's official tool-calling examples do.
|
||||
if method == "function_calling" and not caps.supports_tool_choice:
|
||||
kwargs.setdefault("tool_choice", None)
|
||||
return super().with_structured_output(schema, method=method, **kwargs)
|
||||
|
||||
|
||||
@@ -52,18 +67,16 @@ def _input_to_messages(input_: Any) -> list:
|
||||
class DeepSeekChatOpenAI(NormalizedChatOpenAI):
|
||||
"""DeepSeek-specific overrides on top of the OpenAI-compatible client.
|
||||
|
||||
Two quirks that don't apply to other OpenAI-compatible providers:
|
||||
Thinking-mode round-trip is the only DeepSeek-specific behavior that
|
||||
stays here. 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 it on receive and
|
||||
``_get_request_payload`` re-attaches it on send.
|
||||
|
||||
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``).
|
||||
Tool-choice handling for V4 and reasoner — those models reject the
|
||||
``tool_choice`` parameter — is handled by the capability dispatch in
|
||||
``NormalizedChatOpenAI.with_structured_output``, not here.
|
||||
"""
|
||||
|
||||
def _get_request_payload(self, input_, *, stop=None, **kwargs):
|
||||
@@ -94,15 +107,6 @@ class DeepSeekChatOpenAI(NormalizedChatOpenAI):
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user