Skip to content
Merged
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
2 changes: 1 addition & 1 deletion packages/uipath-platform/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -105,7 +108,7 @@ class ChatCompletionChoice(BaseModel):

index: int
message: ChatMessage
finish_reason: str
finish_reason: Optional[str] = None


class ChatCompletionUsage(BaseModel):
Expand Down
175 changes: 175 additions & 0 deletions packages/uipath-platform/tests/services/test_uipath_llm_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions packages/uipath-platform/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/uipath/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading