mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-26 09:15:08 +00:00
fix(openai): route responses-only models instead of failing with 404 (#6656)
Some checks are pending
Build uv cache / build-cache (3.10) (push) Waiting to run
Build uv cache / build-cache (3.11) (push) Waiting to run
Build uv cache / build-cache (3.12) (push) Waiting to run
Build uv cache / build-cache (3.13) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Vulnerability Scan / pip-audit (push) Waiting to run
Some checks are pending
Build uv cache / build-cache (3.10) (push) Waiting to run
Build uv cache / build-cache (3.11) (push) Waiting to run
Build uv cache / build-cache (3.12) (push) Waiting to run
Build uv cache / build-cache (3.13) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Vulnerability Scan / pip-audit (push) Waiting to run
The pro tier is not served by /v1/chat/completions. Probing the live endpoints:
model /v1/chat/completions /v1/responses
gpt-5-pro 404 OK
gpt-5.5-pro 404 OK
gpt-5.4-pro 404 OK
gpt-5.2-pro 404 OK
o1-pro 404 OK
o3-pro 404 OK
Since api defaults to "completions", LLM(model="openai/gpt-5-pro") fails with
"Model ... not found", which is misleading -- the model exists, the endpoint is
wrong. OpenAI's own 404 text ("This is not a chat model") doesn't make the fix
obvious either.
These requests now route to the Responses API automatically, which is verified to
work for every model above. An explicit api= setting is always honoured.
Model matching normalizes the configured string first, so "openai/gpt-5-pro" and
"gpt-5-pro-2025-10-06" both resolve to "gpt-5-pro". It's an exact list rather
than a "-pro" substring, so a custom deployment named "gpt-4-pro-custom" isn't
swept up.
The chat-completions 404 handler also gained an actionable message: when the
response says responses-only, or the model is a known pro model, the error names
api="responses" instead of just reporting "not found".
Tests: 29 cases covering name normalization, detection, routing (including that
call() reaches the Responses handler), and both 404 message paths.
This commit is contained in:
@@ -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
|
||||
|
||||
209
lib/crewai/tests/llms/openai/test_responses_only_models.py
Normal file
209
lib/crewai/tests/llms/openai/test_responses_only_models.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user