Skip to content

Commit 73a8614

Browse files
feat: add tiff file support (#741)
1 parent 45f9a55 commit 73a8614

8 files changed

Lines changed: 327 additions & 48 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-langchain"
3-
version = "0.9.17"
3+
version = "0.9.18"
44
description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"
@@ -22,6 +22,7 @@ dependencies = [
2222
"jsonpath-ng>=1.7.0",
2323
"mcp==1.26.0",
2424
"langchain-mcp-adapters==0.2.1",
25+
"pillow>=12.1.1",
2526
]
2627

2728
classifiers = [
Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,27 @@
11
"""Multimodal LLM input handling (images, PDFs, etc.)."""
22

33
from .invoke import (
4-
build_file_content_block,
4+
build_file_content_blocks_for,
55
llm_call_with_files,
66
)
7-
from .types import IMAGE_MIME_TYPES, FileInfo
8-
from .utils import download_file_base64, is_image, is_pdf, sanitize_filename
7+
from .types import IMAGE_MIME_TYPES, TIFF_MIME_TYPES, FileInfo
8+
from .utils import (
9+
download_file_base64,
10+
is_image,
11+
is_pdf,
12+
is_tiff,
13+
sanitize_filename,
14+
)
915

1016
__all__ = [
1117
"FileInfo",
1218
"IMAGE_MIME_TYPES",
13-
"build_file_content_block",
19+
"TIFF_MIME_TYPES",
20+
"build_file_content_blocks_for",
1421
"download_file_base64",
1522
"is_image",
1623
"is_pdf",
24+
"is_tiff",
1725
"llm_call_with_files",
1826
"sanitize_filename",
1927
]

src/uipath_langchain/agent/multimodal/invoke.py

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,47 +13,61 @@
1313
from langchain_core.messages.content import create_file_block, create_image_block
1414

1515
from .types import MAX_FILE_SIZE_BYTES, FileInfo
16-
from .utils import download_file_base64, is_image, is_pdf, sanitize_filename
16+
from .utils import (
17+
download_file_base64,
18+
is_image,
19+
is_pdf,
20+
is_tiff,
21+
sanitize_filename,
22+
stream_tiff_to_content_blocks,
23+
)
1724

1825
logger = logging.getLogger("uipath")
1926

2027

21-
async def build_file_content_block(
28+
async def build_file_content_blocks_for(
2229
file_info: FileInfo,
2330
*,
2431
max_size: int = MAX_FILE_SIZE_BYTES,
25-
) -> DataContentBlock:
26-
"""Build a LangChain content block for a file attachment.
32+
) -> list[DataContentBlock]:
33+
"""Build LangChain content blocks for a single file attachment.
2734
28-
Downloads the file with size enforcement and creates the content block.
29-
Size validation happens during download (via Content-Length check and
30-
streaming guard) to avoid loading oversized files into memory.
35+
Handles all supported MIME types in one place: images, PDFs, and
36+
TIFFs (multi-page, converted to individual PNG blocks).
3137
3238
Args:
3339
file_info: File URL, name, and MIME type.
3440
max_size: Maximum allowed raw file size in bytes. LLM providers
3541
enforce payload limits; base64 encoding adds ~30% overhead.
3642
3743
Returns:
38-
A DataContentBlock for the file (image or PDF).
44+
A list of DataContentBlock instances for the file.
3945
4046
Raises:
4147
ValueError: If the MIME type is not supported or the file exceeds
4248
the size limit for LLM payloads.
4349
"""
50+
if is_tiff(file_info.mime_type):
51+
try:
52+
return await stream_tiff_to_content_blocks(file_info.url, max_size=max_size)
53+
except ValueError as exc:
54+
raise ValueError(f"File '{file_info.name}': {exc}") from exc
55+
4456
try:
4557
base64_file = await download_file_base64(file_info.url, max_size=max_size)
4658
except ValueError as exc:
4759
raise ValueError(f"File '{file_info.name}': {exc}") from exc
4860

4961
if is_image(file_info.mime_type):
50-
return create_image_block(base64=base64_file, mime_type=file_info.mime_type)
62+
return [create_image_block(base64=base64_file, mime_type=file_info.mime_type)]
5163
if is_pdf(file_info.mime_type):
52-
return create_file_block(
53-
base64=base64_file,
54-
mime_type=file_info.mime_type,
55-
filename=sanitize_filename(file_info.name),
56-
)
64+
return [
65+
create_file_block(
66+
base64=base64_file,
67+
mime_type=file_info.mime_type,
68+
filename=sanitize_filename(file_info.name),
69+
)
70+
]
5771

5872
raise ValueError(f"Unsupported mime_type={file_info.mime_type}")
5973

@@ -75,8 +89,8 @@ async def build_file_content_blocks(files: list[FileInfo]) -> list[DataContentBl
7589

7690
file_content_blocks: list[DataContentBlock] = []
7791
for file in files:
78-
block = await build_file_content_block(file)
79-
file_content_blocks.append(block)
92+
blocks = await build_file_content_blocks_for(file)
93+
file_content_blocks.extend(blocks)
8094
return file_content_blocks
8195

8296

@@ -111,8 +125,8 @@ async def llm_call_with_files(
111125

112126
content_blocks: list[Any] = []
113127
for file_info in files:
114-
content_block = await build_file_content_block(file_info)
115-
content_blocks.append(content_block)
128+
blocks = await build_file_content_blocks_for(file_info)
129+
content_blocks.extend(blocks)
116130

117131
file_message = HumanMessage(content_blocks=content_blocks)
118132
all_messages = list(messages) + [file_message]

src/uipath_langchain/agent/multimodal/types.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@
1111
"image/webp",
1212
}
1313

14+
TIFF_MIME_TYPES: set[str] = {
15+
"image/tiff",
16+
"image/x-tiff",
17+
}
18+
1419

1520
@dataclass
1621
class FileInfo:

src/uipath_langchain/agent/multimodal/utils.py

Lines changed: 83 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
"""Utility functions for multimodal file handling."""
22

33
import base64
4+
import io
45
import re
56
from collections.abc import AsyncIterator
7+
from contextlib import asynccontextmanager
68

79
import httpx
10+
from langchain_core.messages import DataContentBlock
811
from uipath._utils._ssl_context import get_httpx_client_kwargs
912

10-
from .types import IMAGE_MIME_TYPES
13+
from .types import IMAGE_MIME_TYPES, TIFF_MIME_TYPES
1114

1215

1316
def sanitize_filename(filename: str) -> str:
@@ -37,6 +40,11 @@ def is_image(mime_type: str) -> bool:
3740
return mime_type.lower() in IMAGE_MIME_TYPES
3841

3942

43+
def is_tiff(mime_type: str) -> bool:
44+
"""Check if the MIME type represents a TIFF image."""
45+
return mime_type.lower() in TIFF_MIME_TYPES
46+
47+
4048
def _format_mb(size_bytes: int, decimals: int = 1) -> str:
4149
"""Format a byte count as MB.
4250
@@ -97,22 +105,28 @@ async def encode_streamed_base64(
97105
return result
98106

99107

100-
async def download_file_base64(url: str, *, max_size: int = 0) -> str:
101-
"""Download a file from a URL and return its content as a base64 string.
108+
@asynccontextmanager
109+
async def _stream_download(url: str, *, max_size: int = 0):
110+
"""Stream an HTTP download with size enforcement.
111+
112+
Yields the validated response object. Checks Content-Length upfront
113+
and raises ValueError if the file is known to exceed the limit.
102114
103115
Args:
104116
url: The URL to download from.
105117
max_size: Maximum allowed file size in bytes. 0 means unlimited.
106118
119+
Yields:
120+
The httpx response object, ready for streaming via aiter_bytes().
121+
107122
Raises:
108-
ValueError: If the file exceeds max_size.
123+
ValueError: If the file exceeds max_size (Content-Length check).
109124
httpx.HTTPStatusError: If the HTTP request fails.
110125
"""
111126
async with httpx.AsyncClient(**get_httpx_client_kwargs()) as client:
112127
async with client.stream("GET", url) as response:
113128
response.raise_for_status()
114129

115-
# Fast reject via Content-Length before reading the body
116130
if max_size > 0:
117131
content_length = response.headers.get("content-length")
118132
if content_length:
@@ -130,6 +144,67 @@ async def download_file_base64(url: str, *, max_size: int = 0) -> str:
130144
f" limit for Agent LLM payloads"
131145
)
132146

133-
return await encode_streamed_base64(
134-
response.aiter_bytes(), max_size=max_size
135-
)
147+
yield response
148+
149+
150+
async def stream_tiff_to_content_blocks(
151+
url: str, *, max_size: int = 0
152+
) -> list[DataContentBlock]:
153+
"""Download a TIFF via streaming and convert each page to a content block.
154+
155+
Streams the HTTP response directly into a buffer for PIL, enforcing
156+
size limits as chunks arrive. Each TIFF page is converted to PNG,
157+
base64-encoded, and wrapped in a DataContentBlock immediately so
158+
the raw PNG bytes can be freed.
159+
160+
Args:
161+
url: The URL to download from.
162+
max_size: Maximum allowed file size in bytes. 0 means unlimited.
163+
164+
Returns:
165+
A list of DataContentBlock instances, one per TIFF page.
166+
167+
Raises:
168+
ValueError: If the file exceeds max_size.
169+
httpx.HTTPStatusError: If the HTTP request fails.
170+
"""
171+
from langchain_core.messages.content import create_image_block
172+
from PIL import Image, ImageSequence
173+
174+
async with _stream_download(url, max_size=max_size) as response:
175+
buf = io.BytesIO()
176+
total = 0
177+
async for chunk in response.aiter_bytes():
178+
total += len(chunk)
179+
if max_size > 0 and total > max_size:
180+
raise ValueError(
181+
f"File exceeds the {_format_mb(max_size, decimals=0)}"
182+
f" limit for LLM payloads"
183+
f" (downloaded {_format_mb(total)} so far)"
184+
)
185+
buf.write(chunk)
186+
187+
buf.seek(0)
188+
blocks: list[DataContentBlock] = []
189+
with Image.open(buf) as img:
190+
for frame in ImageSequence.Iterator(img):
191+
png_buf = io.BytesIO()
192+
frame.convert("RGBA").save(png_buf, format="PNG")
193+
png_b64 = base64.b64encode(png_buf.getvalue()).decode("ascii")
194+
blocks.append(create_image_block(base64=png_b64, mime_type="image/png"))
195+
return blocks
196+
197+
198+
async def download_file_base64(url: str, *, max_size: int = 0) -> str:
199+
"""Download a file from a URL and return its content as a base64 string.
200+
201+
Args:
202+
url: The URL to download from.
203+
max_size: Maximum allowed file size in bytes. 0 means unlimited.
204+
205+
Raises:
206+
ValueError: If the file exceeds max_size.
207+
httpx.HTTPStatusError: If the HTTP request fails.
208+
"""
209+
async with _stream_download(url, max_size=max_size) as response:
210+
return await encode_streamed_base64(response.aiter_bytes(), max_size=max_size)

src/uipath_langchain/agent/tools/internal_tools/analyze_files_tool.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@
2424
AgentRuntimeError,
2525
AgentRuntimeErrorCode,
2626
)
27-
from uipath_langchain.agent.multimodal import FileInfo, build_file_content_block
27+
from uipath_langchain.agent.multimodal import (
28+
FileInfo,
29+
build_file_content_blocks_for,
30+
)
2831
from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model
2932
from uipath_langchain.agent.tools.structured_tool_with_argument_properties import (
3033
StructuredToolWithArgumentProperties,
@@ -182,8 +185,8 @@ async def add_files_to_message(
182185

183186
file_content_blocks: list[DataContentBlock] = []
184187
for file in files:
185-
block = await build_file_content_block(file)
186-
file_content_blocks.append(block)
188+
blocks = await build_file_content_blocks_for(file)
189+
file_content_blocks.extend(blocks)
187190
return append_content_blocks_to_message(
188191
message, cast(list[ContentBlock], file_content_blocks)
189192
)

0 commit comments

Comments
 (0)