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.0"
version = "0.13.1"
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 @@ -23,7 +23,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.0,<1.15.0",
"uipath-langchain-client[openai]>=1.14.1,<1.15.0",
]

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

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

[project.entry-points."uipath.middlewares"]
Expand Down
76 changes: 19 additions & 57 deletions src/uipath_langchain/agent/exceptions/licensing.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
"""Convert LLM provider HTTP errors into structured AgentRuntimeErrors.

Each LLM provider wraps HTTP errors in a different exception type:
- OpenAI: openai.PermissionDeniedError → e.status_code
- Vertex: google.genai.errors.ClientError → e.code
- Bedrock: botocore.exceptions.ClientError → e.response dict

This module extracts the HTTP status code from any of these and re-raises
as an AgentRuntimeError so that upstream error handling (exception mapper,
CAS bridge) can categorise by status code without provider-specific logic.
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.
"""
Comment thread
vldcmp-uipath marked this conversation as resolved.

from uipath.runtime.errors import UiPathErrorCategory
Expand All @@ -16,39 +12,7 @@
AgentRuntimeError,
AgentRuntimeErrorCode,
)


def _extract_status_code(e: BaseException) -> int | None:
"""Extract HTTP status code from any provider-specific exception.

Supports OpenAI (status_code), Vertex/google.genai (code), and
Bedrock/botocore (response dict). Walks __cause__ chain to handle
LangChain wrapper exceptions (e.g. ChatGoogleGenerativeAIError).
"""
# OpenAI: e.status_code
sc = getattr(e, "status_code", None)
if isinstance(sc, int):
return sc

# Vertex (google.genai.errors.APIError): e.code
sc = getattr(e, "code", None)
if isinstance(sc, int):
return sc

# Bedrock (botocore.exceptions.ClientError): e.response dict
resp = getattr(e, "response", None)
if isinstance(resp, dict):
sc = resp.get("ResponseMetadata", {}).get("HTTPStatusCode")
if isinstance(sc, int):
return sc

# Walk __cause__ chain
cause = getattr(e, "__cause__", None)
if cause is not None and cause is not e:
return _extract_status_code(cause)

return None

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 @@ -60,27 +24,25 @@ def _extract_status_code(e: BaseException) -> int | None:
def raise_for_provider_http_error(e: BaseException) -> None:
"""Re-raise provider-specific HTTP errors as a structured AgentRuntimeError.

Extracts the HTTP status code from any LLM provider exception and
converts it to an AgentRuntimeError with the status code preserved.
Known status codes (e.g. 403) get a specific error code so upstream
handlers can match on the suffix. Does nothing if no HTTP status code
can be extracted.
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.
"""
sc = _extract_status_code(e)
if sc is None:
err = extract_provider_error(e)
if err.status_code is None:
return

code = _LLM_STATUS_CODE_MAP.get(sc, AgentRuntimeErrorCode.HTTP_ERROR)

if sc == 403:
category = UiPathErrorCategory.DEPLOYMENT
else:
category = UiPathErrorCategory.UNKNOWN
code = _LLM_STATUS_CODE_MAP.get(err.status_code, AgentRuntimeErrorCode.HTTP_ERROR)
category = (
UiPathErrorCategory.DEPLOYMENT
if err.status_code == 403
else UiPathErrorCategory.UNKNOWN
)

raise AgentRuntimeError(
code=code,
title=f"LLM provider returned HTTP {sc}",
detail=str(e),
title=f"LLM provider returned HTTP {err.status_code}",
detail=err.detail or str(e),
category=category,
status=sc,
status=err.status_code,
) from e
95 changes: 95 additions & 0 deletions src/uipath_langchain/chat/provider_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""Normalize LLM provider HTTP errors into a common shape.

Providers behind the LLM Gateway each raise a different exception type, but all

@radu-mocanu radu-mocanu Jun 23, 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.

should uipath-langchain know about llm gateway?
I think this should be the concern of the upstream clients lib

carry the same gateway body (``{status, detail, ...}``); only the attribute that
holds it differs. LangChain may wrap the SDK error, so the useful fields can sit
a few links down the ``__cause__`` chain — always together, on one link.
"""

from dataclasses import dataclass


@dataclass
class ProviderError:
"""Normalized provider HTTP error: status code + user-facing detail."""

status_code: int | None = None
detail: str | None = None

def __bool__(self) -> bool:
"""Truthy once we have a status code — the signal that a provider matched."""
return self.status_code is not None


def _int(value: object) -> int | None:
"""The value if it is an int (a real HTTP status), else None.

Guards against matching unrelated exceptions that happen to carry a
``code``/``status_code`` attribute that isn't an HTTP status.
"""
return value if isinstance(value, int) else None
Comment thread
vldcmp-uipath marked this conversation as resolved.


def _detail(body: object) -> str | None:
"""The gateway ``detail`` message from a parsed body dict, if present."""
if isinstance(body, dict):
return body.get("detail")

return None
Comment thread
vldcmp-uipath marked this conversation as resolved.


# One extractor per provider: read that SDK's status code and detail out of the exception, if present
# Returns an empty (falsy) ProviderError when ``e`` isn't that provider's error type


def _from_openai(e: BaseException) -> ProviderError:
"""OpenAI / Anthropic: ``e.status_code`` + ``e.body``."""
return ProviderError(
_int(getattr(e, "status_code", None)), _detail(getattr(e, "body", None))
)


def _from_vertex(e: BaseException) -> ProviderError:
"""Vertex / google.genai ``APIError``."""
return ProviderError(
_int(getattr(e, "code", None)), _detail(getattr(e, "details", None))
)


def _from_bedrock(e: BaseException) -> ProviderError:
"""Bedrock — same ``e.status_code`` + ``e.body`` shape as OpenAI.

