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..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 @@ -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,14 +213,49 @@ def _parse_arguments(self, args_str: str) -> list[str]: args = next(reader, []) return [arg.strip().strip("'\"") for arg in args] - def _responses_create( + 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 is not None: + kwargs["temperature"] = temp + + return self.client.chat.completions.create(**kwargs) + + def _llm_create( self, messages: list[dict[str, Any]] | None = None, temperature: float | None = None, model: str | None = None, ) -> Any: """ - Single Responses API call via OpenAI client. + LLM API call with automatic fallback from Responses API to Chat Completions. + + 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. @@ -221,8 +263,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 @@ -233,14 +281,36 @@ def _responses_create( "instructions": instructions, "input": input_items, } - if temp != 0: + if temp is not None: 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): + raise + 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 + ) 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..20323c3b --- /dev/null +++ b/agents/vanilla_python/templates/openai_responses_agent/tests/test_agent.py @@ -0,0 +1,174 @@ +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 None + 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() + + 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()