diff --git a/lib/crewai/src/crewai/llms/providers/openai/completion.py b/lib/crewai/src/crewai/llms/providers/openai/completion.py index 78e8d23e8..2cd07e863 100644 --- a/lib/crewai/src/crewai/llms/providers/openai/completion.py +++ b/lib/crewai/src/crewai/llms/providers/openai/completion.py @@ -51,6 +51,13 @@ if TYPE_CHECKING: from crewai.tools.base_tool import BaseTool +# Models learned at runtime to be unavailable on /v1/chat/completions, keyed by +# the model string as configured. Populated from the 404 by +# `_remember_responses_only_model` so the wasted round trip is paid once per model +# per process rather than on every call. +_LEARNED_RESPONSES_ONLY_MODELS: set[str] = set() + + class WebSearchResult(TypedDict, total=False): """Result from web search built-in tool.""" @@ -453,7 +460,7 @@ class OpenAICompletion(BaseLLM): ): raise ValueError("LLM call blocked by before_llm_call hook") - if self.api == "responses": + if self._effective_api() == "responses": return self._call_responses( messages=formatted_messages, tools=tools, @@ -489,27 +496,52 @@ class OpenAICompletion(BaseLLM): from_agent: BaseAgent | None = None, response_model: type[BaseModel] | None = None, ) -> str | Any: - """Call OpenAI Chat Completions API.""" + """Call OpenAI Chat Completions API. + + Falls back to the Responses API when the model turns out not to be served + by chat completions, which OpenAI reports as a 404 distinct from a genuine + unknown model. + """ completion_params = self._prepare_completion_params( messages=messages, tools=tools ) - if self._effective_stream(): - return self._handle_streaming_completion( + try: + if self._effective_stream(): + return self._handle_streaming_completion( + params=completion_params, + available_functions=available_functions, + from_task=from_task, + from_agent=from_agent, + response_model=response_model, + ) + + return self._handle_completion( params=completion_params, available_functions=available_functions, from_task=from_task, from_agent=from_agent, response_model=response_model, ) - - return self._handle_completion( - params=completion_params, - available_functions=available_functions, - from_task=from_task, - from_agent=from_agent, - response_model=response_model, - ) + except Exception as e: + if self.custom_openai or not self._is_responses_only_error( + e.__cause__ or e + ): + raise + self._remember_responses_only_model() + logging.debug( + "Retrying %r on the Responses API: not served by " + '/v1/chat/completions. Set api="responses" to skip this.', + self.model, + ) + return self._call_responses( + messages=messages, + tools=tools, + available_functions=available_functions, + from_task=from_task, + from_agent=from_agent, + response_model=response_model, + ) async def acall( self, @@ -548,7 +580,7 @@ class OpenAICompletion(BaseLLM): formatted_messages = self._format_messages(messages) - if self.api == "responses": + if self._effective_api() == "responses": return await self._acall_responses( messages=formatted_messages, tools=tools, @@ -584,27 +616,46 @@ class OpenAICompletion(BaseLLM): from_agent: BaseAgent | None = None, response_model: type[BaseModel] | None = None, ) -> str | Any: - """Async call to OpenAI Chat Completions API.""" + """Async call to OpenAI Chat Completions API. + + Falls back to the Responses API for models not served by chat completions, + mirroring :meth:`_call_completions`. + """ completion_params = self._prepare_completion_params( messages=messages, tools=tools ) - if self._effective_stream(): - return await self._ahandle_streaming_completion( + try: + if self._effective_stream(): + return await self._ahandle_streaming_completion( + params=completion_params, + available_functions=available_functions, + from_task=from_task, + from_agent=from_agent, + response_model=response_model, + ) + + return await self._ahandle_completion( params=completion_params, available_functions=available_functions, from_task=from_task, from_agent=from_agent, response_model=response_model, ) - - return await self._ahandle_completion( - params=completion_params, - available_functions=available_functions, - from_task=from_task, - from_agent=from_agent, - response_model=response_model, - ) + except Exception as e: + if self.custom_openai or not self._is_responses_only_error( + e.__cause__ or e + ): + raise + self._remember_responses_only_model() + return await self._acall_responses( + messages=messages, + tools=tools, + available_functions=available_functions, + from_task=from_task, + from_agent=from_agent, + response_model=response_model, + ) def _call_responses( self, @@ -1557,6 +1608,65 @@ class OpenAICompletion(BaseLLM): """ return [item for item in response.output if item.type == "reasoning"] + @staticmethod + def _is_responses_only_error(error: BaseException) -> bool: + """Whether a 404 means the model exists but isn't on chat completions. + + OpenAI distinguishes the two 404s it returns here: + + responses-only param="model", "only supported in v1/responses" + or "This is not a chat model" + genuine typo code="model_not_found", "does not exist" + + Matching the former lets a newly Responses-only model be recovered without + needing to be listed in this file. + """ + if not isinstance(error, NotFoundError): + return False + body = getattr(error, "body", None) + inner = body.get("error") if isinstance(body, dict) else None + if isinstance(inner, dict) and inner.get("code") == "model_not_found": + return False + text = str(getattr(error, "message", "") or error).lower() + return "only supported in v1/responses" in text or "not a chat model" in text + + def _remember_responses_only_model(self) -> None: + """Record that this model must use the Responses API.""" + _LEARNED_RESPONSES_ONLY_MODELS.add(self.model) + + def _model_not_found_message(self, error: Exception) -> str: + """Build a 404 message that says what to do about it. + + OpenAI's own wording ("This is not a chat model") doesn't make the fix + obvious, so point at ``api="responses"`` when that's what it means. + """ + if self._is_responses_only_error(error): + return ( + f"Model {self.model} is not available on /v1/chat/completions. " + f'Use api="responses" (LLM(model="{self.model}", api="responses")) ' + f"to reach it: {error}" + ) + return f"Model {self.model} not found: {error}" + + def _effective_api(self) -> str: + """Which OpenAI API to actually use for this model. + + Honours an explicit ``api`` setting, but upgrades to ``"responses"`` for + models already known -- from a previous 404 in this process -- not to be + served by chat completions. + + Never overrides ``custom_openai=True``: an OpenAI-compatible server may + serve any model name on /v1/chat/completions and most don't implement + /v1/responses at all, so the user's choice stands. + """ + if ( + self.api == "completions" + and not self.custom_openai + and self.model in _LEARNED_RESPONSES_ONLY_MODELS + ): + return "responses" + return self.api + def _prepare_completion_params( self, messages: list[LLMMessage], tools: list[dict[str, BaseTool]] | None = None ) -> dict[str, Any]: @@ -1786,7 +1896,7 @@ class OpenAICompletion(BaseLLM): params["messages"], content, from_agent ) except NotFoundError as e: - error_msg = f"Model {self.model} not found: {e}" + error_msg = self._model_not_found_message(e) logging.error(error_msg) self._emit_call_failed_event( error=error_msg, from_task=from_task, from_agent=from_agent @@ -2209,7 +2319,7 @@ class OpenAICompletion(BaseLLM): if usage.get("total_tokens", 0) > 0: logging.info(f"OpenAI API usage: {usage}") except NotFoundError as e: - error_msg = f"Model {self.model} not found: {e}" + error_msg = self._model_not_found_message(e) logging.error(error_msg) self._emit_call_failed_event( error=error_msg, from_task=from_task, from_agent=from_agent diff --git a/lib/crewai/tests/llms/openai/test_responses_only_models.py b/lib/crewai/tests/llms/openai/test_responses_only_models.py new file mode 100644 index 000000000..b46d5d787 --- /dev/null +++ b/lib/crewai/tests/llms/openai/test_responses_only_models.py @@ -0,0 +1,209 @@ +"""Tests for models that /v1/chat/completions doesn't serve at all. + +The pro tier exists but is Responses-API-only. OpenAI reports it as a 404 that is +distinguishable from a genuine unknown model: + + responses-only param="model", "only supported in v1/responses" + or "This is not a chat model" + genuine typo code="model_not_found", "does not exist" + +Measured 2026-07 against the live endpoints: gpt-5-pro, gpt-5.2-pro, gpt-5.4-pro, +gpt-5.5-pro, o1-pro and o3-pro all 404 on chat completions and work on +/v1/responses. Rather than hardcoding that list, the 404 is caught and the call is +retried on the Responses API, then the model is remembered so the wasted round trip +is paid once per process. +""" + +import httpx +import pytest +from openai import NotFoundError + +from crewai.llms.providers.openai import completion as completion_module +from crewai.llms.providers.openai.completion import OpenAICompletion + + +MESSAGES = [{"role": "user", "content": "hi"}] + +RESPONSES_ONLY_MESSAGES = ( + "This model is only supported in v1/responses and not in /v1/chat/completions.", + "This is not a chat model and thus not supported in the v1/chat/completions " + "endpoint. Did you mean to use v1/completions?", +) + + +def build(model: str, **kwargs) -> OpenAICompletion: + return OpenAICompletion(model=model, api_key="sk-test", **kwargs) + + +def make_not_found(message: str, code: str | None = None) -> NotFoundError: + body = { + "error": { + "message": message, + "type": "invalid_request_error", + "param": None if code else "model", + "code": code, + } + } + response = httpx.Response( + status_code=404, + json=body, + request=httpx.Request("POST", "https://api.openai.com/v1/chat/completions"), + ) + return NotFoundError(message, response=response, body=body) + + +@pytest.fixture(autouse=True) +def _clear_learned_models(): + """Keep the process-wide learned set from leaking between tests.""" + completion_module._LEARNED_RESPONSES_ONLY_MODELS.clear() + yield + completion_module._LEARNED_RESPONSES_ONLY_MODELS.clear() + + +class TestErrorClassification: + @pytest.mark.parametrize("message", RESPONSES_ONLY_MESSAGES) + def test_detects_responses_only_404(self, message: str): + assert OpenAICompletion._is_responses_only_error(make_not_found(message)) + + def test_ignores_genuine_unknown_model(self): + """A real typo must keep failing, not get retried on another endpoint.""" + error = make_not_found( + "The model `gpt-5.99-fake` does not exist or you do not have access " + "to it.", + code="model_not_found", + ) + assert not OpenAICompletion._is_responses_only_error(error) + + def test_ignores_unrelated_exceptions(self): + assert not OpenAICompletion._is_responses_only_error(RuntimeError("boom")) + + +class TestFallback: + def test_retries_on_responses_and_remembers_the_model(self, monkeypatch): + llm = build("gpt-5-pro") + calls: list[str] = [] + + def fail_completion(**kwargs): + calls.append("completions") + raise ValueError("wrapped") from make_not_found( + RESPONSES_ONLY_MESSAGES[0] + ) + + monkeypatch.setattr(llm, "_handle_completion", fail_completion) + monkeypatch.setattr( + llm, "_call_responses", lambda **kwargs: calls.append("responses") or "ok" + ) + + assert llm._call_completions(MESSAGES) == "ok" + assert calls == ["completions", "responses"] + # Second call must skip the doomed chat-completions attempt. + assert llm._effective_api() == "responses" + + def test_does_not_retry_genuine_unknown_model(self, monkeypatch): + llm = build("gpt-5.99-fake") + calls: list[str] = [] + + def fail_completion(**kwargs): + calls.append("completions") + raise ValueError("wrapped") from make_not_found( + "The model does not exist.", code="model_not_found" + ) + + monkeypatch.setattr(llm, "_handle_completion", fail_completion) + monkeypatch.setattr( + llm, "_call_responses", lambda **kwargs: calls.append("responses") or "ok" + ) + + with pytest.raises(ValueError): + llm._call_completions(MESSAGES) + + assert calls == ["completions"] + assert not completion_module._LEARNED_RESPONSES_ONLY_MODELS + + def test_custom_endpoint_never_falls_back(self, monkeypatch): + """An OpenAI-compatible server may not implement /v1/responses at all. + + Most (vLLM, LiteLLM proxies, Ollama) don't, so a self-hosted model must + keep the endpoint the user chose rather than being silently rerouted. + """ + llm = build( + "o1-pro", custom_openai=True, base_url="https://my-vllm.internal/v1" + ) + calls: list[str] = [] + + def fail_completion(**kwargs): + calls.append("completions") + raise ValueError("wrapped") from make_not_found( + RESPONSES_ONLY_MESSAGES[0] + ) + + monkeypatch.setattr(llm, "_handle_completion", fail_completion) + monkeypatch.setattr( + llm, "_call_responses", lambda **kwargs: calls.append("responses") or "ok" + ) + + with pytest.raises(ValueError): + llm._call_completions(MESSAGES) + + assert calls == ["completions"] + + @pytest.mark.asyncio + async def test_async_path_falls_back_too(self, monkeypatch): + llm = build("gpt-5-pro") + calls: list[str] = [] + + async def fail_completion(**kwargs): + calls.append("completions") + raise ValueError("wrapped") from make_not_found( + RESPONSES_ONLY_MESSAGES[0] + ) + + async def ok_responses(**kwargs): + calls.append("responses") + return "ok" + + monkeypatch.setattr(llm, "_ahandle_completion", fail_completion) + monkeypatch.setattr(llm, "_acall_responses", ok_responses) + + assert await llm._acall_completions(MESSAGES) == "ok" + assert calls == ["completions", "responses"] + + +class TestEffectiveApi: + def test_defaults_to_completions_for_unknown_models(self): + """Nothing is assumed up front; the 404 is what teaches us.""" + assert build("gpt-5-pro")._effective_api() == "completions" + + def test_explicit_responses_is_honoured(self): + assert build("gpt-5.5", api="responses")._effective_api() == "responses" + + def test_learned_model_routes_directly(self): + llm = build("gpt-5-pro") + llm._remember_responses_only_model() + + assert llm._effective_api() == "responses" + + def test_learned_model_still_respects_custom_endpoint(self): + llm = build( + "gpt-5-pro", custom_openai=True, base_url="https://my-vllm.internal/v1" + ) + completion_module._LEARNED_RESPONSES_ONLY_MODELS.add("gpt-5-pro") + + assert llm._effective_api() == "completions" + + +class TestNotFoundMessage: + @pytest.mark.parametrize("message", RESPONSES_ONLY_MESSAGES) + def test_points_at_responses_api(self, message: str): + msg = build("gpt-5.5")._model_not_found_message(make_not_found(message)) + + assert 'api="responses"' in msg + assert "not available on /v1/chat/completions" in msg + + def test_keeps_plain_not_found_for_real_typos(self): + msg = build("gpt-5.5")._model_not_found_message( + make_not_found("The model does not exist.", code="model_not_found") + ) + + assert "not found" in msg + assert 'api="responses"' not in msg