diff --git a/pyproject.toml b/pyproject.toml index b1bc33f15..c9e97c548 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.14.0" +version = "0.14.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" @@ -9,6 +9,7 @@ dependencies = [ "uipath-core>=0.5.28, <0.6.0", "uipath-platform>=0.2.0, <0.3.0", "uipath-runtime>=0.12.1, <0.13.0", + "uipath-llm-client>=1.16.2, <1.17.0", "langgraph>=1.1.8, <2.0.0", "langchain-core>=1.2.27, <2.0.0", "langgraph-checkpoint-sqlite>=3.0.3, <4.0.0", diff --git a/src/uipath_langchain/agent/exceptions/llm.py b/src/uipath_langchain/agent/exceptions/llm.py new file mode 100644 index 000000000..3a1c95453 --- /dev/null +++ b/src/uipath_langchain/agent/exceptions/llm.py @@ -0,0 +1,24 @@ +"""Map normalized LLM-client errors into agent runtime errors.""" + +from uipath.llm_client import UiPathError, UiPathLLMErrorCode +from uipath.runtime.errors import UiPathErrorCategory + +from uipath_langchain.agent.exceptions.exceptions import ( + AgentRuntimeError, + AgentRuntimeErrorCode, +) + + +def raise_for_llm_client_error(error: UiPathError) -> None: + """Raise a structured agent error for known LLM-client error codes.""" + if error.error_code == UiPathLLMErrorCode.UNSUPPORTED_MIME_TYPE: + raise AgentRuntimeError( + code=AgentRuntimeErrorCode.FILE_ERROR, + title="Unsupported file attachment format.", + detail=( + "The model does not support this attachment's file type. " + "Remove the attachment or convert it to a supported format." + + (f" Provider detail: {error.detail}" if error.detail else "") + ), + category=UiPathErrorCategory.USER, + ) from error diff --git a/src/uipath_langchain/agent/multimodal/invoke.py b/src/uipath_langchain/agent/multimodal/invoke.py index 297356878..fb979d777 100644 --- a/src/uipath_langchain/agent/multimodal/invoke.py +++ b/src/uipath_langchain/agent/multimodal/invoke.py @@ -61,11 +61,10 @@ async def build_file_content_blocks_for( if is_image(file_info.mime_type): return [create_image_block(base64=base64_file, mime_type=file_info.mime_type)] - mime_type = file_info.mime_type or "application/octet-stream" return [ create_file_block( base64=base64_file, - mime_type=mime_type, + mime_type=file_info.mime_type or "application/octet-stream", filename=sanitize_filename(file_info.name), ) ] diff --git a/src/uipath_langchain/agent/react/llm_node.py b/src/uipath_langchain/agent/react/llm_node.py index 4d945764d..7a7a8fe24 100644 --- a/src/uipath_langchain/agent/react/llm_node.py +++ b/src/uipath_langchain/agent/react/llm_node.py @@ -11,7 +11,7 @@ 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 import UiPathAPIError, UiPathError from uipath.llm_client.utils.exceptions import as_uipath_error from uipath.runtime.errors import UiPathErrorCategory @@ -19,6 +19,7 @@ from ..exceptions import AgentRuntimeError, AgentRuntimeErrorCode from ..exceptions.licensing import raise_for_provider_http_error +from ..exceptions.llm import raise_for_llm_client_error from ..messages.message_utils import replace_tool_calls from ..tools.static_args import StaticArgsHandler from .constants import ( @@ -124,6 +125,9 @@ async def llm_node(state: StateT): except UiPathAPIError as e: # New LLM clients surface provider HTTP errors as a normalized UiPathAPIError directly. raise_for_provider_http_error(e) + except UiPathError as e: + raise_for_llm_client_error(e) + raise 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. diff --git a/tests/agent/multimodal/test_utils.py b/tests/agent/multimodal/test_utils.py index b79f3309e..b8174f2d3 100644 --- a/tests/agent/multimodal/test_utils.py +++ b/tests/agent/multimodal/test_utils.py @@ -231,10 +231,31 @@ async def test_arbitrary_mime_type_returns_file_block( assert blocks[0]["type"] == "file" assert blocks[0]["mime_type"] == "text/csv" + async def test_unsupported_mime_type_returns_file_block( + self, httpx_mock: HTTPXMock + ) -> None: + """Format support is delegated to the LLM/provider (#842): an arbitrary + MIME type is wrapped in a file block here, not rejected. A provider that + cannot read it raises at the model-invocation boundary, where it is + translated into a USER error.""" + content = b"\x00\x01\x02" + httpx_mock.add_response(url=FILE_URL, content=content) + file_info = FileInfo( + url=FILE_URL, name="blob.bin", mime_type="application/octet-stream" + ) + + blocks = await build_file_content_blocks_for(file_info) + + assert len(blocks) == 1 + assert blocks[0]["type"] == "file" + assert blocks[0]["mime_type"] == "application/octet-stream" + async def test_empty_mime_type_defaults_to_octet_stream( self, httpx_mock: HTTPXMock ) -> None: - content = b"raw bytes" + """An attachment with no MIME type defaults to octet-stream and is passed + through as a file block (delegated to the LLM).""" + content = b"data" httpx_mock.add_response(url=FILE_URL, content=content) file_info = FileInfo(url=FILE_URL, name="blob.bin", mime_type="") diff --git a/tests/agent/react/test_llm_node.py b/tests/agent/react/test_llm_node.py index 338b86a44..12eb5967d 100644 --- a/tests/agent/react/test_llm_node.py +++ b/tests/agent/react/test_llm_node.py @@ -12,7 +12,8 @@ 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.llm_client import UiPathAPIError, UiPathError, UiPathLLMErrorCode +from uipath.runtime.errors import UiPathErrorCategory from uipath_langchain.agent.exceptions.exceptions import ( AgentRuntimeError, @@ -406,6 +407,33 @@ async def test_legacy_raw_provider_error_is_normalized_and_mapped(self): assert info.status == 403 assert info.code.endswith(AgentRuntimeErrorCode.LICENSE_NOT_AVAILABLE.value) + @pytest.mark.asyncio + async def test_new_client_unsupported_mime_error_maps_to_file_error(self): + node = self._node_raising( + UiPathError( + error_code=UiPathLLMErrorCode.UNSUPPORTED_MIME_TYPE, + detail="Unsupported MIME type: application/x-foo", + ) + ) + + with pytest.raises(AgentRuntimeError) as exc_info: + await node(self.state) + + info = exc_info.value.error_info + assert info.category == UiPathErrorCategory.USER + assert info.code.endswith(AgentRuntimeErrorCode.FILE_ERROR.value) + assert "application/x-foo" in info.detail + + @pytest.mark.asyncio + async def test_unmapped_uipath_error_propagates_unchanged(self): + err = UiPathError(error_code="SOME_OTHER_CODE", detail="unrelated") + node = self._node_raising(err) + + with pytest.raises(UiPathError) as exc_info: + await node(self.state) + + assert exc_info.value is err + @pytest.mark.asyncio async def test_non_http_error_propagates_unchanged(self): # No HTTP status -> as_uipath_error yields a non-UiPathAPIError -> re-raised. diff --git a/uv.lock b/uv.lock index 52bf943dc..5e0c046d3 100644 --- a/uv.lock +++ b/uv.lock @@ -4477,7 +4477,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.14.0" +version = "0.14.1" source = { editable = "." } dependencies = [ { name = "a2a-sdk" }, @@ -4498,6 +4498,7 @@ dependencies = [ { name = "uipath" }, { name = "uipath-core" }, { name = "uipath-langchain-client", extra = ["openai"] }, + { name = "uipath-llm-client" }, { name = "uipath-platform" }, { name = "uipath-runtime" }, ] @@ -4563,6 +4564,7 @@ requires-dist = [ { name = "uipath-langchain-client", extras = ["google"], marker = "extra == 'vertex'", specifier = ">=1.16.1,<1.17.0" }, { name = "uipath-langchain-client", extras = ["openai"], specifier = ">=1.16.1,<1.17.0" }, { name = "uipath-langchain-client", extras = ["vertexai"], marker = "extra == 'vertex'", specifier = ">=1.16.1,<1.17.0" }, + { name = "uipath-llm-client", specifier = ">=1.16.2,<1.17.0" }, { name = "uipath-platform", specifier = ">=0.2.0,<0.3.0" }, { name = "uipath-runtime", specifier = ">=0.12.1,<0.13.0" }, ] @@ -4631,7 +4633,7 @@ vertexai = [ [[package]] name = "uipath-llm-client" -version = "1.16.0" +version = "1.16.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -4640,9 +4642,9 @@ dependencies = [ { name = "tenacity" }, { name = "uipath-platform" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8e/4c/8ee6787ba632fcfe50f5928e50964320c9f4d64ec6df3bd1451e896a8aa2/uipath_llm_client-1.16.0.tar.gz", hash = "sha256:d81605938394fbc12c3130ecd98abd85d3cc4cf317bca65a41a8b8fadb7e1d74", size = 12512889, upload-time = "2026-07-03T12:10:32.681Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/aa/787e092ee901c763887e4ce2d68d2f7575e689db987b493242589a52f1ac/uipath_llm_client-1.16.2.tar.gz", hash = "sha256:6911d5b31552ec32100c036d6c902f9ca938f6142020230d3f88b78c0ca5fc23", size = 12516632, upload-time = "2026-07-06T15:42:50.911Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/03/c5a988b0a869d6207b1bb693c4674b92fdefb3771f5eb238b1b60cd28b02/uipath_llm_client-1.16.0-py3-none-any.whl", hash = "sha256:fd586a6c05ff6876e33773c915bb87cd643110bf9ff0936e3bb228e1167274bb", size = 67958, upload-time = "2026-07-03T12:10:31Z" }, + { url = "https://files.pythonhosted.org/packages/63/c7/9ab74b89a990751210e7ae54735dd29472b00748a8fea2bd63ca885afd0a/uipath_llm_client-1.16.2-py3-none-any.whl", hash = "sha256:c9c1f964d6508b60d2fd0e45402154a5099c661e03b525773db80b16fd888993", size = 68729, upload-time = "2026-07-06T15:42:49.379Z" }, ] [[package]]