Skip to content

Commit e345310

Browse files
cosminachoclaude
andcommitted
test(langchain): add file-input integration tests across providers
Adds a parameterized matrix that exercises every provider's chat model against the 11 most common file formats (txt, md, csv, html, pdf, docx, xlsx, png, jpg, gif, webp) via structured output. Each fixture embeds a known invoice payload so the assertion verifies the model actually read the file rather than hallucinating. Refactors the per-provider integration suites onto a shared `UiPathChatModelIntegrationTests` base that consolidates the setup fixture, `chat_model_class`/`params`, `supports_*` defaults, the `test_stream`/`test_astream` overrides, and the parallel-tool-calling matrix. Providers only carry their `skip_on_specific_configs` rules and override `_bind_parallel_and_sequential` when they need the Anthropic dialect. Fixture files are generated once and committed under `tests/langchain/fixtures/files/`; `tests/langchain/file_fixtures.py` keeps the generators (run `python -m tests.langchain.file_fixtures` to refresh). Tests load the committed bytes via `load_fixture(fmt)`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 737893c commit e345310

23 files changed

Lines changed: 826 additions & 684 deletions

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ dev = [
4545
"uipath-llm-client[all]",
4646
"uipath_langchain_client[all]",
4747
"openinference-instrumentation-langchain>=0.1.63,<1.0.0",
48+
"python-docx>=1.1.2,<2.0.0",
49+
"openpyxl>=3.1.5,<4.0.0",
50+
"reportlab>=4.2.5,<5.0.0",
51+
"Pillow>=12.0.0,<13.0.0",
4852
]
4953

5054
[tool.uv]

tests/cassettes.db

0 Bytes
Binary file not shown.

tests/langchain/clients/anthropic/test_integration.py

