mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-10-12 02:12:31 +00:00
Merge branch 'main' into my-crewAI
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:<base model>:<org>:<suffix>:<id>
|
||||
_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}"
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user