From 1ed43a4895fb04e7a57ead291163055ccdc48004 Mon Sep 17 00:00:00 2001 From: cotovanu-cristian Date: Thu, 25 Jun 2026 19:06:55 +0300 Subject: [PATCH] fix(errors): categorize provider unsupported-MIME rejection as User MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Format support for non-image attachments is delegated to the LLM/provider (#842) — do not reintroduce a client-side MIME allow-list. uipath-llm-client normalizes a provider's unsupported-attachment rejection to a UiPathError carrying UiPathLLMErrorCode.UNSUPPORTED_MIME_TYPE. llm_node normalizes the model-invoke error via as_uipath_error and translates that code into a User-categorized AgentRuntimeError(FILE_ERROR), covering both new clients (already normalized) and legacy clients (raw provider ValueError). build_file_content_blocks_for keeps the #842 pass-through behavior. Declare a direct uipath-llm-client>=1.16.2 dependency (the version that added UiPathLLMErrorCode) and bump uipath-langchain to 0.14.1. Co-Authored-By: Claude Opus 4.8 (1M context) --- pyproject.toml | 3 +- .../agent/exceptions/attachments.py | 40 +++++++++++++ .../agent/multimodal/invoke.py | 13 +++-- src/uipath_langchain/agent/react/llm_node.py | 5 +- tests/agent/multimodal/test_utils.py | 23 +++++++- tests/agent/test_exception_helpers.py | 58 +++++++++++++++++++ uv.lock | 10 ++-- 7 files changed, 139 insertions(+), 13 deletions(-) create mode 100644 src/uipath_langchain/agent/exceptions/attachments.py 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/attachments.py b/src/uipath_langchain/agent/exceptions/attachments.py new file mode 100644 index 000000000..9db918e28 --- /dev/null +++ b/src/uipath_langchain/agent/exceptions/attachments.py @@ -0,0 +1,40 @@ +"""Map a normalized unsupported-MIME rejection to a USER agent error.""" + +from collections.abc import Iterator + +from uipath.llm_client import UiPathError, UiPathLLMErrorCode +from uipath.runtime.errors import UiPathErrorCategory + +from uipath_langchain.agent.exceptions.exceptions import ( + AgentRuntimeError, + AgentRuntimeErrorCode, +) + + +def _iter_error_chain(exc: BaseException) -> Iterator[BaseException]: + seen: set[int] = set() + current: BaseException | None = exc + while current is not None and id(current) not in seen: + seen.add(id(current)) + yield current + current = current.__cause__ or current.__context__ + + +def raise_for_unsupported_attachment(exc: BaseException) -> None: + """Raise a USER ``FILE_ERROR`` if the error chain carries a normalized + ``UNSUPPORTED_MIME_TYPE``; no-op otherwise.""" + for err in _iter_error_chain(exc): + if ( + isinstance(err, UiPathError) + and err.error_code == UiPathLLMErrorCode.UNSUPPORTED_MIME_TYPE + ): + raise AgentRuntimeError( + code=AgentRuntimeErrorCode.FILE_ERROR, + title="Unsupported file attachment format.", + detail=( + "An attachment has a file type the model does not support. " + "Remove the attachment or convert it to a supported format." + + (f" Provider detail: {err.detail}" if err.detail else "") + ), + category=UiPathErrorCategory.USER, + ) from exc diff --git a/src/uipath_langchain/agent/multimodal/invoke.py b/src/uipath_langchain/agent/multimodal/invoke.py index 297356878..5c486898b 100644 --- a/src/uipath_langchain/agent/multimodal/invoke.py +++ b/src/uipath_langchain/agent/multimodal/invoke.py @@ -31,10 +31,12 @@ async def build_file_content_blocks_for( ) -> list[DataContentBlock]: """Build LangChain content blocks for a single file attachment. - Images become image blocks, TIFFs are split into per-page PNG blocks, - and every other MIME type — PDF, text, office documents, and any - arbitrary binary — is wrapped in a generic file block. Provider - compatibility for non-image formats is delegated to the LLM. + Images become image blocks, TIFFs are split into per-page PNG blocks, and + every other MIME type is wrapped in a generic file block. Format support for + non-image types is delegated to the LLM/provider (see #842): we do not gate + on a MIME allow-list here. A type the provider cannot read surfaces later as + a provider error at the model-invocation boundary, where it is translated + into a USER error. Args: file_info: File URL, name, and MIME type. @@ -53,6 +55,8 @@ async def build_file_content_blocks_for( except ValueError as exc: raise ValueError(f"File '{file_info.name}': {exc}") from exc + mime_type = file_info.mime_type or "application/octet-stream" + try: base64_file = await download_file_base64(file_info.url, max_size=max_size) except ValueError as exc: @@ -61,7 +65,6 @@ 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, diff --git a/src/uipath_langchain/agent/react/llm_node.py b/src/uipath_langchain/agent/react/llm_node.py index 4d945764d..e4c8e42c5 100644 --- a/src/uipath_langchain/agent/react/llm_node.py +++ b/src/uipath_langchain/agent/react/llm_node.py @@ -18,6 +18,7 @@ from uipath_langchain.chat.handlers import get_payload_handler from ..exceptions import AgentRuntimeError, AgentRuntimeErrorCode +from ..exceptions.attachments import raise_for_unsupported_attachment from ..exceptions.licensing import raise_for_provider_http_error from ..messages.message_utils import replace_tool_calls from ..tools.static_args import StaticArgsHandler @@ -125,9 +126,9 @@ async def llm_node(state: StateT): # 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. + # Legacy clients raise raw provider exceptions; normalize before mapping. uipath_error = as_uipath_error(e) + raise_for_unsupported_attachment(uipath_error) if isinstance(uipath_error, UiPathAPIError): raise_for_provider_http_error(uipath_error) raise 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/test_exception_helpers.py b/tests/agent/test_exception_helpers.py index 624ecb940..8bbcceb6d 100644 --- a/tests/agent/test_exception_helpers.py +++ b/tests/agent/test_exception_helpers.py @@ -138,3 +138,61 @@ def test_original_exception_chained(self) -> None: with pytest.raises(AgentRuntimeError) as exc_info: raise_for_enriched(err, _KNOWN_ERRORS, title=_TITLE, tool="T") assert exc_info.value.__cause__ is err + + +class TestRaiseForUnsupportedAttachment: + """Tests for raise_for_unsupported_attachment (normalized MIME code -> USER).""" + + def test_translates_direct_unsupported_mime_error(self) -> None: + from uipath.llm_client import UiPathError, UiPathLLMErrorCode + + from uipath_langchain.agent.exceptions.attachments import ( + raise_for_unsupported_attachment, + ) + + exc = UiPathError( + error_code=UiPathLLMErrorCode.UNSUPPORTED_MIME_TYPE, + detail="Unsupported MIME type: application/octet-stream.", + ) + + with pytest.raises(AgentRuntimeError) as exc_info: + raise_for_unsupported_attachment(exc) + + assert exc_info.value.error_info.category == UiPathErrorCategory.USER + assert exc_info.value.error_info.code == AgentRuntimeError.full_code( + AgentRuntimeErrorCode.FILE_ERROR + ) + + def test_translates_code_in_cause_chain(self) -> None: + from uipath.llm_client import UiPathError, UiPathLLMErrorCode + + from uipath_langchain.agent.exceptions.attachments import ( + raise_for_unsupported_attachment, + ) + + root = UiPathError( + error_code=UiPathLLMErrorCode.UNSUPPORTED_MIME_TYPE, + detail="Unsupported MIME type: application/x-foo", + ) + wrapper = RuntimeError("model invocation failed") + wrapper.__cause__ = root + + with pytest.raises(AgentRuntimeError) as exc_info: + raise_for_unsupported_attachment(wrapper) + + assert exc_info.value.error_info.category == UiPathErrorCategory.USER + + def test_noop_for_unrelated_or_unnormalized_exception(self) -> None: + from uipath.llm_client import UiPathError + + from uipath_langchain.agent.exceptions.attachments import ( + raise_for_unsupported_attachment, + ) + + raise_for_unsupported_attachment( + UiPathError(error_code="SOME_OTHER_CODE", detail="unrelated") + ) + raise_for_unsupported_attachment( + ValueError("Unsupported MIME type: application/x-foo") + ) + raise_for_unsupported_attachment(RuntimeError("network down")) 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]]