Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions reflexio/server/llm/_litellm_text_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,17 @@ class TextGenerationMixin:
"gemini-3-pro-preview",
}

# Models that reject function tools on /v1/chat/completions while reasoning is
# on. The API answers 400 with:
# "Function tools with reasoning_effort are not supported for gpt-5.6-luna
# in /v1/chat/completions. To use function tools, use /v1/responses or set
# reasoning_effort to 'none'."
# Sending reasoning_effort="none" alongside the tools keeps these models usable
# on the completions path.
TOOLS_REQUIRE_REASONING_DISABLED_MODELS = {
"gpt-5.6",
}

# Base-owned attributes these methods read (init'd in the facade ``__init__``).
config: "LiteLLMConfig"
logger: logging.Logger
Expand Down Expand Up @@ -569,6 +580,10 @@ def _build_completion_params(
params["allowed_openai_params"] = allowed_openai_params
if tools is not None:
params["tools"] = tools
if self._tools_require_reasoning_disabled(actual_model):
# setdefault, and placed before params.update(kwargs), so an
# explicit caller-supplied reasoning_effort still wins.
params.setdefault("reasoning_effort", "none")
if tool_choice is not None:
params["tool_choice"] = tool_choice

Expand Down Expand Up @@ -1737,6 +1752,24 @@ def encode_image_to_base64(self, image_path: str) -> tuple[str, str]:
except ImageEncodingError as exc:
raise LiteLLMClientError(str(exc)) from exc

def _tools_require_reasoning_disabled(self, model: str) -> bool:
"""Check whether a model needs reasoning off to accept function tools.

Args:
model: Model name to check.

Returns:
True if the model rejects function tools unless reasoning_effort is
"none" on the chat/completions path.
"""
model_lower = model.lower()
# Strip provider routing prefixes (e.g., "openrouter/openai/gpt-5.6-luna").
model_name = model_lower.rsplit("/", 1)[-1]
return any(
model_name.startswith(restricted) or model_name == restricted
for restricted in self.TOOLS_REQUIRE_REASONING_DISABLED_MODELS
)

def _is_temperature_restricted_model(self, model: str) -> bool:
"""
Check if a model has temperature restrictions (e.g., GPT-5 and Gemini 3 models only support temperature=1.0).
Expand Down
90 changes: 90 additions & 0 deletions tests/server/llm/test_litellm_client_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -4510,3 +4510,93 @@ class Model(BaseModel):

assert errors == ("count: int_parsing",)
assert all("customer data" not in error for error in errors)


# ===================================================================
# Function tools on models that reject reasoning
# ===================================================================


_TOOLS = [
{
"type": "function",
"function": {
"name": "finish",
"parameters": {"type": "object", "properties": {}},
},
}
]


class TestToolsRequireReasoningDisabled:
"""Tests for _tools_require_reasoning_disabled and its param injection."""

@pytest.fixture()
def client(self):
return _build_client()

@pytest.mark.parametrize(
"model",
[
"gpt-5.6",
"gpt-5.6-luna",
"gpt-5.6-terra",
"gpt-5.6-sol",
"GPT-5.6-Luna",
],
)
def test_affected_models(self, client, model):
assert client._tools_require_reasoning_disabled(model) is True

@pytest.mark.parametrize(
"model",
[
"gpt-5.5",
"gpt-5.4-mini",
"gpt-5-nano",
"gpt-4o",
"claude-3-5-sonnet",
"gemini-3-flash-preview",
],
)
def test_unaffected_models(self, client, model):
assert client._tools_require_reasoning_disabled(model) is False

def test_provider_prefix_stripped(self, client):
assert (
client._tools_require_reasoning_disabled("openrouter/openai/gpt-5.6-luna")
is True
)

def _capture(self, model, **call_kwargs):
calls: list[dict[str, Any]] = []

def fake_completion(**kwargs):
calls.append(kwargs)
return _make_completion_response("ok")

client = _build_client(LiteLLMConfig(model=model))
with patch("litellm.completion", side_effect=fake_completion):
client.generate_chat_response(
messages=[{"role": "user", "content": "test"}], **call_kwargs
)
assert len(calls) == 1
return calls[0]

def test_tools_on_affected_model_disable_reasoning(self):
kwargs = self._capture("gpt-5.6-luna", tools=_TOOLS)
assert kwargs["reasoning_effort"] == "none"

def test_no_tools_leaves_reasoning_alone(self):
"""Only the tools path is affected — plain calls keep reasoning enabled."""
kwargs = self._capture("gpt-5.6-luna")
assert "reasoning_effort" not in kwargs

def test_unaffected_model_with_tools_is_untouched(self):
kwargs = self._capture("gpt-5.5", tools=_TOOLS)
assert "reasoning_effort" not in kwargs

def test_explicit_reasoning_effort_wins(self):
"""setdefault, not assignment: a caller-supplied value must survive."""
kwargs = self._capture("gpt-5.6-luna", tools=_TOOLS, reasoning_effort="low")
assert kwargs["reasoning_effort"] == "low"