From 68414dd4074c4682e43d7a74a89fa9c2b0245f90 Mon Sep 17 00:00:00 2001 From: adonheis Date: Mon, 13 Jul 2026 14:00:34 -0400 Subject: [PATCH 1/4] fix(vanilla_python): add Chat Completions API fallback for non-Responses servers The vanilla_python agent exclusively used the OpenAI Responses API, which causes HTTP 400 errors when the upstream model server (e.g., vLLM) does not implement that endpoint. This adds automatic detection and fallback to the Chat Completions API on the first call, caching the result so subsequent calls skip the probe. No changes needed to the agentic loop since tool calling uses text-pattern matching (ReAct style) in both API modes. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/openai_responses_agent/agent.py | 72 +++++++++++++++++-- 1 file changed, 68 insertions(+), 4 deletions(-) diff --git a/agents/vanilla_python/templates/openai_responses_agent/src/openai_responses_agent/agent.py b/agents/vanilla_python/templates/openai_responses_agent/src/openai_responses_agent/agent.py index 21567f77..4c50ee92 100644 --- a/agents/vanilla_python/templates/openai_responses_agent/src/openai_responses_agent/agent.py +++ b/agents/vanilla_python/templates/openai_responses_agent/src/openai_responses_agent/agent.py @@ -130,7 +130,13 @@ def _messages_to_responses_input( def _get_output_text_from_response(response: Any) -> str: - """Extract assistant text from Responses API response (response.output[].content[]).""" + """Extract assistant text from a Responses API or Chat Completions API response.""" + # Chat Completions format: response.choices[0].message.content + choices = getattr(response, "choices", None) + if choices: + return getattr(choices[0].message, "content", None) or "" + + # Responses API format: response.output[].content[] if not getattr(response, "output", None) or not response.output: return "" for item in response.output: @@ -186,6 +192,7 @@ def __init__( self.temperature = temperature self.tools: dict[str, Callable] = {} self.messages: list[dict[str, Any]] = [] + self._use_responses_api: bool | None = None self.action_re = re.compile(r"Action:\s*(\w+)\s*\((.*?)\)") def add_message(self, role: str, content: str) -> None: @@ -206,6 +213,36 @@ def _parse_arguments(self, args_str: str) -> list[str]: args = next(reader, []) return [arg.strip().strip("'\"") for arg in args] + def _chat_completions_create( + self, + messages: list[dict[str, Any]] | None = None, + temperature: float | None = None, + model: str | None = None, + ) -> Any: + """ + Single Chat Completions API call via OpenAI client. + + Args: + messages: List of messages; if None, self.messages is used. + temperature: Override temperature for this call. + model: Override model for this call. + + Returns: + Response from client.chat.completions.create. + """ + msg_list = messages if messages is not None else self.messages + temp = temperature if temperature is not None else self.temperature + model_id = model if model is not None else self.model + + kwargs: dict[str, Any] = { + "model": model_id, + "messages": msg_list, + } + if temp != 0: + kwargs["temperature"] = temp + + return self.client.chat.completions.create(**kwargs) + def _responses_create( self, messages: list[dict[str, Any]] | None = None, @@ -213,7 +250,11 @@ def _responses_create( model: str | None = None, ) -> Any: """ - Single Responses API call via OpenAI client. + LLM API call with automatic fallback from Responses API to Chat Completions. + + On the first call, tries the Responses API. If the server returns HTTP 400 + or 404 (unsupported endpoint), falls back to Chat Completions and caches the + result so subsequent calls skip the probe. Args: messages: List of messages; if None, self.messages is used. @@ -221,8 +262,14 @@ def _responses_create( model: Override model for this call. Returns: - Response from client.responses.create (object with .output etc.). + Response from the Responses API or Chat Completions API. """ + # Fast path: already know which API to use + if self._use_responses_api is False: + return self._chat_completions_create( + messages=messages, temperature=temperature, model=model + ) + msg_list = messages if messages is not None else self.messages temp = temperature if temperature is not None else self.temperature model_id = model if model is not None else self.model @@ -236,7 +283,24 @@ def _responses_create( if temp != 0: kwargs["temperature"] = temp - return self.client.responses.create(**kwargs) + try: + response = self.client.responses.create(**kwargs) + if self._use_responses_api is None: + self._use_responses_api = True + logger.info("Using Responses API (supported by server)") + return response + except APIStatusError as exc: + if exc.status_code not in (400, 404) or self._use_responses_api is True: + raise + self._use_responses_api = False + logger.info( + "Responses API not supported (HTTP %d), falling back to " + "Chat Completions API", + exc.status_code, + ) + return self._chat_completions_create( + messages=messages, temperature=temperature, model=model + ) def _execute(self) -> str: """Execute a Responses API request.""" From d525731aefa385fc762a50e1dc5b9aa9a10a42f8 Mon Sep 17 00:00:00 2001 From: adonheis Date: Mon, 13 Jul 2026 17:49:23 -0400 Subject: [PATCH 2/4] fix(vanilla_python): always fall back on Responses API 400/404 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vLLM partially supports the Responses API — simple single-turn requests succeed, but multi-turn conversations fail with HTTP 400. The previous guard `self._use_responses_api is True` prevented fallback when the API had previously succeeded, causing the 400 to propagate as an error. Remove the guard so any 400/404 from the Responses API permanently switches to Chat Completions, regardless of prior success. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/openai_responses_agent/agent.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/agents/vanilla_python/templates/openai_responses_agent/src/openai_responses_agent/agent.py b/agents/vanilla_python/templates/openai_responses_agent/src/openai_responses_agent/agent.py index 4c50ee92..9bc9a7bd 100644 --- a/agents/vanilla_python/templates/openai_responses_agent/src/openai_responses_agent/agent.py +++ b/agents/vanilla_python/templates/openai_responses_agent/src/openai_responses_agent/agent.py @@ -290,11 +290,11 @@ def _responses_create( logger.info("Using Responses API (supported by server)") return response except APIStatusError as exc: - if exc.status_code not in (400, 404) or self._use_responses_api is True: + if exc.status_code not in (400, 404): raise self._use_responses_api = False logger.info( - "Responses API not supported (HTTP %d), falling back to " + "Responses API returned HTTP %d, falling back to " "Chat Completions API", exc.status_code, ) From 7a4cd2cdb739709fc4f45d659762f82d6c96130d Mon Sep 17 00:00:00 2001 From: adonheis Date: Mon, 13 Jul 2026 18:08:14 -0400 Subject: [PATCH 3/4] fix(vanilla_python): rename _responses_create, fix formatting, add fallback tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename _responses_create to _llm_create to reflect its dual-API role, fix ruff format issue, update stale _execute docstring, and add unit tests covering the Responses→Chat Completions fallback (400/404), cache behavior, and response parsing for both API formats. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/openai_responses_agent/agent.py | 9 +- .../tests/test_agent.py | 122 ++++++++++++++++++ 2 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 agents/vanilla_python/templates/openai_responses_agent/tests/test_agent.py diff --git a/agents/vanilla_python/templates/openai_responses_agent/src/openai_responses_agent/agent.py b/agents/vanilla_python/templates/openai_responses_agent/src/openai_responses_agent/agent.py index 9bc9a7bd..f173711f 100644 --- a/agents/vanilla_python/templates/openai_responses_agent/src/openai_responses_agent/agent.py +++ b/agents/vanilla_python/templates/openai_responses_agent/src/openai_responses_agent/agent.py @@ -243,7 +243,7 @@ def _chat_completions_create( return self.client.chat.completions.create(**kwargs) - def _responses_create( + def _llm_create( self, messages: list[dict[str, Any]] | None = None, temperature: float | None = None, @@ -294,8 +294,7 @@ def _responses_create( raise self._use_responses_api = False logger.info( - "Responses API returned HTTP %d, falling back to " - "Chat Completions API", + "Responses API returned HTTP %d, falling back to Chat Completions API", exc.status_code, ) return self._chat_completions_create( @@ -303,8 +302,8 @@ def _responses_create( ) def _execute(self) -> str: - """Execute a Responses API request.""" - response = self._responses_create( + """Execute an LLM API request (Responses API with Chat Completions fallback).""" + response = self._llm_create( messages=self.messages, temperature=self.temperature, model=self.model, diff --git a/agents/vanilla_python/templates/openai_responses_agent/tests/test_agent.py b/agents/vanilla_python/templates/openai_responses_agent/tests/test_agent.py new file mode 100644 index 00000000..adfda8a1 --- /dev/null +++ b/agents/vanilla_python/templates/openai_responses_agent/tests/test_agent.py @@ -0,0 +1,122 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest +from openai import APIStatusError +from openai_responses_agent.agent import AIAgent, _get_output_text_from_response + + +class TestGetOutputTextFromResponse: + def test_chat_completions_format(self): + response = SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace(content="hello"))] + ) + assert _get_output_text_from_response(response) == "hello" + + def test_chat_completions_none_content(self): + response = SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace(content=None))] + ) + assert _get_output_text_from_response(response) == "" + + def test_responses_api_format(self): + block = SimpleNamespace(type="output_text", text="world") + item = SimpleNamespace(content=[block]) + response = SimpleNamespace(output=[item]) + assert _get_output_text_from_response(response) == "world" + + def test_empty_response(self): + response = SimpleNamespace() + assert _get_output_text_from_response(response) == "" + + +class TestLLMCreateFallback: + @patch("openai_responses_agent.agent.OpenAI") + def _make_agent(self, mock_openai_cls): + mock_client = MagicMock() + mock_openai_cls.return_value = mock_client + agent = AIAgent(model="test-model", base_url="http://localhost:8000") + return agent, mock_client + + def test_responses_api_success_caches_result(self): + agent, mock_client = self._make_agent() + mock_response = SimpleNamespace( + output=[ + SimpleNamespace( + content=[SimpleNamespace(type="output_text", text="ok")] + ) + ] + ) + mock_client.responses.create.return_value = mock_response + + agent.messages = [{"role": "user", "content": "hi"}] + result = agent._llm_create(messages=agent.messages) + + assert result is mock_response + assert agent._use_responses_api is True + mock_client.responses.create.assert_called_once() + mock_client.chat.completions.create.assert_not_called() + + def test_fallback_on_400(self): + agent, mock_client = self._make_agent() + mock_response = MagicMock() + exc = APIStatusError( + message="Bad Request", + response=MagicMock(status_code=400, headers={}), + body=None, + ) + mock_client.responses.create.side_effect = exc + mock_client.chat.completions.create.return_value = mock_response + + agent.messages = [{"role": "user", "content": "hi"}] + result = agent._llm_create(messages=agent.messages) + + assert result is mock_response + assert agent._use_responses_api is False + mock_client.chat.completions.create.assert_called_once() + + def test_fallback_on_404(self): + agent, mock_client = self._make_agent() + mock_response = MagicMock() + exc = APIStatusError( + message="Not Found", + response=MagicMock(status_code=404, headers={}), + body=None, + ) + mock_client.responses.create.side_effect = exc + mock_client.chat.completions.create.return_value = mock_response + + agent.messages = [{"role": "user", "content": "hi"}] + result = agent._llm_create(messages=agent.messages) + + assert result is mock_response + assert agent._use_responses_api is False + + def test_non_fallback_error_propagates(self): + agent, mock_client = self._make_agent() + exc = APIStatusError( + message="Internal Server Error", + response=MagicMock(status_code=500, headers={}), + body=None, + ) + mock_client.responses.create.side_effect = exc + + agent.messages = [{"role": "user", "content": "hi"}] + with pytest.raises(APIStatusError): + agent._llm_create(messages=agent.messages) + + assert agent._use_responses_api is None + mock_client.chat.completions.create.assert_not_called() + + def test_cached_fallback_skips_probe(self): + agent, mock_client = self._make_agent() + mock_response = MagicMock() + mock_client.chat.completions.create.return_value = mock_response + + agent._use_responses_api = False + agent.messages = [{"role": "user", "content": "hi"}] + result = agent._llm_create(messages=agent.messages) + + assert result is mock_response + mock_client.responses.create.assert_not_called() + mock_client.chat.completions.create.assert_called_once() From d58ca66bc7831b6157e3cdde428f0724020f546c Mon Sep 17 00:00:00 2001 From: adonheis Date: Tue, 14 Jul 2026 11:48:04 -0400 Subject: [PATCH 4/4] fix(vanilla_python): address PR review comments for API fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix temperature=0 bug: `if temp != 0` → `if temp is not None` so temperature=0 is actually sent to the API (was silently dropped, causing non-deterministic responses) - Differentiate 400 vs 404 in fallback caching: only 404 permanently disables Responses API; 400 falls back per-call without caching (400 can indicate a legitimate request error, not just unsupported) - Add tests for multi-turn fallback (prior success → later failure) and temperature=0 being passed through Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/openai_responses_agent/agent.py | 27 ++++++---- .../tests/test_agent.py | 54 ++++++++++++++++++- 2 files changed, 70 insertions(+), 11 deletions(-) diff --git a/agents/vanilla_python/templates/openai_responses_agent/src/openai_responses_agent/agent.py b/agents/vanilla_python/templates/openai_responses_agent/src/openai_responses_agent/agent.py index f173711f..04739c3a 100644 --- a/agents/vanilla_python/templates/openai_responses_agent/src/openai_responses_agent/agent.py +++ b/agents/vanilla_python/templates/openai_responses_agent/src/openai_responses_agent/agent.py @@ -238,7 +238,7 @@ def _chat_completions_create( "model": model_id, "messages": msg_list, } - if temp != 0: + if temp is not None: kwargs["temperature"] = temp return self.client.chat.completions.create(**kwargs) @@ -252,9 +252,10 @@ def _llm_create( """ LLM API call with automatic fallback from Responses API to Chat Completions. - On the first call, tries the Responses API. If the server returns HTTP 400 - or 404 (unsupported endpoint), falls back to Chat Completions and caches the - result so subsequent calls skip the probe. + Tries the Responses API unless a prior 404 permanently disabled it. If the + server returns HTTP 404, permanently falls back to Chat Completions for all + subsequent calls. On HTTP 400, falls back for the current call only (400 can + also indicate a legitimate request error, not just an unsupported endpoint). Args: messages: List of messages; if None, self.messages is used. @@ -280,7 +281,7 @@ def _llm_create( "instructions": instructions, "input": input_items, } - if temp != 0: + if temp is not None: kwargs["temperature"] = temp try: @@ -292,11 +293,17 @@ def _llm_create( except APIStatusError as exc: if exc.status_code not in (400, 404): raise - self._use_responses_api = False - logger.info( - "Responses API returned HTTP %d, falling back to Chat Completions API", - exc.status_code, - ) + if exc.status_code == 404: + self._use_responses_api = False + logger.info( + "Responses API not found (HTTP 404), " + "permanently falling back to Chat Completions API", + ) + else: + logger.info( + "Responses API returned HTTP 400, " + "falling back to Chat Completions API for this call", + ) return self._chat_completions_create( messages=messages, temperature=temperature, model=model ) diff --git a/agents/vanilla_python/templates/openai_responses_agent/tests/test_agent.py b/agents/vanilla_python/templates/openai_responses_agent/tests/test_agent.py index adfda8a1..20323c3b 100644 --- a/agents/vanilla_python/templates/openai_responses_agent/tests/test_agent.py +++ b/agents/vanilla_python/templates/openai_responses_agent/tests/test_agent.py @@ -72,7 +72,7 @@ def test_fallback_on_400(self): result = agent._llm_create(messages=agent.messages) assert result is mock_response - assert agent._use_responses_api is False + assert agent._use_responses_api is None mock_client.chat.completions.create.assert_called_once() def test_fallback_on_404(self): @@ -120,3 +120,55 @@ def test_cached_fallback_skips_probe(self): assert result is mock_response mock_client.responses.create.assert_not_called() mock_client.chat.completions.create.assert_called_once() + + def test_temperature_zero_is_passed(self): + """temperature=0 must be included in API kwargs, not omitted.""" + agent, mock_client = self._make_agent() + mock_client.chat.completions.create.return_value = MagicMock() + + agent._use_responses_api = False + agent.messages = [{"role": "user", "content": "hi"}] + agent._llm_create(messages=agent.messages) + + call_kwargs = mock_client.chat.completions.create.call_args + assert call_kwargs[1]["temperature"] == 0 + + def test_fallback_on_400_after_prior_success(self): + """400 after a prior Responses API success falls back without caching.""" + agent, mock_client = self._make_agent() + mock_response = MagicMock() + exc = APIStatusError( + message="Bad Request", + response=MagicMock(status_code=400, headers={}), + body=None, + ) + mock_client.responses.create.side_effect = exc + mock_client.chat.completions.create.return_value = mock_response + + agent._use_responses_api = True + agent.messages = [{"role": "user", "content": "multi-turn message"}] + result = agent._llm_create(messages=agent.messages) + + assert result is mock_response + assert agent._use_responses_api is True + mock_client.chat.completions.create.assert_called_once() + + def test_fallback_on_404_after_prior_success(self): + """404 after a prior Responses API success permanently disables it.""" + agent, mock_client = self._make_agent() + mock_response = MagicMock() + exc = APIStatusError( + message="Not Found", + response=MagicMock(status_code=404, headers={}), + body=None, + ) + mock_client.responses.create.side_effect = exc + mock_client.chat.completions.create.return_value = mock_response + + agent._use_responses_api = True + agent.messages = [{"role": "user", "content": "multi-turn message"}] + result = agent._llm_create(messages=agent.messages) + + assert result is mock_response + assert agent._use_responses_api is False + mock_client.chat.completions.create.assert_called_once()