Skip to content
Merged
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
8 changes: 8 additions & 0 deletions packages/uipath_langchain_client/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

All notable changes to `uipath_langchain_client` will be documented in this file.

## [1.17.2] - 2026-08-06

### Added
- `UiPathChat.with_structured_output(method="auto")` selects JSON mode for Anthropic models and function calling for other providers, avoiding provider-specific response-format behavior in callers.

### Fixed
- `include_raw=True` now accepts standard LangChain message-list inputs and returns `raw`, `parsed`, and `parsing_error` as documented.

## [1.17.1] - 2026-07-17

### Changed
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
__title__ = "UiPath LangChain Client"
__description__ = "A Python client for interacting with UiPath's LLM services via LangChain."
__version__ = "1.17.1"
__version__ = "1.17.2"
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import json
from collections.abc import AsyncGenerator, Callable, Generator, Sequence
from functools import partial
from operator import itemgetter
from typing import Any, Literal, Union, cast

from langchain_core.callbacks import (
Expand Down Expand Up @@ -55,7 +56,7 @@
ChatGenerationChunk,
ChatResult,
)
from langchain_core.runnables import Runnable, RunnableLambda, RunnablePassthrough
from langchain_core.runnables import Runnable, RunnableLambda, RunnableMap, RunnablePassthrough
from langchain_core.tools import BaseTool
from langchain_core.utils.function_calling import (
convert_to_openai_function,
Expand Down Expand Up @@ -309,7 +310,9 @@ def with_structured_output(
self,
schema: _DictOrPydanticClass | None = None,
*,
method: Literal["function_calling", "json_mode", "json_schema"] = "function_calling",
method: Literal["auto", "function_calling", "json_mode", "json_schema"] = (
"function_calling"
),
include_raw: bool = False,
strict: bool | None = None,
**kwargs: Any,
Expand All @@ -319,8 +322,9 @@ def with_structured_output(
Args:
schema: The output schema as a Pydantic class, TypedDict, JSON Schema dict,
or OpenAI function schema.
method: Either "json_schema" (uses response_format) or "function_calling"
(uses tool calling to force the schema).
method: "auto" selects a provider-compatible method. Anthropic models use
"json_mode"; other models use "function_calling". Explicit methods retain
their existing behavior.
include_raw: If True, returns dict with 'raw', 'parsed', and 'parsing_error'.
strict: If True, model output is guaranteed to match the schema exactly.
**kwargs: Additional arguments passed to bind().
Expand All @@ -331,9 +335,19 @@ def with_structured_output(
if schema is None:
raise ValueError("schema must be specified.")

auto_method = method == "auto"
if auto_method:
method = (
"json_mode"
if self.model_name and is_anthropic_model_name(self.model_name)
else "function_calling"
)

is_pydantic = isinstance(schema, type) and is_basemodel_subclass(schema)

if method == "function_calling":
if auto_method:
kwargs.setdefault("parallel_tool_calls", False)
tool_name = convert_to_openai_tool(schema)["function"]["name"]
llm = self.bind_tools(
[schema],
Expand Down Expand Up @@ -386,12 +400,12 @@ def with_structured_output(
else:
raise ValueError(
f"Unrecognized method: '{method}'. "
"Expected 'function_calling', 'json_mode', or 'json_schema'."
"Expected 'auto', 'function_calling', 'json_mode', or 'json_schema'."
)

if include_raw:
parser_assign = RunnablePassthrough.assign(
parsed=lambda x: output_parser.invoke(x["raw"]),
parsed=itemgetter("raw") | output_parser,
parsing_error=lambda _: None,
)
parser_none = RunnablePassthrough.assign(
Expand All @@ -400,7 +414,7 @@ def with_structured_output(
parser_with_fallback = parser_assign.with_fallbacks(
[parser_none], exception_key="parsing_error"
)
return RunnablePassthrough.assign(raw=llm) | parser_with_fallback # type: ignore[return-value]
return RunnableMap(raw=llm) | parser_with_fallback # type: ignore[return-value]
return llm | output_parser # type: ignore[return-value]

def _preprocess_request(
Expand Down
108 changes: 108 additions & 0 deletions tests/langchain/clients/normalized/test_unit.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
"""LangChain unit tests for Normalized provider clients."""

from typing import Any
from unittest.mock import patch

import pytest
from langchain_core.embeddings import Embeddings
from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.runnables import RunnableLambda
from langchain_tests.unit_tests import ChatModelUnitTests, EmbeddingsUnitTests
from pydantic import BaseModel
from uipath_langchain_client.clients.normalized.chat_models import UiPathChat
from uipath_langchain_client.clients.normalized.embeddings import UiPathEmbeddings

Expand All @@ -15,6 +19,110 @@
NORMALIZED_EMBEDDINGS_CLASSES = [UiPathEmbeddings]


class StructuredAnswer(BaseModel):
answer: str


@pytest.mark.parametrize(
("model_name", "expected_method"),
[
("anthropic.claude-haiku-4-5-20251001-v1:0", "json_mode"),
("claude-haiku-4-5@20251001", "json_mode"),
("gemini-2.5-flash", "function_calling"),
("gpt-4o-2024-11-20", "function_calling"),
],
)
def test_auto_structured_output_selects_provider_compatible_method(
client_settings: UiPathBaseSettings,
model_name: str,
expected_method: str,
) -> None:
model = UiPathChat(model=model_name, settings=client_settings)
raw = AIMessage(content='{"answer":"ok"}')

with (
patch.object(
UiPathChat,
"bind",
autospec=True,
return_value=RunnableLambda(lambda _: raw),
) as bind,
patch.object(
UiPathChat,
"bind_tools",
autospec=True,
return_value=RunnableLambda(lambda _: raw),
) as bind_tools,
):
model.with_structured_output(StructuredAnswer, method="auto")

if expected_method == "json_mode":
bind.assert_called_once()
bind_tools.assert_not_called()
assert bind.call_args.kwargs["response_format"] == {"type": "json_object"}
else:
bind.assert_not_called()
bind_tools.assert_called_once()
assert bind_tools.call_args.kwargs["parallel_tool_calls"] is False


def test_explicit_function_calling_keeps_existing_parallel_default(
client_settings: UiPathBaseSettings,
) -> None:
model = UiPathChat(
model="anthropic.claude-haiku-4-5-20251001-v1:0",
settings=client_settings,
)

with patch.object(
UiPathChat,
"bind_tools",
autospec=True,
return_value=RunnableLambda(lambda _: AIMessage(content="")),
) as bind_tools:
model.with_structured_output(StructuredAnswer, method="function_calling")

assert "parallel_tool_calls" not in bind_tools.call_args.kwargs


@pytest.mark.parametrize(
("content", "expected_answer", "has_error"),
[
('{"answer":"ok"}', "ok", False),
("not json", None, True),
],
)
def test_include_raw_accepts_message_list_input(
client_settings: UiPathBaseSettings,
content: str,
expected_answer: str | None,
has_error: bool,
) -> None:
model = UiPathChat(model="gpt-4o-2024-11-20", settings=client_settings)
raw = AIMessage(content=content)

with patch.object(
UiPathChat,
"bind",
autospec=True,
return_value=RunnableLambda(lambda _: raw),
):
runnable = model.with_structured_output(
StructuredAnswer,
method="json_mode",
include_raw=True,
)
result = runnable.invoke([HumanMessage(content="answer the question")])

assert isinstance(result, dict)
assert result["raw"] is raw
assert (result["parsing_error"] is not None) is has_error
if expected_answer is None:
assert result["parsed"] is None
else:
assert result["parsed"] == StructuredAnswer(answer=expected_answer)


class TestNormalizedChatModel(ChatModelUnitTests):
@pytest.fixture(autouse=True, params=NORMALIZED_CHAT_CLASSES)
def setup_models(self, request: pytest.FixtureRequest, client_settings: UiPathBaseSettings):
Expand Down
Loading