From 3cddf1e331dbbd5d993ace62edbb58cb3dd65713 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Sun, 14 Jun 2026 07:23:19 +0000 Subject: [PATCH] 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. --- tests/test_openai_responses_base_url.py | 43 ++++++++++++++++++++++ tradingagents/llm_clients/openai_client.py | 34 +++++++++++++---- 2 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 tests/test_openai_responses_base_url.py diff --git a/tests/test_openai_responses_base_url.py b/tests/test_openai_responses_base_url.py new file mode 100644 index 000000000..f527afd16 --- /dev/null +++ b/tests/test_openai_responses_base_url.py @@ -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 diff --git a/tradingagents/llm_clients/openai_client.py b/tradingagents/llm_clients/openai_client.py index 8aaa988df..c818a9101 100644 --- a/tradingagents/llm_clients/openai_client.py +++ b/tradingagents/llm_clients/openai_client.py @@ -1,6 +1,7 @@ import os 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_openai import ChatOpenAI @@ -84,7 +85,7 @@ class DeepSeekChatOpenAI(NormalizedChatOpenAI): 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_)): + 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") @@ -102,7 +103,7 @@ class DeepSeekChatOpenAI(NormalizedChatOpenAI): ) ) 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") if reasoning is not None: @@ -167,8 +168,8 @@ class ProviderSpec: """ chat_class: type = NormalizedChatOpenAI # provider quirks live in the subclass - base_url: Optional[str] = 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: 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) @@ -205,6 +206,22 @@ def is_openai_compatible(provider: str) -> bool: 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. @@ -217,7 +234,7 @@ class OpenAIClient(BaseLLMClient): def __init__( self, model: str, - base_url: Optional[str] = None, + base_url: str | None = None, provider: str = "openai", **kwargs, ): @@ -264,7 +281,10 @@ class OpenAIClient(BaseLLMClient): 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 elif self.base_url: llm_kwargs["base_url"] = self.base_url