From 41f5a82aefe17ef33178bae3e8b557907b901711 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Wed, 12 Aug 2026 08:57:47 -0700 Subject: [PATCH 1/2] fix(llm): force reasoning_effort none for gpt-5.6-terra tool calls and tolerate role-less choices gpt-5.6-terra rejects function tools combined with any reasoning effort other than "none", and with reasoning left on it returns a forced tool-call choice without message.role or finish_reason. The strict ChatCompletion pydantic model then failed validation ("2 validation errors for ChatCompletion: choices.0.message.role Field required, choices.0.finish_reason Field required"), so every LLM-as-judge evaluation using terra errored out and scored 0%. Python-side mirror of UiPath/Agents#6020 and UiPath/Agents#5995 (SRE-636507 / SRE-639489): - UiPathLlmChatService.chat_completions sends reasoning_effort "none" for gpt-5.6-terra when the request carries tools; tool-less requests keep the default behavior. - ChatMessage.role defaults to "assistant" and ChatCompletionChoice.finish_reason is optional, so a role-less tool-call choice parses instead of being rejected. Co-Authored-By: Claude Fable 5 --- .../platform/chat/_llm_gateway_service.py | 19 ++ .../src/uipath/platform/chat/llm_gateway.py | 7 +- .../services/test_uipath_llm_integration.py | 175 ++++++++++++++++++ 3 files changed, 199 insertions(+), 2 deletions(-) diff --git a/packages/uipath-platform/src/uipath/platform/chat/_llm_gateway_service.py b/packages/uipath-platform/src/uipath/platform/chat/_llm_gateway_service.py index 65ec6223a..5daa5e68e 100644 --- a/packages/uipath-platform/src/uipath/platform/chat/_llm_gateway_service.py +++ b/packages/uipath-platform/src/uipath/platform/chat/_llm_gateway_service.py @@ -49,6 +49,11 @@ DEFAULT_REQUESTING_PRODUCT = "uipath-python-sdk" DEFAULT_REQUESTING_FEATURE = "llm-call" +# Models that only accept function tools with a specific reasoning effort (keyed by +# lowercase model name). gpt-5.6-terra requires reasoning_effort "none" when tools +# are present; see SRE-636507 / SRE-639489. +TOOL_CALL_MODEL_REASONING_EFFORT = {"gpt-5.6-terra": "none"} + def _build_llm_headers( requesting_product: str = DEFAULT_REQUESTING_PRODUCT, @@ -630,6 +635,20 @@ class Country(BaseModel): else: request_body["tool_choice"] = tool_choice.model_dump() + # gpt-5.6 (terra) rejects function tools combined with any reasoning effort + # other than "none" ("Function tools with reasoning_effort are not supported + # for gpt-5.6-terra ... set reasoning_effort to 'none'"); with reasoning left + # on it can also return a reasoning/tool-call choice without message.role or + # finish_reason, which fails ChatCompletion validation. Force "none" for + # tool-bearing requests; tool-less requests keep the default behavior. + if ( + request_body.get("tools") + and model_lower in TOOL_CALL_MODEL_REASONING_EFFORT + ): + request_body["reasoning_effort"] = TOOL_CALL_MODEL_REASONING_EFFORT[ + model_lower + ] + headers = { **self._llm_headers, **build_trace_context_headers(), diff --git a/packages/uipath-platform/src/uipath/platform/chat/llm_gateway.py b/packages/uipath-platform/src/uipath/platform/chat/llm_gateway.py index 0223bd4d3..092e3c6a5 100644 --- a/packages/uipath-platform/src/uipath/platform/chat/llm_gateway.py +++ b/packages/uipath-platform/src/uipath/platform/chat/llm_gateway.py @@ -95,7 +95,10 @@ class SpecificToolChoice(BaseModel): class ChatMessage(BaseModel): """Model representing a chat message.""" - role: str + # Reasoning models (e.g. gpt-5.6-terra) can return a forced tool-call choice + # without a role or finish_reason, so both are tolerated instead of rejecting + # an otherwise valid response. + role: str = "assistant" content: Optional[str] = None tool_calls: Optional[List[ToolCall]] = None @@ -105,7 +108,7 @@ class ChatCompletionChoice(BaseModel): index: int message: ChatMessage - finish_reason: str + finish_reason: Optional[str] = None class ChatCompletionUsage(BaseModel): diff --git a/packages/uipath-platform/tests/services/test_uipath_llm_integration.py b/packages/uipath-platform/tests/services/test_uipath_llm_integration.py index 9e2292c60..06ca7fa69 100644 --- a/packages/uipath-platform/tests/services/test_uipath_llm_integration.py +++ b/packages/uipath-platform/tests/services/test_uipath_llm_integration.py @@ -590,3 +590,178 @@ async def test_no_tools_mocked(self, mock_request, llm_service): assert kwargs["json"]["messages"] == messages assert kwargs["json"]["max_tokens"] == 100 assert kwargs["json"]["temperature"] == 0.7 + + +class TestTerraToolCallReasoningEffort: + """gpt-5.6-terra only accepts function tools with reasoning_effort "none", + and with reasoning left on it can return a tool-call choice without + message.role / finish_reason (SRE-636507 / SRE-639489).""" + + @pytest.fixture + def config(self): + return UiPathApiConfig(base_url="https://example.com", secret="test_secret") + + @pytest.fixture + def execution_context(self): + return UiPathExecutionContext() + + @pytest.fixture + def llm_service(self, config, execution_context): + return UiPathLlmChatService(config=config, execution_context=execution_context) + + @staticmethod + def _tool() -> ToolDefinition: + return ToolDefinition( + type="function", + function=ToolFunctionDefinition( + name="submit_evaluation", + description="submit the evaluation score", + parameters=ToolParametersDefinition( + type="object", + properties={ + "score": ToolPropertyDefinition( + type="number", description="score from 0-100" + ), + }, + required=["score"], + ), + ), + ) + + @staticmethod + def _mock_response(model: str) -> MagicMock: + mock_response = MagicMock() + mock_response.json.return_value = { + "id": "chatcmpl-terra", + "object": "chat.completion", + "created": 1677858242, + "model": model, + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_terra1", + "name": "submit_evaluation", + "arguments": {"score": 90}, + } + ], + }, + "finish_reason": "tool_calls", + } + ], + "usage": { + "prompt_tokens": 50, + "completion_tokens": 25, + "total_tokens": 75, + "cache_read_input_tokens": None, + }, + } + return mock_response + + @pytest.mark.asyncio + @patch.object(UiPathLlmChatService, "request_async") + async def test_terra_with_tools_forces_reasoning_effort_none( + self, mock_request, llm_service + ): + mock_request.return_value = self._mock_response("gpt-5.6-terra") + + await llm_service.chat_completions( + messages=[{"role": "user", "content": "score this"}], + model="gpt-5.6-terra", + max_tokens=250, + tools=[self._tool()], + tool_choice=RequiredToolChoice(), + ) + + request_body = mock_request.call_args[1]["json"] + assert request_body["reasoning_effort"] == "none" + + @pytest.mark.asyncio + @patch.object(UiPathLlmChatService, "request_async") + async def test_terra_without_tools_keeps_default_reasoning( + self, mock_request, llm_service + ): + mock_request.return_value = self._mock_response("gpt-5.6-terra") + + await llm_service.chat_completions( + messages=[{"role": "user", "content": "hello"}], + model="gpt-5.6-terra", + max_tokens=250, + ) + + request_body = mock_request.call_args[1]["json"] + assert "reasoning_effort" not in request_body + + @pytest.mark.asyncio + @patch.object(UiPathLlmChatService, "request_async") + async def test_other_models_with_tools_do_not_send_reasoning_effort( + self, mock_request, llm_service + ): + mock_request.return_value = self._mock_response( + ChatModels.gpt_4_1_mini_2025_04_14 + ) + + await llm_service.chat_completions( + messages=[{"role": "user", "content": "score this"}], + model=ChatModels.gpt_4_1_mini_2025_04_14, + max_tokens=250, + tools=[self._tool()], + tool_choice=RequiredToolChoice(), + ) + + request_body = mock_request.call_args[1]["json"] + assert "reasoning_effort" not in request_body + + @pytest.mark.asyncio + @patch.object(UiPathLlmChatService, "request_async") + async def test_roleless_tool_call_choice_without_finish_reason_parses( + self, mock_request, llm_service + ): + # Terra's forced-tool-call shape: no message.role, no finish_reason. + mock_response = MagicMock() + mock_response.json.return_value = { + "id": "chatcmpl-terra", + "object": "chat.completion", + "created": 1677858242, + "model": "gpt-5.6-terra", + "choices": [ + { + "index": 0, + "message": { + "tool_calls": [ + { + "id": "call_terra2", + "name": "submit_evaluation", + "arguments": {"score": 85}, + "summary": [], + } + ], + }, + } + ], + "usage": { + "prompt_tokens": 50, + "completion_tokens": 25, + "total_tokens": 75, + }, + } + mock_request.return_value = mock_response + + result = await llm_service.chat_completions( + messages=[{"role": "user", "content": "score this"}], + model="gpt-5.6-terra", + max_tokens=250, + tools=[self._tool()], + tool_choice=RequiredToolChoice(), + ) + + assert result.choices[0].message.role == "assistant" + assert result.choices[0].finish_reason is None + tool_calls = result.choices[0].message.tool_calls + assert tool_calls is not None + assert tool_calls[0].name == "submit_evaluation" + assert tool_calls[0].arguments["score"] == 85 From f2bc1bcbd1eebbebabf053099d0873f897d258cc Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Wed, 12 Aug 2026 09:31:31 -0700 Subject: [PATCH 2/2] chore(release): bump uipath-platform to 0.2.18 Co-Authored-By: Claude Fable 5 --- packages/uipath-platform/pyproject.toml | 2 +- packages/uipath-platform/uv.lock | 4 ++-- packages/uipath/uv.lock | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index eab210d34..b12e287e3 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.17" +version = "0.2.18" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 2e506b0af..99067a7a6 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-07-29T07:23:36.9681123Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.17" +version = "0.2.18" source = { editable = "." } dependencies = [ { name = "anyio" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 13c6bacff..f9c60b86a 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2760,7 +2760,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.17" +version = "0.2.18" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" },