Lines changed: 33 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -3,58 +3,33 @@
33
Tests UiPathChatAnthropic with both vertexai and awsbedrock vendor_types.
44
"""
55

6+
from collections.abc import Iterable
67
from typing import Any
78

89
import pytest
910
from langchain_core.language_models.chat_models import BaseChatModel
10-
from langchain_core.messages import AIMessageChunk
11-
from langchain_tests.integration_tests import ChatModelIntegrationTests
11+
from langchain_core.runnables import Runnable
1212

13-
from tests.langchain.utils import search_accommodation, search_attractions, search_flights
13+
from tests.langchain.file_fixtures import IMAGE_FORMATS, PDF_FORMATS
14+
from tests.langchain.integration_tests import UiPathChatModelIntegrationTests
1415

1516

1617
@pytest.mark.asyncio
1718
@pytest.mark.vcr
18-
class TestAnthropicIntegrationChatModel(ChatModelIntegrationTests):
19-
@pytest.fixture(autouse=True)
20-
def setup_models(self, completions_config: tuple[type[BaseChatModel], dict[str, Any]]):
21-
self._completions_class, self.completions_kwargs = completions_config
22-
23-
@property
24-
def supports_image_inputs(self) -> bool:
25-
return True
26-
27-
@property
28-
def supports_image_tool_message(self) -> bool:
29-
return True
30-
31-
@property
32-
def supports_image_urls(self) -> bool:
33-
return True
34-
35-
@property
36-
def supports_pdf_inputs(self) -> bool:
37-
return True
38-
39-
@property
40-
def supports_pdf_tool_message(self) -> bool:
41-
return True
42-
19+
class TestAnthropicIntegrationChatModel(UiPathChatModelIntegrationTests):
4320
@pytest.fixture(autouse=True)
4421
def skip_on_specific_configs(
4522
self,
4623
request: pytest.FixtureRequest,
4724
completions_config: tuple[type[BaseChatModel], dict[str, Any]],
4825
) -> None:
49-
model_class, model_kwargs = completions_config
26+
_, model_kwargs = completions_config
5027
model_name = model_kwargs.get("model", "")
5128
test_name = request.node.originalname
5229
has_thinking = "thinking" in model_kwargs
5330
is_vertex = model_kwargs.get("vendor_type") == "vertexai" or "@" in model_name
54-
55-
# Useless framework tests
56-
if test_name in ["test_no_overrides_DO_NOT_OVERRIDE", "test_unicode_tool_call_integration"]:
57-
pytest.skip(f"Skipping {test_name}: not relevant")
31+
callspec = getattr(request.node, "callspec", None)
32+
fmt = callspec.params.get("fmt") if callspec else None
5833

5934
# Claude via Vertex AI: streaming bugged (502 / empty content)
6035
if is_vertex and test_name in [
@@ -111,102 +86,29 @@ def skip_on_specific_configs(
11186
]:
11287
pytest.skip(f"Skipping {test_name}: URL image sources not supported via gateway")
11388

114-
@property
115-
def chat_model_class(self) -> type[BaseChatModel]:
116-
return self._completions_class
117-
118-
@property
119-
def chat_model_params(self) -> dict[str, Any]:
120-
return self.completions_kwargs
121-
122-
@pytest.mark.parametrize("model", [{}, {"output_version": "v1"}], indirect=True)
123-
def test_stream(self, model: BaseChatModel) -> None:
124-
chunks: list[AIMessageChunk] = []
125-
full: AIMessageChunk | None = None
126-
for chunk in model.stream("Hello"):
127-
assert chunk is not None
128-
assert isinstance(chunk, AIMessageChunk)
129-
assert isinstance(chunk.content, str | list)
130-
chunks.append(chunk)
131-
full = chunk if full is None else full + chunk
132-
assert len(chunks) > 0
133-
assert isinstance(full, AIMessageChunk)
134-
assert full.content
135-
text_blocks = [block for block in full.content_blocks if block["type"] == "text"]
136-
assert len(text_blocks) == 1
137-
138-
last_chunk = chunks[-1]
139-
assert last_chunk.chunk_position == "last", (
140-
f"Final chunk must have chunk_position='last', got {last_chunk.chunk_position!r}"
141-
)
142-
143-
@pytest.mark.parametrize("model", [{}, {"output_version": "v1"}], indirect=True)
144-
async def test_astream(self, model: BaseChatModel) -> None:
145-
chunks: list[AIMessageChunk] = []
146-
full: AIMessageChunk | None = None
147-
async for chunk in model.astream("Hello"):
148-
assert chunk is not None
149-
assert isinstance(chunk, AIMessageChunk)
150-
assert isinstance(chunk.content, str | list)
151-
chunks.append(chunk)
152-
full = chunk if full is None else full + chunk
153-
assert len(chunks) > 0
154-
assert isinstance(full, AIMessageChunk)
155-
assert full.content
156-
text_blocks = [block for block in full.content_blocks if block["type"] == "text"]
157-
assert len(text_blocks) == 1
158-
159-
last_chunk = chunks[-1]
160-
assert last_chunk.chunk_position == "last", (
161-
f"Final chunk must have chunk_position='last', got {last_chunk.chunk_position!r}"
162-
)
163-
164-
def test_parallel_and_sequential_tool_calling(self, model: BaseChatModel) -> None:
165-
"""Test parallel tool calling for Claude models."""
166-
tools = [search_accommodation, search_flights, search_attractions]
167-
prompt = (
168-
"I want to plan a trip to Paris from New York. "
169-
"I need to find flights for March 15th, accommodation from March 15th to March 20th, and things to do there.",
170-
"Search for accomodations, flights and attractions in parallel. Don't repeat the same tool call.",
171-
)
172-
model_with_tools_parallel = model.bind_tools(
173-
tools,
174-
tool_choice={"type": "any", "disable_parallel_tool_use": False}, # type: ignore
89+
# File-input matrix: structured output forces tool_choice (incompatible with
90+
# thinking); image/PDF blocks don't round-trip through the Anthropic gateway.
91+
if test_name in ("test_file_inputs", "test_file_inputs_async"):
92+
if has_thinking:
93+
pytest.skip(
94+
"Structured output forces tool_choice, which is incompatible with thinking"
95+
)
96+
if fmt in (IMAGE_FORMATS | PDF_FORMATS):
97+
pytest.skip(
98+
"Image/PDF content blocks are not supported via the Anthropic gateway path"
99+
)
100+
101+
def _bind_parallel_and_sequential(
102+
self, model: BaseChatModel, tools: Iterable[Any]
103+
) -> tuple[Runnable, Runnable]:
104+
tools_list = list(tools)
105+
return (
106+
model.bind_tools(
107+
tools_list,
108+
tool_choice={"type": "any", "disable_parallel_tool_use": False}, # type: ignore
109+
),
110+
model.bind_tools(
111+
tools_list,
112+
tool_choice={"type": "any", "disable_parallel_tool_use": True}, # type: ignore
113+
),
175114
)
176-
model_with_tools_sequential = model.bind_tools(
177-
tools,
178-
tool_choice={"type": "any", "disable_parallel_tool_use": True}, # type: ignore
179-
)
180-
181-
parallel_response = model_with_tools_parallel.invoke(prompt)
182-
sequential_response = model_with_tools_sequential.invoke(prompt)
183-
184-
assert parallel_response.tool_calls is not None
185-
assert sequential_response.tool_calls is not None
186-
assert len(parallel_response.tool_calls) == len(tools)
187-
assert len(sequential_response.tool_calls) == 1
188-
189-
async def test_parallel_and_sequential_tool_calling_async(self, model: BaseChatModel) -> None:
190-
"""Test parallel and sequential tool calling async for Claude."""
191-
tools = [search_accommodation, search_flights, search_attractions]
192-
prompt = (
193-
"I want to plan a trip to Paris from New York. "
194-
"I need to find flights for March 15th, accommodation from March 15th to March 20th, and things to do there.",
195-
"Search for accomodations, flights and attractions in parallel. Don't repeat the same tool call.",
196-
)
197-
model_with_tools_parallel = model.bind_tools(
198-
tools,
199-
tool_choice={"type": "any", "disable_parallel_tool_use": False}, # type: ignore
200-
)
201-
model_with_tools_sequential = model.bind_tools(
202-
tools,
203-
tool_choice={"type": "any", "disable_parallel_tool_use": True}, # type: ignore
204-
)
205-
206-
parallel_response = await model_with_tools_parallel.ainvoke(prompt)
207-
sequential_response = await model_with_tools_sequential.ainvoke(prompt)
208-
209-
assert parallel_response.tool_calls is not None
210-
assert sequential_response.tool_calls is not None
211-
assert len(parallel_response.tool_calls) == len(tools)
212-
assert len(sequential_response.tool_calls) == 1

0 commit comments

Comments
 (0)