diff --git a/lib/crewai/src/crewai/llm.py b/lib/crewai/src/crewai/llm.py
index 100b25b74..4b2039a3e 100644
--- a/lib/crewai/src/crewai/llm.py
+++ b/lib/crewai/src/crewai/llm.py
@@ -387,7 +387,9 @@ class LLM(BaseLLM):
api_base: str | None = None
api_version: str | None = None
callbacks: list[Any] | None = None
- reasoning_effort: Literal["none", "low", "medium", "high"] | None = None
+ reasoning_effort: (
+ Literal["none", "minimal", "low", "medium", "high", "xhigh"] | None
+ ) = None
stream: bool = False
interceptor: Any = None
thinking: Any = None
diff --git a/lib/crewai/src/crewai/llms/providers/openai/completion.py b/lib/crewai/src/crewai/llms/providers/openai/completion.py
index 75e7fb7a7..88164ba64 100644
--- a/lib/crewai/src/crewai/llms/providers/openai/completion.py
+++ b/lib/crewai/src/crewai/llms/providers/openai/completion.py
@@ -5,7 +5,9 @@ from dataclasses import dataclass, field
import json
import logging
import os
+import re
from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal, TypedDict
+from urllib.parse import urlsplit
import httpx
from openai import (
@@ -78,6 +80,12 @@ if TYPE_CHECKING:
# per process rather than on every call.
_LEARNED_RESPONSES_ONLY_MODELS: set[str] = set()
+# (endpoint, model) pairs a 400 has shown to reject `reasoning_effort` in this
+# process, so the rejected call is paid once rather than on every request. Keyed
+# by endpoint as well as name because the same name on a gateway and on OpenAI
+# itself are different models, and one must not silence the other.
+_LEARNED_NO_REASONING_EFFORT_MODELS: set[tuple[str, str]] = set()
+
# Upstream status codes carried inside a 200 body, mapped to the exception the SDK
# raises when the same code arrives as a real HTTP status. Keeping the classes
# identical means a gateway-masked failure is catchable by whatever already handles
@@ -168,6 +176,48 @@ def _raise_for_upstream_error(
raise error_cls(message, response=http_response, body=error)
+# `reasoning_effort` is accepted by the o-series and by GPT generation 5 onwards.
+# Matched by shape rather than by a list of names so a new member of an existing
+# family works without a release here; `gpt-4o` and `gpt-4.1` parse to generation
+# 4 and are excluded, and a fine-tune is judged by its base model. An over-match
+# is recovered rather than fatal -- the call is retried without the parameter
+# when the API rejects it.
+_O_SERIES_MODEL: Final = re.compile(r"^o\d")
+_GPT_GENERATION: Final = re.compile(r"^gpt-(\d+)")
+_MIN_REASONING_GPT_GENERATION: Final[int] = 5
+# ft::::
+_FINE_TUNE_PREFIX: Final = "ft:"
+_OPENAI_API_HOST: Final = "api.openai.com"
+# How servers say a request field is not one they know: OpenAI's own two shapes
+# alongside the wordings of compatible servers (pydantic, serde, Go). A 400 that
+# names the parameter without one of these -- a bad value, the tools case -- is
+# not a rejection of the parameter and must surface.
+_UNKNOWN_PARAMETER_PHRASES: Final = (
+ "unsupported parameter",
+ "unrecognized request argument",
+ "unknown field",
+ "unknown parameter",
+ "unknown argument",
+ "unexpected field",
+ "unexpected parameter",
+ "extra inputs",
+ "not permitted",
+ "additional properties",
+)
+
+
+def _supports_reasoning_effort(model: str) -> bool:
+ """Whether the model accepts `reasoning_effort` on /v1/chat/completions."""
+ name = model.rsplit("/", 1)[-1].lower().removeprefix(_FINE_TUNE_PREFIX)
+ if _O_SERIES_MODEL.match(name):
+ return True
+ generation = _GPT_GENERATION.match(name)
+ return (
+ generation is not None
+ and int(generation.group(1)) >= _MIN_REASONING_GPT_GENERATION
+ )
+
+
class WebSearchResult(TypedDict, total=False):
"""Result from web search built-in tool."""
@@ -649,6 +699,13 @@ class OpenAICompletion(BaseLLM):
)
return dispatch(retry_params)
+ if self._rejects_reasoning_effort_as_unsupported(cause):
+ retry_params = self._without_reasoning_effort(completion_params)
+ if retry_params is not None:
+ result = dispatch(retry_params)
+ self._remember_no_reasoning_effort_model()
+ return result
+
if self.custom_openai or not self._is_responses_only_error(cause):
raise
self._remember_responses_only_model()
@@ -780,6 +837,13 @@ class OpenAICompletion(BaseLLM):
if retry_params is not None:
return await dispatch(retry_params)
+ if self._rejects_reasoning_effort_as_unsupported(cause):
+ retry_params = self._without_reasoning_effort(completion_params)
+ if retry_params is not None:
+ result = await dispatch(retry_params)
+ self._remember_no_reasoning_effort_model()
+ return result
+
if self.custom_openai or not self._is_responses_only_error(cause):
raise
self._remember_responses_only_model()
@@ -1862,6 +1926,118 @@ class OpenAICompletion(BaseLLM):
message = str(source.get("message") or "").lower()
return "function tools" in message and "reasoning_effort" in message
+ def _sends_reasoning_effort(self) -> bool:
+ """Whether `reasoning_effort` goes on the wire for this model.
+
+ OpenAI's own models are matched by shape. A compatible server names its
+ models in its own namespace, so the name says nothing about support
+ there and the explicit setting is honoured. Either way, a model that
+ has rejected the parameter on this endpoint in this process is not
+ sent it again.
+ """
+ if self._reasoning_effort_key() in _LEARNED_NO_REASONING_EFFORT_MODELS:
+ return False
+ return self._on_compatible_server() or _supports_reasoning_effort(self.model)
+
+ def _effective_base_url(self) -> str | None:
+ """The base URL the client is built with; None means OpenAI's own API.
+
+ Same precedence as `_get_client_params`, where `client_params` win. The
+ SDK accepts ``str | httpx.URL`` there and `_get_client_params` forwards
+ either, so both are normalised to the string the client will call.
+ """
+ override = (self.client_params or {}).get("base_url")
+ if isinstance(override, (str, httpx.URL)) and str(override):
+ return str(override)
+ return (
+ self.base_url
+ or self.api_base
+ or os.getenv("OPENAI_BASE_URL")
+ or os.getenv("OPENAI_API_BASE")
+ )
+
+ def _on_compatible_server(self) -> bool:
+ """Whether requests go to an OpenAI-compatible server rather than OpenAI.
+
+ Decided by where the client actually points -- an explicit ``base_url``
+ or ``api_base``, the ``OPENAI_BASE_URL``/``OPENAI_API_BASE`` env, or the
+ URL a provider subclass resolves -- because ``custom_openai`` is only
+ set on some of those paths.
+ """
+ if self.custom_openai:
+ return True
+ url = self._effective_base_url()
+ if not url:
+ return False
+ return urlsplit(url).hostname != _OPENAI_API_HOST
+
+ def _reasoning_effort_key(self) -> tuple[str, str]:
+ return (self._effective_base_url() or _OPENAI_API_HOST, self.model)
+
+ def _remember_no_reasoning_effort_model(self) -> None:
+ """Record that this model rejected `reasoning_effort`.
+
+ Called once the retry without the parameter has succeeded, so a retry
+ that fails for an unrelated reason leaves the setting in place for the
+ next call instead of silently dropping it for the rest of the process.
+ """
+ logging.warning(
+ "%r rejected reasoning_effort=%r; the call succeeded without it, and "
+ "it will not be sent to this model again in this process.",
+ self.model,
+ self.reasoning_effort,
+ )
+ _LEARNED_NO_REASONING_EFFORT_MODELS.add(self._reasoning_effort_key())
+
+ def _rejects_reasoning_effort_as_unsupported(self, error: BaseException) -> bool:
+ """Whether a 400 is the server refusing `reasoning_effort` for this model.
+
+ Non-reasoning models reject the parameter itself, in one of two shapes:
+
+ {"code": "unsupported_parameter", "param": "reasoning_effort",
+ "message": "Unsupported parameter: 'reasoning_effort' is not
+ supported with this model."}
+
+ {"param": null, "message": "Unrecognized request argument
+ supplied: reasoning_effort"}
+
+ Distinct from `_rejects_reasoning_effort_with_tools`: that is a
+ reasoning model refusing the parameter only alongside function tools,
+ and it recovers by sending "none" rather than by dropping the key. Also
+ deliberately does not match the "Unsupported value" 400 that o1/o3
+ return for a bad *value* -- the model does support the parameter, so
+ silently dropping it would restore the very bug this recovers from.
+
+ A compatible server words the same rejection its own way -- "unknown
+ field", "Extra inputs are not permitted" -- so the message is matched
+ against the common phrasings, read from the body's `message` or from
+ the error text when the body is not the OpenAI shape. A 400 (or 422)
+ that names the parameter without saying it is unknown is a bad value,
+ or the tools case, and surfaces.
+ """
+ if not isinstance(error, (BadRequestError, UnprocessableEntityError)):
+ return False
+ body = getattr(error, "body", None)
+ source: dict[str, Any] = {}
+ if isinstance(body, dict):
+ inner = body.get("error")
+ source = inner if isinstance(inner, dict) else body
+ message = str(
+ source.get("message") or getattr(error, "message", "") or error
+ ).lower()
+ if "reasoning_effort" not in message:
+ return False
+ if source.get("code") == "unsupported_parameter":
+ return True
+ return any(phrase in message for phrase in _UNKNOWN_PARAMETER_PHRASES)
+
+ @staticmethod
+ def _without_reasoning_effort(params: dict[str, Any]) -> dict[str, Any] | None:
+ """Params with `reasoning_effort` removed, or None if it was not set."""
+ if "reasoning_effort" not in params:
+ return None
+ return {k: v for k, v in params.items() if k != "reasoning_effort"}
+
def _reasoning_effort_none_params(
self, params: dict[str, Any]
) -> dict[str, Any] | None:
@@ -1926,8 +2102,11 @@ class OpenAICompletion(BaseLLM):
if self.top_logprobs is not None:
params["top_logprobs"] = self.top_logprobs
- # Handle o1 model specific parameters
- if self.is_o1_model and self.reasoning_effort:
+ # Not gated on `is_o1_model`: that is a literal "o1" substring test, so
+ # gpt-5, o3 and o4-mini failed it and silently thought at the server
+ # default. It also drives tool support and message rewriting, so it
+ # cannot be widened to mean "is a reasoning model".
+ if self.reasoning_effort and self._sends_reasoning_effort():
params["reasoning_effort"] = self.reasoning_effort
if self.response_format is not None:
@@ -2154,9 +2333,11 @@ class OpenAICompletion(BaseLLM):
logging.error(f"Context window exceeded: {e}")
raise LLMContextLengthExceededError(str(e)) from e
- # `_call_completions` retries this one, so reporting a failed call
+ # `_call_completions` retries these, so reporting a failed call
# here would surface an error the caller never experiences.
- if self._rejects_reasoning_effort_with_tools(e):
+ if self._rejects_reasoning_effort_with_tools(
+ e
+ ) or self._rejects_reasoning_effort_as_unsupported(e):
raise
error_msg = f"OpenAI API call failed: {e!s}"
@@ -2592,9 +2773,11 @@ class OpenAICompletion(BaseLLM):
logging.error(f"Context window exceeded: {e}")
raise LLMContextLengthExceededError(str(e)) from e
- # `_call_completions` retries this one, so reporting a failed call
+ # `_call_completions` retries these, so reporting a failed call
# here would surface an error the caller never experiences.
- if self._rejects_reasoning_effort_with_tools(e):
+ if self._rejects_reasoning_effort_with_tools(
+ e
+ ) or self._rejects_reasoning_effort_as_unsupported(e):
raise
error_msg = f"OpenAI API call failed: {e!s}"
diff --git a/lib/crewai/tests/llms/openai/test_reasoning_effort_forwarding.py b/lib/crewai/tests/llms/openai/test_reasoning_effort_forwarding.py
new file mode 100644
index 000000000..fbdbea4a5
--- /dev/null
+++ b/lib/crewai/tests/llms/openai/test_reasoning_effort_forwarding.py
@@ -0,0 +1,711 @@
+"""`reasoning_effort` reaches every reasoning model, not just o1.
+
+The completions path used to gate the parameter behind
+``is_o1_model = "o1" in model.lower()``, a literal substring test. gpt-5, o3 and
+o4-mini contain no "o1", so an explicitly configured effort was dropped and the
+model thought at the server default -- silently, since the request still
+succeeded.
+
+The gate could not simply be widened: ``is_o1_model`` also drives
+``supports_function_calling``, ``supports_stop_words`` and the system->user
+message rewrite, so marking gpt-5 as an o1 model would report that it cannot
+call tools. ``_supports_reasoning_effort`` is a separate predicate matched on
+model *shape* -- the o-series, and GPT generation 5 onwards -- so a new member of
+an existing family needs no release, and a non-reasoning model never pays a
+wasted round trip. If the shape match is ever wrong for a future family, the
+400 is retried without the key rather than surfacing.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+import httpx
+import pytest
+from openai import APIConnectionError, BadRequestError, UnprocessableEntityError
+
+from crewai.llm import LLM
+from crewai.llms.providers.openai import completion as completion_module
+from crewai.llms.providers.openai.completion import (
+ OpenAICompletion,
+ _supports_reasoning_effort,
+)
+from crewai.llms.providers.openai_compatible.completion import (
+ OpenAICompatibleCompletion,
+)
+
+
+MESSAGES = [{"role": "user", "content": "hi"}]
+
+REASONING_MODELS = ["gpt-5", "gpt-5-mini", "o3", "o3-mini", "o4-mini", "o1"]
+
+
+def build(model: str = "gpt-5", **kwargs: Any) -> OpenAICompletion:
+ return OpenAICompletion(model=model, api_key="sk-test", **kwargs)
+
+
+GATEWAY = "https://gateway.example/v1"
+
+
+def gateway(model: str = "gpt-4o", **kwargs: Any) -> OpenAICompletion:
+ """An OpenAI-compatible server fronting whatever it calls `model`."""
+ return build(model, base_url=GATEWAY, **kwargs)
+
+
+def _status_error(cls: type, status: int, body: Any) -> Any:
+ """A rejection whose body is not the OpenAI shape, as the SDK raises it."""
+ message = f"Error code: {status} - {body}"
+ return cls(
+ message,
+ response=httpx.Response(
+ status, json=body, request=httpx.Request("POST", GATEWAY)
+ ),
+ body=body,
+ )
+
+
+@pytest.fixture(autouse=True)
+def forget_rejecting_models():
+ completion_module._LEARNED_NO_REASONING_EFFORT_MODELS.clear()
+ yield
+ completion_module._LEARNED_NO_REASONING_EFFORT_MODELS.clear()
+
+
+def _bad_request(message: str, **source: Any) -> BadRequestError:
+ body = {"error": {"message": message, "type": "invalid_request_error", **source}}
+ return BadRequestError(
+ message,
+ response=httpx.Response(
+ 400,
+ json=body,
+ request=httpx.Request("POST", "https://api.openai.com/v1/chat/completions"),
+ ),
+ body=body,
+ )
+
+
+def unsupported_parameter_error() -> BadRequestError:
+ """What a non-reasoning model returns for the parameter itself."""
+ return _bad_request(
+ "Unsupported parameter: 'reasoning_effort' is not supported with this model.",
+ param="reasoning_effort",
+ code="unsupported_parameter",
+ )
+
+
+def unrecognized_argument_error() -> BadRequestError:
+ """The other shape, where `param` is null."""
+ return _bad_request(
+ "Unrecognized request argument supplied: reasoning_effort", param=None
+ )
+
+
+def unsupported_value_error() -> BadRequestError:
+ """A bad *value* -- the model does support the parameter."""
+ return _bad_request(
+ "Unsupported value: 'reasoning_effort' does not support 'none'.",
+ param="reasoning_effort",
+ )
+
+
+class TestParameterReachesTheModel:
+ @pytest.mark.parametrize("model", REASONING_MODELS)
+ def test_forwarded_for_every_reasoning_model(self, model):
+ params = build(model, reasoning_effort="high")._prepare_completion_params(
+ MESSAGES
+ )
+
+ assert params["reasoning_effort"] == "high"
+
+ def test_minimal_is_forwarded(self):
+ """gpt-5's cheapest setting, and the one extraction workloads want."""
+ params = build("gpt-5", reasoning_effort="minimal")._prepare_completion_params(
+ MESSAGES
+ )
+
+ assert params["reasoning_effort"] == "minimal"
+
+ @pytest.mark.parametrize("model", ["gpt-4o", "gpt-5", "o3"])
+ def test_absent_when_not_configured(self, model):
+ """Unset must stay off the wire, whatever the model."""
+ assert "reasoning_effort" not in build(model)._prepare_completion_params(
+ MESSAGES
+ )
+
+ @pytest.mark.parametrize("model", ["gpt-4o", "gpt-4.1", "gpt-3.5-turbo"])
+ def test_not_sent_to_non_reasoning_models(self, model):
+ """No wasted round trip: these never reach the API with the parameter."""
+ params = build(model, reasoning_effort="high")._prepare_completion_params(
+ MESSAGES
+ )
+
+ assert "reasoning_effort" not in params
+
+
+class TestSupportedModelShape:
+ """Matched on shape so a new family member needs no release."""
+
+ @pytest.mark.parametrize(
+ "model",
+ [
+ "o1",
+ "o1-mini",
+ "o1-preview",
+ "o3",
+ "o3-mini",
+ "o4-mini",
+ "gpt-5",
+ "gpt-5-mini",
+ "gpt-5.1",
+ "gpt-5.6-sol",
+ "openai/gpt-5",
+ "ft:o4-mini-2025-04-16:acme::abc123",
+ ],
+ )
+ def test_supported(self, model):
+ assert _supports_reasoning_effort(model) is True
+
+ @pytest.mark.parametrize(
+ "model",
+ [
+ "gpt-4o",
+ "gpt-4o-mini",
+ "gpt-4.1",
+ "gpt-4-turbo",
+ "gpt-3.5-turbo",
+ "chatgpt-4o-latest",
+ "omni-moderation-latest",
+ "text-embedding-3-small",
+ "ft:gpt-4o-mini-2024-07-18:acme::abc123",
+ ],
+ )
+ def test_unsupported(self, model):
+ assert _supports_reasoning_effort(model) is False
+
+ @pytest.mark.parametrize("model", ["gpt-6", "gpt-7-turbo", "o5", "o9-mini"])
+ def test_future_family_members_need_no_release(self, model):
+ """The staleness this replaced: an unreleased family must still match."""
+ assert _supports_reasoning_effort(model) is True
+
+
+class TestO1FlagUntouched:
+ """The gate was shared; widening it would have broken these."""
+
+ @pytest.mark.parametrize("model", ["gpt-5", "o3", "o4-mini"])
+ def test_reasoning_models_still_support_tools(self, model):
+ assert build(model).supports_function_calling() is True
+
+ @pytest.mark.parametrize("model", ["gpt-5", "o3"])
+ def test_is_o1_model_still_only_matches_o1(self, model):
+ assert build(model).is_o1_model is False
+ assert build("o1").is_o1_model is True
+
+ def test_system_messages_are_not_rewritten_for_gpt5(self):
+ formatted = build("gpt-5")._format_messages(
+ [{"role": "system", "content": "be terse"}]
+ )
+
+ assert formatted[0]["role"] == "system"
+
+
+class TestErrorDetection:
+ def test_matches_unsupported_parameter(self):
+ assert build()._rejects_reasoning_effort_as_unsupported(
+ unsupported_parameter_error()
+ )
+
+ def test_matches_unrecognized_argument(self):
+ assert build()._rejects_reasoning_effort_as_unsupported(
+ unrecognized_argument_error()
+ )
+
+ def test_ignores_an_unsupported_value(self):
+ """Dropping the key here would silently restore the original bug."""
+ assert not build()._rejects_reasoning_effort_as_unsupported(
+ unsupported_value_error()
+ )
+
+ def test_ignores_a_400_about_another_parameter(self):
+ assert not build()._rejects_reasoning_effort_as_unsupported(
+ _bad_request(
+ "Unsupported parameter: 'temperature' is not supported.",
+ param="temperature",
+ code="unsupported_parameter",
+ )
+ )
+
+ def test_ignores_unrelated_exceptions(self):
+ assert not build()._rejects_reasoning_effort_as_unsupported(
+ RuntimeError("boom")
+ )
+
+
+class TestRetryParams:
+ def test_removes_the_key(self):
+ params = OpenAICompletion._without_reasoning_effort(
+ {"model": "gpt-4o", "reasoning_effort": "high"}
+ )
+
+ assert params == {"model": "gpt-4o"}
+
+ def test_returns_none_when_absent(self):
+ """Nothing left to drop -- the retry must not loop."""
+ assert OpenAICompletion._without_reasoning_effort({"model": "gpt-4o"}) is None
+
+
+class TestRetryBehaviour:
+ def test_retries_without_the_key_and_succeeds(self, monkeypatch):
+ llm = build("gpt-6-future", reasoning_effort="high")
+ seen: list[dict] = []
+
+ def fake_handle(params, **kwargs):
+ seen.append(params)
+ if "reasoning_effort" in params:
+ raise unsupported_parameter_error()
+ return "ok"
+
+ monkeypatch.setattr(llm, "_handle_completion", fake_handle)
+
+ assert llm._call_completions(MESSAGES) == "ok"
+ assert len(seen) == 2, "expected one rejected call and one retry"
+ assert seen[0]["reasoning_effort"] == "high"
+ assert "reasoning_effort" not in seen[1]
+
+ @pytest.mark.asyncio
+ async def test_retries_on_the_async_path(self, monkeypatch):
+ llm = build("gpt-6-future", reasoning_effort="high")
+ seen: list[dict] = []
+
+ async def fake_handle(params, **kwargs):
+ seen.append(params)
+ if "reasoning_effort" in params:
+ raise unsupported_parameter_error()
+ return "ok"
+
+ monkeypatch.setattr(llm, "_ahandle_completion", fake_handle)
+
+ assert await llm._acall_completions(MESSAGES) == "ok"
+ assert len(seen) == 2
+ assert "reasoning_effort" not in seen[1]
+
+ def test_retries_on_the_streaming_path(self, monkeypatch):
+ llm = build("gpt-6-future", reasoning_effort="high", stream=True)
+ seen: list[dict] = []
+
+ def fake_handle(params, **kwargs):
+ seen.append(params)
+ if "reasoning_effort" in params:
+ raise unsupported_parameter_error()
+ return "ok"
+
+ monkeypatch.setattr(llm, "_handle_streaming_completion", fake_handle)
+
+ assert llm._call_completions(MESSAGES) == "ok"
+ assert len(seen) == 2
+ assert "reasoning_effort" not in seen[1]
+
+ @pytest.mark.asyncio
+ async def test_retries_on_the_async_streaming_path(self, monkeypatch):
+ llm = build("gpt-6-future", reasoning_effort="high", stream=True)
+ seen: list[dict] = []
+
+ async def fake_handle(params, **kwargs):
+ seen.append(params)
+ if "reasoning_effort" in params:
+ raise unsupported_parameter_error()
+ return "ok"
+
+ monkeypatch.setattr(llm, "_ahandle_streaming_completion", fake_handle)
+
+ assert await llm._acall_completions(MESSAGES) == "ok"
+ assert len(seen) == 2
+ assert "reasoning_effort" not in seen[1]
+
+ def test_does_not_retry_forever(self, monkeypatch):
+ llm = build("gpt-6-future", reasoning_effort="high")
+ calls: list[dict] = []
+
+ def always_fail(params, **kwargs):
+ calls.append(params)
+ raise unsupported_parameter_error()
+
+ monkeypatch.setattr(llm, "_handle_completion", always_fail)
+
+ with pytest.raises(BadRequestError, match="not supported with this model"):
+ llm._call_completions(MESSAGES)
+
+ assert len(calls) == 2, "one original call plus exactly one retry"
+
+ def test_an_unsupported_value_surfaces(self, monkeypatch):
+ """A bad value is the caller's mistake and must not be papered over."""
+ llm = build("o3", reasoning_effort="none")
+ calls: list[dict] = []
+
+ def always_fail(params, **kwargs):
+ calls.append(params)
+ raise unsupported_value_error()
+
+ monkeypatch.setattr(llm, "_handle_completion", always_fail)
+
+ with pytest.raises(BadRequestError, match="does not support 'none'"):
+ llm._call_completions(MESSAGES)
+
+ assert len(calls) == 1, "a bad value must not be retried"
+
+ def test_a_model_that_rejected_the_parameter_is_not_sent_it_again(
+ self, monkeypatch
+ ):
+ """The rejected call is paid once per process, not on every request."""
+ llm = build("gpt-6-future", reasoning_effort="high")
+ seen: list[dict] = []
+
+ def fake_handle(params, **kwargs):
+ seen.append(params)
+ if "reasoning_effort" in params:
+ raise unsupported_parameter_error()
+ return "ok"
+
+ monkeypatch.setattr(llm, "_handle_completion", fake_handle)
+
+ llm._call_completions(MESSAGES)
+ llm._call_completions(MESSAGES)
+
+ assert ["reasoning_effort" in params for params in seen] == [
+ True,
+ False,
+ False,
+ ]
+
+ def test_a_rejection_is_remembered_only_once_the_retry_succeeds(
+ self, monkeypatch
+ ):
+ """A retry that dies for another reason has not established the fallback.
+
+ Remembering before the retry would silently stop sending a configured
+ effort for the rest of the process on the strength of one transient
+ failure; the setting must survive until a call without it succeeds.
+ """
+ llm = build("gpt-6-future", reasoning_effort="high")
+ seen: list[dict] = []
+ retry_outcome: list[Exception | None] = [
+ APIConnectionError(request=httpx.Request("POST", GATEWAY))
+ ]
+
+ def fake_handle(params, **kwargs):
+ seen.append(params)
+ if "reasoning_effort" in params:
+ raise unsupported_parameter_error()
+ if retry_outcome and (failure := retry_outcome.pop()):
+ raise failure
+ return "ok"
+
+ monkeypatch.setattr(llm, "_handle_completion", fake_handle)
+
+ with pytest.raises(APIConnectionError):
+ llm._call_completions(MESSAGES)
+ assert not completion_module._LEARNED_NO_REASONING_EFFORT_MODELS
+
+ assert llm._call_completions(MESSAGES) == "ok"
+ assert llm._reasoning_effort_key() in (
+ completion_module._LEARNED_NO_REASONING_EFFORT_MODELS
+ )
+ assert ["reasoning_effort" in params for params in seen] == [
+ True,
+ False,
+ True,
+ False,
+ ], "the effort is sent again after a failed retry, then dropped for good"
+
+ @pytest.mark.asyncio
+ async def test_a_rejection_is_remembered_only_once_the_async_retry_succeeds(
+ self, monkeypatch
+ ):
+ llm = build("gpt-6-future", reasoning_effort="high")
+ seen: list[dict] = []
+ retry_outcome: list[Exception | None] = [
+ APIConnectionError(request=httpx.Request("POST", GATEWAY))
+ ]
+
+ async def fake_handle(params, **kwargs):
+ seen.append(params)
+ if "reasoning_effort" in params:
+ raise unsupported_parameter_error()
+ if retry_outcome and (failure := retry_outcome.pop()):
+ raise failure
+ return "ok"
+
+ monkeypatch.setattr(llm, "_ahandle_completion", fake_handle)
+
+ with pytest.raises(APIConnectionError):
+ await llm._acall_completions(MESSAGES)
+ assert not completion_module._LEARNED_NO_REASONING_EFFORT_MODELS
+
+ assert await llm._acall_completions(MESSAGES) == "ok"
+ assert ["reasoning_effort" in params for params in seen] == [
+ True,
+ False,
+ True,
+ False,
+ ]
+
+
+class TestCompatibleServers:
+ """The model name is the server's namespace, so it says nothing about support."""
+
+ @pytest.mark.parametrize(
+ "model", ["gpt-4o", "gpt-oss-120b", "qwen3-235b", "deepseek-r1"]
+ )
+ def test_explicit_setting_is_forwarded_whatever_the_name(self, model):
+ params = gateway(model, reasoning_effort="low")._prepare_completion_params(
+ MESSAGES
+ )
+
+ assert params["reasoning_effort"] == "low"
+
+ def test_the_flag_marks_a_server_compatible(self):
+ params = gateway(
+ "gpt-4o", custom_openai=True, reasoning_effort="low"
+ )._prepare_completion_params(MESSAGES)
+
+ assert params["reasoning_effort"] == "low"
+
+ def test_a_client_params_base_url_marks_a_server_compatible(self):
+ """`client_params` win over the standard fields when the client is built."""
+ llm = build("gpt-4o", client_params={"base_url": GATEWAY}, reasoning_effort="low")
+
+ assert llm._prepare_completion_params(MESSAGES)["reasoning_effort"] == "low"
+
+ def test_an_httpx_url_override_is_the_endpoint_the_client_calls(self):
+ """The SDK takes `str | httpx.URL`; detection and memory must see both alike."""
+ llm = build(
+ "gpt-4o",
+ client_params={"base_url": httpx.URL(GATEWAY)},
+ reasoning_effort="low",
+ )
+
+ assert llm._prepare_completion_params(MESSAGES)["reasoning_effort"] == "low"
+ assert llm._reasoning_effort_key() == (GATEWAY, "gpt-4o")
+
+ def test_an_env_base_url_marks_a_server_compatible(self, monkeypatch):
+ monkeypatch.setenv("OPENAI_BASE_URL", "http://localhost:11434/v1")
+
+ params = build("qwen3", reasoning_effort="low")._prepare_completion_params(
+ MESSAGES
+ )
+
+ assert params["reasoning_effort"] == "low"
+
+ def test_openais_own_url_is_not_a_compatible_server(self):
+ llm = build("gpt-4o", base_url="https://api.openai.com/v1", reasoning_effort="low")
+
+ assert "reasoning_effort" not in llm._prepare_completion_params(MESSAGES)
+
+ def test_reaches_the_wire_through_the_llm_factory(self):
+ """gpt-oss looks like an OpenAI name to the factory, so it never flags it."""
+ llm = LLM(
+ model="gpt-oss-120b",
+ base_url="http://localhost:8000/v1",
+ api_key="sk-test",
+ reasoning_effort="high",
+ )
+
+ assert llm._prepare_completion_params(MESSAGES)["reasoning_effort"] == "high"
+
+ def test_forwarded_through_a_provider_subclass(self):
+ llm = OpenAICompatibleCompletion(
+ model="openai/gpt-oss-120b",
+ provider="openrouter",
+ api_key="sk-test",
+ reasoning_effort="low",
+ )
+
+ assert llm._prepare_completion_params(MESSAGES)["reasoning_effort"] == "low"
+
+ def test_unset_stays_off_the_wire(self):
+ params = gateway("gpt-oss-120b")._prepare_completion_params(MESSAGES)
+
+ assert "reasoning_effort" not in params
+
+ def test_a_rejection_in_the_servers_own_words_is_recovered(self, monkeypatch):
+ llm = gateway("qwen3-235b", reasoning_effort="low")
+ seen: list[dict] = []
+
+ def fake_handle(params, **kwargs):
+ seen.append(params)
+ if "reasoning_effort" in params:
+ raise _bad_request(
+ "[{'loc': ('body', 'reasoning_effort'), "
+ "'msg': 'Extra inputs are not permitted'}]",
+ param=None,
+ )
+ return "ok"
+
+ monkeypatch.setattr(llm, "_handle_completion", fake_handle)
+
+ assert llm._call_completions(MESSAGES) == "ok"
+ assert len(seen) == 2
+ assert "reasoning_effort" not in seen[1]
+
+ @pytest.mark.parametrize(
+ "error",
+ [
+ _status_error(
+ BadRequestError, 400, {"error": "unknown field `reasoning_effort`"}
+ ),
+ _status_error(
+ UnprocessableEntityError,
+ 422,
+ {
+ "detail": [
+ {
+ "loc": ["body", "reasoning_effort"],
+ "msg": "Extra inputs are not permitted",
+ }
+ ]
+ },
+ ),
+ ],
+ ids=["string-body", "fastapi-422"],
+ )
+ def test_a_rejection_outside_the_openai_shape_is_recovered(
+ self, monkeypatch, error
+ ):
+ llm = gateway("qwen3-235b", reasoning_effort="low")
+ seen: list[dict] = []
+
+ def fake_handle(params, **kwargs):
+ seen.append(params)
+ if "reasoning_effort" in params:
+ raise error
+ return "ok"
+
+ monkeypatch.setattr(llm, "_handle_completion", fake_handle)
+
+ assert llm._call_completions(MESSAGES) == "ok"
+ assert "reasoning_effort" not in seen[1]
+
+ def test_a_complaint_about_the_value_surfaces_and_is_not_remembered(
+ self, monkeypatch
+ ):
+ """The server takes the parameter; the value is the caller's mistake."""
+ llm = gateway("o3", reasoning_effort="minimal")
+ calls: list[dict] = []
+
+ def always_fail(params, **kwargs):
+ calls.append(params)
+ raise _bad_request(
+ "Invalid value 'minimal' for reasoning_effort; "
+ "must be one of low, medium, high.",
+ param="reasoning_effort",
+ )
+
+ monkeypatch.setattr(llm, "_handle_completion", always_fail)
+
+ with pytest.raises(BadRequestError, match="Invalid value"):
+ llm._call_completions(MESSAGES)
+
+ assert len(calls) == 1
+ assert not completion_module._LEARNED_NO_REASONING_EFFORT_MODELS
+
+ @pytest.mark.parametrize(
+ "error",
+ [
+ _status_error(
+ UnprocessableEntityError,
+ 422,
+ {
+ "detail": [
+ {
+ "loc": ["body", "reasoning_effort"],
+ "msg": "Input should be 'low', 'medium' or 'high'",
+ "type": "literal_error",
+ }
+ ]
+ },
+ ),
+ _status_error(
+ BadRequestError, 400, {"error": "reasoning_effort: something odd"}
+ ),
+ ],
+ ids=["pydantic-enum", "ambiguous"],
+ )
+ def test_without_evidence_the_parameter_is_unknown_the_error_surfaces(
+ self, monkeypatch, error
+ ):
+ """Only a rejection of the parameter itself is recovered; anything else
+ naming it is the caller's to see."""
+ llm = gateway("qwen3-235b", reasoning_effort="minimal")
+ calls: list[dict] = []
+
+ def always_fail(params, **kwargs):
+ calls.append(params)
+ raise error
+
+ monkeypatch.setattr(llm, "_handle_completion", always_fail)
+
+ with pytest.raises(type(error)):
+ llm._call_completions(MESSAGES)
+
+ assert len(calls) == 1
+ assert not completion_module._LEARNED_NO_REASONING_EFFORT_MODELS
+
+ @pytest.mark.parametrize(
+ "gateway_config",
+ [
+ pytest.param({"base_url": GATEWAY}, id="base_url"),
+ pytest.param(
+ {"client_params": {"base_url": httpx.URL(GATEWAY)}}, id="httpx.URL"
+ ),
+ ],
+ )
+ def test_a_gateway_rejection_does_not_silence_the_model_on_openai(
+ self, monkeypatch, gateway_config
+ ):
+ """However the gateway is configured, its rejection is keyed to it, not to OpenAI."""
+ llm = build("gpt-5", reasoning_effort="high", **gateway_config)
+
+ def reject(params, **kwargs):
+ if "reasoning_effort" in params:
+ raise unsupported_parameter_error()
+ return "ok"
+
+ monkeypatch.setattr(llm, "_handle_completion", reject)
+ llm._call_completions(MESSAGES)
+
+ params = build("gpt-5", reasoning_effort="high")._prepare_completion_params(
+ MESSAGES
+ )
+ assert params["reasoning_effort"] == "high"
+
+ def test_openai_itself_is_held_to_the_known_shapes(self, monkeypatch):
+ """The lenient match is for other servers; OpenAI's 400s stay precise."""
+ llm = build("gpt-5", reasoning_effort="low")
+ calls: list[dict] = []
+
+ def always_fail(params, **kwargs):
+ calls.append(params)
+ raise _bad_request("Something else about reasoning_effort.", param=None)
+
+ monkeypatch.setattr(llm, "_handle_completion", always_fail)
+
+ with pytest.raises(BadRequestError, match="Something else"):
+ llm._call_completions(MESSAGES)
+
+ assert len(calls) == 1
+
+
+class TestLLMSurface:
+ @pytest.mark.parametrize(
+ "effort", ["none", "minimal", "low", "medium", "high", "xhigh"]
+ )
+ def test_llm_accepts_every_documented_effort(self, effort):
+ llm = LLM(model="gpt-5", reasoning_effort=effort, is_litellm=True)
+
+ assert llm.reasoning_effort == effort
+
+ def test_reaches_the_wire_through_the_llm_factory(self):
+ """End to end: LLM(...) -> native provider -> request params."""
+ llm = LLM(model="gpt-5", reasoning_effort="minimal", is_litellm=False)
+ params = llm._prepare_completion_params(MESSAGES)
+
+ assert params["reasoning_effort"] == "minimal"