mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-06-17 05:16:14 +03:00
fix(llm): use the OpenAI Responses API only for native endpoints
The Responses API exists only on native OpenAI. When the openai provider is pointed at a custom base_url (a proxy, gateway, or local server that speaks only Chat Completions), keep the Responses API off so the call does not fail.
This commit is contained in:
43
tests/test_openai_responses_base_url.py
Normal file
43
tests/test_openai_responses_base_url.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""The Responses API only exists on native OpenAI; a custom base_url on the
|
||||||
|
openai provider must fall back to Chat Completions (#1024)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tradingagents.llm_clients.openai_client import (
|
||||||
|
OpenAIClient,
|
||||||
|
_is_native_openai_base_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class NativeBaseUrlTests:
|
||||||
|
def test_unset_is_native(self):
|
||||||
|
assert _is_native_openai_base_url(None) is True
|
||||||
|
assert _is_native_openai_base_url("") is True
|
||||||
|
|
||||||
|
def test_openai_hosts_are_native(self):
|
||||||
|
assert _is_native_openai_base_url("https://api.openai.com/v1") is True
|
||||||
|
assert _is_native_openai_base_url("api.openai.com/v1") is True
|
||||||
|
|
||||||
|
def test_custom_endpoints_are_not_native(self):
|
||||||
|
assert _is_native_openai_base_url("http://localhost:1234/v1") is False
|
||||||
|
assert _is_native_openai_base_url("https://my-gateway.example.com/v1") is False
|
||||||
|
assert _is_native_openai_base_url("https://api.openai.com.evil.com/v1") is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class ResponsesApiSelectionTests:
|
||||||
|
def test_native_openai_enables_responses_api(self, monkeypatch):
|
||||||
|
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
||||||
|
llm = OpenAIClient("gpt-5.5", provider="openai").get_llm()
|
||||||
|
assert getattr(llm, "use_responses_api", False) is True
|
||||||
|
|
||||||
|
def test_custom_base_url_disables_responses_api(self, monkeypatch):
|
||||||
|
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
||||||
|
llm = OpenAIClient(
|
||||||
|
"gpt-5.5", base_url="http://localhost:1234/v1", provider="openai"
|
||||||
|
).get_llm()
|
||||||
|
# use_responses_api should be absent/False so the client speaks Chat Completions.
|
||||||
|
assert getattr(llm, "use_responses_api", False) is False
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Optional
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from langchain_core.messages import AIMessage
|
from langchain_core.messages import AIMessage
|
||||||
from langchain_openai import ChatOpenAI
|
from langchain_openai import ChatOpenAI
|
||||||
@@ -84,7 +85,7 @@ class DeepSeekChatOpenAI(NormalizedChatOpenAI):
|
|||||||
def _get_request_payload(self, input_, *, stop=None, **kwargs):
|
def _get_request_payload(self, input_, *, stop=None, **kwargs):
|
||||||
payload = super()._get_request_payload(input_, stop=stop, **kwargs)
|
payload = super()._get_request_payload(input_, stop=stop, **kwargs)
|
||||||
outgoing = payload.get("messages", [])
|
outgoing = payload.get("messages", [])
|
||||||
for message_dict, message in zip(outgoing, _input_to_messages(input_)):
|
for message_dict, message in zip(outgoing, _input_to_messages(input_), strict=False):
|
||||||
if not isinstance(message, AIMessage):
|
if not isinstance(message, AIMessage):
|
||||||
continue
|
continue
|
||||||
reasoning = message.additional_kwargs.get("reasoning_content")
|
reasoning = message.additional_kwargs.get("reasoning_content")
|
||||||
@@ -102,7 +103,7 @@ class DeepSeekChatOpenAI(NormalizedChatOpenAI):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
for generation, choice in zip(
|
for generation, choice in zip(
|
||||||
chat_result.generations, response_dict.get("choices", [])
|
chat_result.generations, response_dict.get("choices", []), strict=False
|
||||||
):
|
):
|
||||||
reasoning = choice.get("message", {}).get("reasoning_content")
|
reasoning = choice.get("message", {}).get("reasoning_content")
|
||||||
if reasoning is not None:
|
if reasoning is not None:
|
||||||
@@ -167,8 +168,8 @@ class ProviderSpec:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
chat_class: type = NormalizedChatOpenAI # provider quirks live in the subclass
|
chat_class: type = NormalizedChatOpenAI # provider quirks live in the subclass
|
||||||
base_url: Optional[str] = None # default endpoint (None -> SDK default)
|
base_url: str | None = None # default endpoint (None -> SDK default)
|
||||||
base_url_env: Optional[str] = None # env var that overrides base_url (e.g. OLLAMA_BASE_URL)
|
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
|
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)
|
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)
|
require_base_url: bool = False # error if no base_url is resolved (generic endpoint)
|
||||||
@@ -205,6 +206,22 @@ def is_openai_compatible(provider: str) -> bool:
|
|||||||
return provider.lower() in OPENAI_COMPATIBLE_PROVIDERS
|
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):
|
class OpenAIClient(BaseLLMClient):
|
||||||
"""Client for OpenAI, Ollama, OpenRouter, and xAI providers.
|
"""Client for OpenAI, Ollama, OpenRouter, and xAI providers.
|
||||||
|
|
||||||
@@ -217,7 +234,7 @@ class OpenAIClient(BaseLLMClient):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
model: str,
|
model: str,
|
||||||
base_url: Optional[str] = None,
|
base_url: str | None = None,
|
||||||
provider: str = "openai",
|
provider: str = "openai",
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
@@ -264,7 +281,10 @@ class OpenAIClient(BaseLLMClient):
|
|||||||
f"(e.g. add {api_key_env}=your_key to your .env file)."
|
f"(e.g. add {api_key_env}=your_key to your .env file)."
|
||||||
)
|
)
|
||||||
|
|
||||||
if spec.use_responses_api:
|
# 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
|
llm_kwargs["use_responses_api"] = True
|
||||||
elif self.base_url:
|
elif self.base_url:
|
||||||
llm_kwargs["base_url"] = self.base_url
|
llm_kwargs["base_url"] = self.base_url
|
||||||
|
|||||||
Reference in New Issue
Block a user