mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-09-19 11:15:24 +03:00
339 lines
16 KiB
Python
339 lines
16 KiB
Python
import os
|
|
import re
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
from urllib.parse import urlparse
|
|
|
|
from langchain_core.messages import AIMessage
|
|
from langchain_openai import ChatOpenAI
|
|
|
|
from .api_key_env import get_api_key_env
|
|
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 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`` 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):
|
|
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)
|
|
|
|
|
|
class LocalCompatibleChatOpenAI(NormalizedChatOpenAI):
|
|
"""OpenAI-compatible client for arbitrary local servers (LM Studio, vLLM,
|
|
llama.cpp via the generic ``openai_compatible`` provider).
|
|
|
|
Their tool-calling support varies, and many reject the object-form
|
|
``tool_choice`` langchain sends for function-calling structured output. Bind
|
|
the schema as a tool but don't force tool_choice, so structured output works
|
|
across local servers regardless of the model ID's capabilities (#1057).
|
|
"""
|
|
|
|
def with_structured_output(self, schema, *, method=None, **kwargs):
|
|
resolved = method or get_capabilities(self.model_name).preferred_structured_method
|
|
if resolved == "function_calling":
|
|
kwargs.setdefault("tool_choice", None)
|
|
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.
|
|
|
|
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.
|
|
|
|
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):
|
|
payload = super()._get_request_payload(input_, stop=stop, **kwargs)
|
|
outgoing = payload.get("messages", [])
|
|
for message_dict, message in zip(outgoing, _input_to_messages(input_), strict=False):
|
|
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", []), strict=False
|
|
):
|
|
reasoning = choice.get("message", {}).get("reasoning_content")
|
|
if reasoning is not None:
|
|
generation.message.additional_kwargs["reasoning_content"] = reasoning
|
|
return chat_result
|
|
|
|
|
|
class MinimaxChatOpenAI(NormalizedChatOpenAI):
|
|
"""MiniMax-specific overrides on top of the OpenAI-compatible client.
|
|
|
|
M2.x reasoning models embed ``<think>...</think>`` blocks directly in
|
|
``message.content`` by default, which would pollute saved reports.
|
|
Per platform.minimax.io/docs/api-reference/text-openai-api,
|
|
``reasoning_split=True`` redirects the thinking block into
|
|
``reasoning_details`` so ``content`` stays clean. It is sent via
|
|
``extra_body`` (not a top-level kwarg) because the openai SDK validates
|
|
top-level params and rejects unknown ones like reasoning_split (#826).
|
|
|
|
The flag is gated by ``ModelCapabilities.requires_reasoning_split`` so
|
|
only M2.x reasoning models receive it; non-reasoning MiniMax endpoints
|
|
(Coding Plan, MiniMax-Text-01) never see it.
|
|
|
|
Tool-choice handling for M2.x — those models accept only the string
|
|
enum ``{"none", "auto"}`` and reject langchain's function-spec dict —
|
|
is handled by the capability dispatch in
|
|
``NormalizedChatOpenAI.with_structured_output``, not here.
|
|
"""
|
|
|
|
def _get_request_payload(self, input_, *, stop=None, **kwargs):
|
|
payload = super()._get_request_payload(input_, stop=stop, **kwargs)
|
|
if get_capabilities(self.model_name).requires_reasoning_split:
|
|
# Pass via extra_body, not as a top-level kwarg: the openai SDK
|
|
# (>=1.56) validates top-level params against Completions.create
|
|
# and rejects unknown ones like reasoning_split (#826). extra_body
|
|
# is forwarded into the request body untouched.
|
|
extra_body = payload.setdefault("extra_body", {})
|
|
extra_body.setdefault("reasoning_split", True)
|
|
return payload
|
|
|
|
|
|
# Kwargs forwarded from user config to ChatOpenAI
|
|
_PASSTHROUGH_KWARGS = (
|
|
"timeout", "max_retries", "reasoning_effort", "temperature", "max_tokens",
|
|
"api_key", "callbacks", "http_client", "http_async_client",
|
|
)
|
|
|
|
# OpenAI's ``reasoning_effort`` is only accepted by reasoning models — GPT-5 and
|
|
# later, and the o-series. Non-reasoning models (gpt-4.1, gpt-4o, ...) 400 with
|
|
# "Unsupported parameter: 'reasoning.effort' is not supported with this model".
|
|
# Drop the kwarg for those rather than crash the run.
|
|
_OPENAI_REASONING_MODEL = re.compile(r"^(?:gpt-(?:[5-9]|[1-9]\d)|o[1-9])(?:[.-]|$)")
|
|
|
|
|
|
def _supports_reasoning_effort(model: str) -> bool:
|
|
"""Whether the (native OpenAI) model accepts ``reasoning_effort``."""
|
|
return bool(_OPENAI_REASONING_MODEL.match(model.lower().strip()))
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ProviderSpec:
|
|
"""Declarative config for one OpenAI-compatible provider.
|
|
|
|
The OpenAI-compatible family (OpenAI, xAI, DeepSeek, Qwen, GLM, MiniMax,
|
|
OpenRouter, Ollama, and any user endpoint) all speak the same Chat
|
|
Completions API and differ only by these fields — so one row here replaces
|
|
the former per-provider base-URL dict, auth handling, and client-class
|
|
branches. Native Anthropic / Google use their own clients (genuinely
|
|
different APIs) and are intentionally NOT in this registry.
|
|
|
|
The API-key env var stays in ``api_key_env.PROVIDER_API_KEY_ENV`` (the single
|
|
source consulted by both this client and the CLI prompt); only behavior that
|
|
is provider-specific (base URL, key optionality, wire-format quirks via
|
|
``chat_class``) lives here.
|
|
"""
|
|
|
|
chat_class: type = NormalizedChatOpenAI # provider quirks live in the subclass
|
|
base_url: str | None = None # default endpoint (None -> SDK default)
|
|
base_url_env: str | None = None # env var that overrides base_url (e.g. OLLAMA_BASE_URL)
|
|
key_optional: bool = False # don't require/prompt; send a placeholder if unset
|
|
placeholder_key: str = "EMPTY" # sent when no key is available (keyless local servers)
|
|
require_base_url: bool = False # error if no base_url is resolved (generic endpoint)
|
|
use_responses_api: bool = False # native OpenAI Responses API
|
|
|
|
|
|
# Single source of truth for the OpenAI-compatible provider family. Dual-region
|
|
# providers (qwen/glm/minimax) keep separate endpoints because international and
|
|
# China accounts cannot share credentials (#758).
|
|
OPENAI_COMPATIBLE_PROVIDERS: dict[str, ProviderSpec] = {
|
|
"openai": ProviderSpec(use_responses_api=True),
|
|
"xai": ProviderSpec(base_url="https://api.x.ai/v1"),
|
|
"deepseek": ProviderSpec(base_url="https://api.deepseek.com", chat_class=DeepSeekChatOpenAI),
|
|
"qwen": ProviderSpec(base_url="https://dashscope-intl.aliyuncs.com/compatible-mode/v1"),
|
|
"qwen-cn": ProviderSpec(base_url="https://dashscope.aliyuncs.com/compatible-mode/v1"),
|
|
"glm": ProviderSpec(base_url="https://api.z.ai/api/paas/v4/"),
|
|
"glm-cn": ProviderSpec(base_url="https://open.bigmodel.cn/api/paas/v4/"),
|
|
"minimax": ProviderSpec(base_url="https://api.minimax.io/v1", chat_class=MinimaxChatOpenAI),
|
|
"minimax-cn": ProviderSpec(base_url="https://api.minimaxi.com/v1", chat_class=MinimaxChatOpenAI),
|
|
"openrouter": ProviderSpec(base_url="https://openrouter.ai/api/v1"),
|
|
"mistral": ProviderSpec(base_url="https://api.mistral.ai/v1"),
|
|
"kimi": ProviderSpec(base_url="https://api.moonshot.ai/v1"),
|
|
"groq": ProviderSpec(base_url="https://api.groq.com/openai/v1"),
|
|
"nvidia": ProviderSpec(base_url="https://integrate.api.nvidia.com/v1"),
|
|
"ollama": ProviderSpec(base_url="http://localhost:11434/v1", base_url_env="OLLAMA_BASE_URL",
|
|
key_optional=True, placeholder_key="ollama",
|
|
chat_class=LocalCompatibleChatOpenAI),
|
|
# Generic endpoint: user supplies base_url; key optional (keyless local).
|
|
"openai_compatible": ProviderSpec(
|
|
require_base_url=True, key_optional=True, chat_class=LocalCompatibleChatOpenAI
|
|
),
|
|
}
|
|
|
|
|
|
def is_openai_compatible(provider: str) -> bool:
|
|
"""Whether ``provider`` is served by the OpenAI-compatible registry."""
|
|
return provider.lower() in OPENAI_COMPATIBLE_PROVIDERS
|
|
|
|
|
|
def _is_native_openai_base_url(base_url: str | None) -> bool:
|
|
"""True when ``base_url`` is unset or points at api.openai.com.
|
|
|
|
The Responses API (/v1/responses) only exists on native OpenAI. A custom
|
|
base_url on the ``openai`` provider (a proxy, gateway, or local server)
|
|
speaks only Chat Completions, so the Responses API must stay off there even
|
|
though the provider spec enables it (#1024).
|
|
"""
|
|
if not base_url:
|
|
return True
|
|
if "://" not in base_url:
|
|
base_url = "https://" + base_url
|
|
host = urlparse(base_url).hostname or ""
|
|
return host == "api.openai.com" or host.endswith(".openai.com")
|
|
|
|
|
|
class OpenAIClient(BaseLLMClient):
|
|
"""Client for OpenAI, Ollama, OpenRouter, and xAI providers.
|
|
|
|
For native OpenAI models, uses the Responses API (/v1/responses) which
|
|
supports reasoning_effort with function tools across all model families
|
|
(GPT-4.1, GPT-5). Third-party compatible providers (xAI, OpenRouter,
|
|
Ollama) use standard Chat Completions.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
model: str,
|
|
base_url: str | None = None,
|
|
provider: str = "openai",
|
|
**kwargs,
|
|
):
|
|
super().__init__(model, base_url, **kwargs)
|
|
self.provider = provider.lower()
|
|
|
|
def get_llm(self) -> Any:
|
|
"""Return a configured ChatOpenAI instance, driven by the provider registry."""
|
|
self.warn_if_unknown_model()
|
|
llm_kwargs = {"model": self.model}
|
|
spec = OPENAI_COMPATIBLE_PROVIDERS.get(self.provider)
|
|
chat_cls = NormalizedChatOpenAI
|
|
|
|
if spec is not None:
|
|
chat_cls = spec.chat_class
|
|
|
|
# base_url precedence: explicit client base_url (carries the config /
|
|
# TRADINGAGENTS_LLM_BACKEND_URL value) > provider env override (e.g.
|
|
# OLLAMA_BASE_URL) > provider default. None means use the SDK default.
|
|
env_base_url = os.environ.get(spec.base_url_env) if spec.base_url_env else None
|
|
base_url = self.base_url or env_base_url or spec.base_url
|
|
if spec.require_base_url and not base_url:
|
|
raise ValueError(
|
|
f"Provider '{self.provider}' requires a base_url. Set it via "
|
|
"backend_url / TRADINGAGENTS_LLM_BACKEND_URL to your endpoint, "
|
|
"e.g. http://localhost:8000/v1 (vLLM) or http://localhost:1234/v1 "
|
|
"(LM Studio)."
|
|
)
|
|
if base_url:
|
|
llm_kwargs["base_url"] = base_url
|
|
|
|
# API key: required unless key_optional; keyless local servers get a
|
|
# placeholder. The env-var name is the single source in api_key_env.
|
|
api_key_env = get_api_key_env(self.provider)
|
|
api_key = os.environ.get(api_key_env) if api_key_env else None
|
|
if api_key:
|
|
llm_kwargs["api_key"] = api_key
|
|
elif spec.key_optional:
|
|
llm_kwargs["api_key"] = spec.placeholder_key
|
|
elif api_key_env:
|
|
raise ValueError(
|
|
f"API key for provider '{self.provider}' is not set. "
|
|
f"Please set the {api_key_env} environment variable "
|
|
f"(e.g. add {api_key_env}=your_key to your .env file)."
|
|
)
|
|
|
|
# The Responses API only exists on native OpenAI; if the user points
|
|
# the openai provider at a custom base_url (proxy/gateway/local), it
|
|
# only speaks Chat Completions, so keep Responses off there (#1024).
|
|
if spec.use_responses_api and _is_native_openai_base_url(base_url):
|
|
llm_kwargs["use_responses_api"] = True
|
|
elif self.base_url:
|
|
llm_kwargs["base_url"] = self.base_url
|
|
|
|
# Forward user-provided kwargs
|
|
for key in _PASSTHROUGH_KWARGS:
|
|
if key not in self.kwargs:
|
|
continue
|
|
if key == "reasoning_effort" and not _supports_reasoning_effort(self.model):
|
|
continue
|
|
llm_kwargs[key] = self.kwargs[key]
|
|
|
|
# The subclass (provider quirks) comes from the registry spec.
|
|
return chat_cls(**llm_kwargs)
|
|
|
|
def validate_model(self) -> bool:
|
|
"""Validate model for the provider."""
|
|
return validate_model(self.provider, self.model)
|