Bedrock requests go through the uipath-client ``WrappedBotoClient`` shim
rather than boto3. On a gateway HTTP error its ``raise_for_status`` raises a
``UiPathPermissionDeniedError`` (a ``UiPathAPIError`` / ``httpx.HTTPStatusError``
subclass) that exposes the OpenAI-style ``.status_code`` and ``.body``.
"""
return ProviderError(
_int(getattr(e, "status_code", None)), _detail(getattr(e, "body", None))
)


def _from_botocore(e: BaseException) -> ProviderError:
"""Bedrock via legacy direct boto3 (``use_new_llm_clients=False``): a
``botocore.exceptions.ClientError`` carrying everything in ``e.response``."""
resp = getattr(e, "response", None)
if not isinstance(resp, dict):
return ProviderError()
return ProviderError(
_int(resp.get("ResponseMetadata", {}).get("HTTPStatusCode")),
_detail(resp.get("Error")),
)


_PROVIDERS = (_from_openai, _from_vertex, _from_bedrock, _from_botocore)


def extract_provider_error(e: BaseException | None) -> ProviderError:
"""Return the first provider that matches ``e`` or any of its ``__cause__`` links."""
if e is None:
return ProviderError()
for extract in _PROVIDERS:
error = extract(e)
if error:
return error
return extract_provider_error(e.__cause__)
Comment thread
vldcmp-uipath marked this conversation as resolved.
127 changes: 127 additions & 0 deletions tests/chat/test_provider_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
"""Tests for normalizing provider HTTP errors into a common shape.

Each provider behind the LLM Gateway raises a different exception type, but all
carry the same gateway body (``{status, detail, ...}``). ``extract_provider_error``
reads the status code + detail off whichever attribute the SDK exposes, walking
the ``__cause__`` chain when LangChain wraps the SDK error.
"""

import pytest
from uipath.runtime.errors import UiPathErrorCategory

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

_DETAIL = "License not available for LLM usage. You need additional 'AGU'."
_BODY = {
"title": "License not available",
"status": 403,
"detail": _DETAIL,
}


class TestExtractProviderError:
def test_openai_status_code_and_body(self) -> None:
class OpenAIError(Exception):
status_code = 403
body = _BODY

result = extract_provider_error(OpenAIError("Forbidden"))
assert result == ProviderError(status_code=403, detail=_DETAIL)

def test_bedrock_uipath_api_error_same_shape_as_openai(self) -> None:
# Bedrock via WrappedBotoClient surfaces as a UiPathAPIError (httpx
# subclass) exposing OpenAI-style .status_code / .body.
class UiPathPermissionDeniedError(Exception):
status_code = 403
body = _BODY

result = extract_provider_error(UiPathPermissionDeniedError("Forbidden"))
assert result == ProviderError(status_code=403, detail=_DETAIL)

def test_vertex_wrapped_in_langchain_error(self) -> None:
# google.genai exposes .code + .details; LangChain wraps it in a class
# that itself exposes nothing, so the fields live on the __cause__.
class GenAIError(Exception):
code = 403
details = _BODY

class ChatGoogleGenerativeAIError(Exception):
pass

try:
try:
raise GenAIError("403")
except GenAIError as cause:
raise ChatGoogleGenerativeAIError("wrapped") from cause
except ChatGoogleGenerativeAIError as wrapper:
result = extract_provider_error(wrapper)

assert result == ProviderError(status_code=403, detail=_DETAIL)

def test_botocore_response_dict(self) -> None:
# Legacy direct boto3 path: botocore.ClientError carries a response dict.
class ClientError(Exception):
response = {
"ResponseMetadata": {"HTTPStatusCode": 403},
"Error": {"Code": "AccessDenied", "detail": _BODY["detail"]},
}

result = extract_provider_error(ClientError("denied"))
assert result.status_code == 403

def test_none_returns_empty(self) -> None:
result = extract_provider_error(None)
assert result == ProviderError()
assert not result

def test_non_int_status_attribute_is_ignored(self) -> None:
# An unrelated exception that happens to carry a string `code` must not
# be mistaken for a provider HTTP error.
class OSLike(Exception):
code = "ENOENT"

assert extract_provider_error(OSLike("nope")) == ProviderError()


class TestRaiseForProviderHttpError:
def test_403_maps_to_license_not_available(self) -> None:
class OpenAIError(Exception):
status_code = 403
body = _BODY

with pytest.raises(AgentRuntimeError) as exc_info:
raise_for_provider_http_error(OpenAIError("Forbidden"))

info = exc_info.value.error_info
assert info.status == 403
assert info.category == UiPathErrorCategory.DEPLOYMENT
assert info.code.endswith(AgentRuntimeErrorCode.LICENSE_NOT_AVAILABLE.value)
assert _DETAIL in info.detail

def test_other_status_falls_back_to_http_error_and_str(self) -> None:
# Non-403 status, and no `detail` in the body → detail falls back to str(e).
class OpenAIError(Exception):
status_code = 500
body: dict[str, str] = {}

with pytest.raises(AgentRuntimeError) as exc_info:
raise_for_provider_http_error(OpenAIError("boom"))

info = exc_info.value.error_info
assert info.status == 500
assert info.category == UiPathErrorCategory.UNKNOWN
assert info.code.endswith(AgentRuntimeErrorCode.HTTP_ERROR.value)
assert "boom" in info.detail # str(e) fallback

def test_no_status_does_not_raise(self) -> None:
# No extractable HTTP status → no-op (the original exception is left to
# propagate from the caller). Reaching the end without raising is the assert.
raise_for_provider_http_error(ValueError("unrelated transport error"))
Loading
Loading