Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -206,23 +213,64 @@ 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.
temperature: Override temperature for this call.
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
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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()