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
16 changes: 8 additions & 8 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "uipath-langchain"
version = "0.13.12"
version = "0.13.13"
description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11"
Expand All @@ -24,7 +24,7 @@ dependencies = [
"langchain-mcp-adapters==0.2.1",
"pillow>=12.1.1",
"a2a-sdk>=0.2.0,<1.0.0",
"uipath-langchain-client[openai]>=1.14.1,<1.15.0",
"uipath-langchain-client[openai]>=1.15.0,<1.16.0",
]

classifiers = [
Expand All @@ -41,21 +41,21 @@ maintainers = [

[project.optional-dependencies]
anthropic = [
"uipath-langchain-client[anthropic]>=1.14.1,<1.15.0",
"uipath-langchain-client[anthropic]>=1.15.0,<1.16.0",
]
vertex = [
"uipath-langchain-client[google]>=1.14.1,<1.15.0",
"uipath-langchain-client[vertexai]>=1.14.1,<1.15.0",
"uipath-langchain-client[google]>=1.15.0,<1.16.0",
"uipath-langchain-client[vertexai]>=1.15.0,<1.16.0",
]
bedrock = [
"uipath-langchain-client[bedrock]>=1.14.1,<1.15.0",
"uipath-langchain-client[bedrock]>=1.15.0,<1.16.0",
"boto3-stubs>=1.41.4",
]
fireworks = [
"uipath-langchain-client[fireworks]>=1.14.1,<1.15.0",
"uipath-langchain-client[fireworks]>=1.15.0,<1.16.0",
]
all = [
"uipath-langchain-client[all]>=1.14.1,<1.15.0",
"uipath-langchain-client[all]>=1.15.0,<1.16.0",
]

[project.entry-points."uipath.middlewares"]
Expand Down
41 changes: 20 additions & 21 deletions src/uipath_langchain/agent/exceptions/licensing.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
"""Convert LLM provider HTTP errors into structured AgentRuntimeErrors.
"""Map a normalized LLM-client ``UiPathAPIError`` into an ``AgentRuntimeError``.

Provider exceptions are first normalized to a common ``ProviderError`` (status_code + detail).
This module maps that status code to an AgentRuntimeError so
upstream handling (exception mapper, CAS bridge) can categorise by status code
without provider-specific logic.
The LLM client (uipath-llm-client / uipath-langchain-client) normalizes provider
HTTP errors into a ``UiPathAPIError`` carrying ``status_code`` and ``body``. This
module maps that status code to an ``AgentRuntimeError`` so upstream handling
(exception mapper, CAS bridge) can categorise without provider-specific logic.
"""

from typing import NoReturn

from uipath.llm_client import UiPathAPIError
from uipath.runtime.errors import UiPathErrorCategory

from uipath_langchain.agent.exceptions.exceptions import (
AgentRuntimeError,
AgentRuntimeErrorCode,
)
from uipath_langchain.chat.provider_errors import extract_provider_error

# Maps known LLM Gateway status codes to specific error codes.
# Unknown status codes fall back to HTTP_ERROR.
Expand All @@ -21,28 +23,25 @@
}


def raise_for_provider_http_error(e: BaseException) -> None:
"""Re-raise provider-specific HTTP errors as a structured AgentRuntimeError.
def raise_for_provider_http_error(error: UiPathAPIError) -> NoReturn:
"""Convert a normalized ``UiPathAPIError`` into a structured ``AgentRuntimeError``.

Extracts the HTTP status code and the gateway's ``detail``
from any LLM provider exception and converts it to an
AgentRuntimeError. Does nothing if no HTTP status code can be extracted.
Reads the HTTP status code and the gateway's ``detail`` (from ``error.body``)
and re-raises as an ``AgentRuntimeError`` chained on the original.
"""
err = extract_provider_error(e)
if err.status_code is None:
return

code = _LLM_STATUS_CODE_MAP.get(err.status_code, AgentRuntimeErrorCode.HTTP_ERROR)
status_code = error.status_code
code = _LLM_STATUS_CODE_MAP.get(status_code, AgentRuntimeErrorCode.HTTP_ERROR)
category = (
UiPathErrorCategory.DEPLOYMENT
if err.status_code == 403
if status_code == 403
else UiPathErrorCategory.UNKNOWN
)
detail = error.body.get("detail") if isinstance(error.body, dict) else None

raise AgentRuntimeError(
code=code,
title=f"LLM provider returned HTTP {err.status_code}",
detail=err.detail or str(e),
title=f"LLM provider returned HTTP {status_code}",
detail=detail or error.message or str(error),
category=category,
status=err.status_code,
) from e
status=status_code,
) from error
14 changes: 10 additions & 4 deletions src/uipath_langchain/agent/react/llm_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
from langchain_core.tools import BaseTool
from pydantic import BaseModel
from uipath.agent.react import RAISE_ERROR_TOOL
from uipath.llm_client import UiPathAPIError
from uipath.llm_client.utils.exceptions import as_uipath_error
from uipath.runtime.errors import UiPathErrorCategory

from uipath_langchain.chat.handlers import get_payload_handler
Expand Down Expand Up @@ -119,11 +121,15 @@ async def llm_node(state: StateT):

try:
response = await llm.ainvoke(messages)
except Exception as e:
# LLM errors arrive as provider-specific exceptions (OpenAI, Bedrock,
# Vertex). Convert to a structured AgentRuntimeError with the HTTP
# status code so upstream handlers can categorise (e.g. 403 → licensing).
except UiPathAPIError as e:
# New LLM clients surface provider HTTP errors as a normalized UiPathAPIError directly.
raise_for_provider_http_error(e)
except Exception as e:
# Legacy in-repo clients (use_new_llm_clients=False) raise raw provider SDK exceptions.
# Normalize via as_uipath_error and apply the same mapping when the error is HTTP-shaped; non-HTTP errors propagate.
uipath_error = as_uipath_error(e)

@radu-mocanu radu-mocanu Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This breaks the legacy provider error mapping. Before, legacy Vertex/google-genai code/details errors and botocore Bedrock dict-shaped response errors with HTTP 403 would map to AgentRuntimeError(LICENSE_NOT_AVAILABLE). Now as_uipath_error() maps those shapes to plain UiPathError, so this falls through to raise and surfaces the raw provider error instead.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the provider exceptions already wrap over response which as_uipath_error() map to UiPathApiError

spec for this: https://github.com/UiPath/uipath-langchain-python/pull/946/changes#diff-81eb220e0d94c8aaad7befe42c87f1a220044879dfb57988ef4e9bb1180487a2R392

if isinstance(uipath_error, UiPathAPIError):
raise_for_provider_http_error(uipath_error)
raise
Comment thread
vldcmp-uipath marked this conversation as resolved.
if not isinstance(response, AIMessage):
raise AgentRuntimeError(
Expand Down
95 changes: 0 additions & 95 deletions src/uipath_langchain/chat/provider_errors.py

This file was deleted.

71 changes: 71 additions & 0 deletions tests/agent/react/test_llm_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,21 @@
from typing import Any
from unittest.mock import AsyncMock, Mock

import httpx
import openai
import pytest
from langchain_core.language_models import BaseChatModel
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.messages.content import create_text_block, create_tool_call
from langchain_core.tools import BaseTool
from langchain_openai import AzureChatOpenAI
from uipath.agent.react import END_EXECUTION_TOOL, RAISE_ERROR_TOOL
from uipath.llm_client import UiPathAPIError

from uipath_langchain.agent.exceptions.exceptions import (
AgentRuntimeError,
AgentRuntimeErrorCode,
)
from uipath_langchain.agent.react.llm_node import create_llm_node
from uipath_langchain.agent.react.types import AgentGraphState

Expand Down Expand Up @@ -342,3 +349,67 @@ async def test_multiple_raise_error_calls_keeps_only_first(self):
assert len(response_message.tool_calls) == 1
assert response_message.tool_calls[0]["name"] == RAISE_ERROR_TOOL.name
assert response_message.tool_calls[0]["args"]["message"] == "first error"


class TestLLMNodeProviderErrorHandling:
"""llm_node maps provider HTTP errors to AgentRuntimeError (new + legacy clients)."""

mock_model: Any

def setup_method(self):
self.mock_model = _StubAzureChatOpenAI.model_construct()
self.mock_model.bind_tools = Mock(return_value=self.mock_model)
self.mock_model.bind = Mock(return_value=self.mock_model)
self.tool = Mock(spec=BaseTool)
self.tool.name = "regular_tool"
self.state = AgentGraphState(messages=[HumanMessage(content="Test")])

def _node_raising(self, exc: BaseException):
self.mock_model.ainvoke = AsyncMock(side_effect=exc)
return create_llm_node(self.mock_model, [self.tool])

@staticmethod
def _http_403() -> httpx.Response:
request = httpx.Request("POST", "http://gateway/")
return httpx.Response(
403, request=request, json={"status": 403, "detail": "need AGU"}
)

@pytest.mark.asyncio
async def test_new_client_uipath_api_error_maps_to_license(self):
# New LLM clients raise a normalized UiPathAPIError -> except UiPathAPIError.
node = self._node_raising(UiPathAPIError.from_response(self._http_403()))

with pytest.raises(AgentRuntimeError) as exc_info:
await node(self.state)

info = exc_info.value.error_info
assert info.status == 403
assert info.code.endswith(AgentRuntimeErrorCode.LICENSE_NOT_AVAILABLE.value)
assert info.detail == "need AGU"

@pytest.mark.asyncio
async def test_legacy_raw_provider_error_is_normalized_and_mapped(self):
# Legacy clients (use_new_llm_clients=False) raise raw provider exceptions
# -> except Exception -> as_uipath_error normalizes -> mapped.
raw = openai.PermissionDeniedError(
"Forbidden",
response=self._http_403(),
body={"status": 403, "detail": "need AGU"},
)
node = self._node_raising(raw)

with pytest.raises(AgentRuntimeError) as exc_info:
await node(self.state)

info = exc_info.value.error_info
assert info.status == 403
assert info.code.endswith(AgentRuntimeErrorCode.LICENSE_NOT_AVAILABLE.value)

@pytest.mark.asyncio
async def test_non_http_error_propagates_unchanged(self):
# No HTTP status -> as_uipath_error yields a non-UiPathAPIError -> re-raised.
node = self._node_raising(ValueError("boom"))

with pytest.raises(ValueError, match="boom"):
await node(self.state)
Loading
Loading