From 8c3cf25854075c5874237ef068d70e444029b398 Mon Sep 17 00:00:00 2001 From: radu-mocanu Date: Thu, 6 Aug 2026 15:42:27 +0300 Subject: [PATCH] fix: add provider-safe structured output mode --- packages/uipath_langchain_client/CHANGELOG.md | 8 ++ .../uipath_langchain_client/__version__.py | 2 +- .../clients/normalized/chat_models.py | 28 +++-- .../langchain/clients/normalized/test_unit.py | 108 ++++++++++++++++++ 4 files changed, 138 insertions(+), 8 deletions(-) diff --git a/packages/uipath_langchain_client/CHANGELOG.md b/packages/uipath_langchain_client/CHANGELOG.md index 0e31b821..3b576eef 100644 --- a/packages/uipath_langchain_client/CHANGELOG.md +++ b/packages/uipath_langchain_client/CHANGELOG.md @@ -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 diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/__version__.py b/packages/uipath_langchain_client/src/uipath_langchain_client/__version__.py index 618fd904..724013c5 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/__version__.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/__version__.py @@ -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" diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/clients/normalized/chat_models.py b/packages/uipath_langchain_client/src/uipath_langchain_client/clients/normalized/chat_models.py index bfaeb155..c337c478 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/clients/normalized/chat_models.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/clients/normalized/chat_models.py @@ -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 ( @@ -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, @@ -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, @@ -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(). @@ -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], @@ -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( @@ -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( diff --git a/tests/langchain/clients/normalized/test_unit.py b/tests/langchain/clients/normalized/test_unit.py index d5f7b16a..01e29c68 100644 --- a/tests/langchain/clients/normalized/test_unit.py +++ b/tests/langchain/clients/normalized/test_unit.py @@ -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 @@ -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):