From 6d7d7d9edc55aeb946bd1d3b95474dd8fb1a9bae Mon Sep 17 00:00:00 2001 From: Andrei Tava Date: Thu, 6 Aug 2026 13:45:17 +0300 Subject: [PATCH 1/4] fix: preserve provider tool call metadata in legacy chat completions --- .../agent/messages/message_utils.py | 1 + src/uipath_langchain/chat/_legacy/openai.py | 144 ++++++++- tests/chat/test_openai_tool_call_extras.py | 283 ++++++++++++++++++ 3 files changed, 426 insertions(+), 2 deletions(-) create mode 100644 tests/chat/test_openai_tool_call_extras.py diff --git a/src/uipath_langchain/agent/messages/message_utils.py b/src/uipath_langchain/agent/messages/message_utils.py index c56d521f1..864e6e457 100644 --- a/src/uipath_langchain/agent/messages/message_utils.py +++ b/src/uipath_langchain/agent/messages/message_utils.py @@ -36,6 +36,7 @@ def replace_tool_calls(message: AIMessage, tool_calls: list[ToolCall]) -> AIMess return AIMessage( content_blocks=content_blocks, tool_calls=tool_calls, + additional_kwargs=dict(message.additional_kwargs), response_metadata=response_metadata, id=message.id, ) diff --git a/src/uipath_langchain/chat/_legacy/openai.py b/src/uipath_langchain/chat/_legacy/openai.py index 93cf89b56..b40e3470f 100644 --- a/src/uipath_langchain/chat/_legacy/openai.py +++ b/src/uipath_langchain/chat/_legacy/openai.py @@ -1,8 +1,13 @@ import logging import os -from typing import Optional +from collections.abc import Mapping +from typing import Any, Optional, cast import httpx +import openai +from langchain_core.language_models import LanguageModelInput +from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessageChunk +from langchain_core.outputs import ChatGenerationChunk, ChatResult from langchain_openai import AzureChatOpenAI from pydantic import PrivateAttr from uipath.platform.chat.llm_trace_context import build_trace_context_headers @@ -19,6 +24,141 @@ logger = logging.getLogger(__name__) +_OPENAI_TOOL_CALL_EXTRAS_KEY = "__openai_tool_call_extras__" +_STANDARD_TOOL_CALL_FIELDS = frozenset({"id", "type", "function", "index"}) + + +def _get_tool_call_extras( + tool_call: Mapping[str, Any], fallback_key: str | None = None +) -> tuple[str | None, dict[str, Any]]: + """Extract provider extensions without retaining replaceable call fields.""" + extras = { + key: value + for key, value in tool_call.items() + if key not in _STANDARD_TOOL_CALL_FIELDS + } + if not extras: + return None, {} + return tool_call.get("id") or fallback_key, extras + + +def _store_tool_call_extras( + message: AIMessage | AIMessageChunk, + tool_calls: list[Mapping[str, Any]], +) -> None: + stored_extras = message.additional_kwargs.get(_OPENAI_TOOL_CALL_EXTRAS_KEY) + extras_by_id = dict(stored_extras) if isinstance(stored_extras, Mapping) else {} + for index, tool_call in enumerate(tool_calls): + fallback_key = ( + f"__index_{tool_call['index']}" + if "index" in tool_call + else f"__index_{index}" + ) + key, extras = _get_tool_call_extras(tool_call, fallback_key) + if key and extras: + extras_by_id[key] = extras + if extras_by_id: + message.additional_kwargs[_OPENAI_TOOL_CALL_EXTRAS_KEY] = extras_by_id + + +class _OpenAIToolCallExtrasMixin: + """Keep provider-specific tool-call fields across LangChain conversion.""" + + def _create_chat_result( + self, + response: dict[str, Any] | openai.BaseModel, + generation_info: dict[str, Any] | None = None, + ) -> ChatResult: + response_dict = ( + response + if isinstance(response, dict) + else response.model_dump( + exclude={"choices": {"__all__": {"message": {"parsed"}}}} + ) + ) + result = cast(Any, super())._create_chat_result(response, generation_info) + + for choice, generation in zip( + response_dict.get("choices") or [], result.generations, strict=False + ): + raw_tool_calls = choice.get("message", {}).get("tool_calls") or [] + if raw_tool_calls and isinstance(generation.message, AIMessage): + _store_tool_call_extras(generation.message, raw_tool_calls) + + return cast(ChatResult, result) + + def _convert_chunk_to_generation_chunk( + self, + chunk: dict[str, Any], + default_chunk_class: type[BaseMessageChunk], + base_generation_info: dict[str, Any] | None, + ) -> ChatGenerationChunk | None: + generation = cast(Any, super())._convert_chunk_to_generation_chunk( + chunk, default_chunk_class, base_generation_info + ) + if generation is None or not isinstance(generation.message, AIMessageChunk): + return cast(ChatGenerationChunk | None, generation) + + choices = chunk.get("choices", []) or chunk.get("chunk", {}).get("choices", []) + if choices and choices[0].get("delta"): + raw_tool_calls = choices[0]["delta"].get("tool_calls") or [] + if raw_tool_calls: + _store_tool_call_extras(generation.message, raw_tool_calls) + + return cast(ChatGenerationChunk, generation) + + def _get_generation_chunk_from_completion( + self, completion: openai.BaseModel + ) -> ChatGenerationChunk: + generation = cast(Any, super())._get_generation_chunk_from_completion( + completion + ) + # This final summary chunk follows the actual tool-call deltas. Repeating + # string-valued extras here would make LangChain concatenate the signature. + generation.message.additional_kwargs.pop(_OPENAI_TOOL_CALL_EXTRAS_KEY, None) + return cast(ChatGenerationChunk, generation) + + def _get_request_payload( + self, + input_: LanguageModelInput, + *, + stop: list[str] | None = None, + **kwargs: Any, + ) -> dict[str, Any]: + messages = cast(Any, self)._convert_input(input_).to_messages() + payload = cast(Any, super())._get_request_payload(input_, stop=stop, **kwargs) + payload_messages = payload.get("messages") + if not isinstance(payload_messages, list): + return cast(dict[str, Any], payload) + + for message, payload_message in zip(messages, payload_messages, strict=False): + if not isinstance(message, AIMessage) or not isinstance( + payload_message, dict + ): + continue + + extras_by_id = message.additional_kwargs.get(_OPENAI_TOOL_CALL_EXTRAS_KEY) + tool_calls = payload_message.get("tool_calls") + if not isinstance(extras_by_id, dict) or not isinstance(tool_calls, list): + continue + + for index, tool_call in enumerate(tool_calls): + if not isinstance(tool_call, dict): + continue + extras = extras_by_id.get(tool_call.get("id")) or extras_by_id.get( + f"__index_{index}" + ) + if isinstance(extras, Mapping): + tool_call.update( + { + key: value + for key, value in extras.items() + if key not in _STANDARD_TOOL_CALL_FIELDS + } + ) + + return cast(dict[str, Any], payload) + def _rewrite_openai_url( original_url: str, params: httpx.QueryParams @@ -90,7 +230,7 @@ def handle_request(self, request: httpx.Request) -> httpx.Response: return super().handle_request(request) -class UiPathChatOpenAI(AzureChatOpenAI): +class UiPathChatOpenAI(_OpenAIToolCallExtrasMixin, AzureChatOpenAI): llm_provider: LLMProvider = LLMProvider.OPENAI _api_flavor: APIFlavor = PrivateAttr() diff --git a/tests/chat/test_openai_tool_call_extras.py b/tests/chat/test_openai_tool_call_extras.py new file mode 100644 index 000000000..2dc5e2fbe --- /dev/null +++ b/tests/chat/test_openai_tool_call_extras.py @@ -0,0 +1,283 @@ +from collections.abc import Iterator +from typing import Any + +from langchain.messages import ( + AIMessage, + AIMessageChunk, + HumanMessage, + ToolCall, + ToolMessage, +) +from langchain_core.language_models.chat_models import generate_from_stream +from langchain_core.messages import BaseMessageChunk +from langchain_core.outputs import ChatGenerationChunk +from openai.types.chat import ChatCompletion, ChatCompletionChunk + +from uipath_langchain.agent.messages.message_utils import replace_tool_calls +from uipath_langchain.chat._legacy.openai import ( + _OPENAI_TOOL_CALL_EXTRAS_KEY, + UiPathChatOpenAI, +) + + +def _client() -> UiPathChatOpenAI: + return UiPathChatOpenAI.model_construct( + model_name="provider-model", + output_version=None, + use_responses_api=False, + ) + + +def _completion(tool_calls: list[dict[str, Any]]) -> ChatCompletion: + return ChatCompletion.model_validate( + { + "id": "response-1", + "model": "provider-model", + "object": "chat.completion", + "created": 1, + "choices": [ + { + "index": 0, + "finish_reason": "tool_calls", + "message": { + "role": "assistant", + "content": None, + "tool_calls": tool_calls, + }, + } + ], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 2, + "total_tokens": 3, + }, + } + ) + + +def test_provider_specific_tool_call_fields_survive_round_trip() -> None: + client = _client() + completion = _completion( + [ + { + "id": "call-1", + "type": "function", + "function": { + "name": "log", + "arguments": '{"message":"original"}', + }, + "provider_metadata": {"opaque_token": "token-1"}, + "provider_flag": True, + } + ] + ) + + message = client._create_chat_result(completion).generations[0].message + assert isinstance(message, AIMessage) + assert message.additional_kwargs[_OPENAI_TOOL_CALL_EXTRAS_KEY] == { + "call-1": { + "provider_metadata": {"opaque_token": "token-1"}, + "provider_flag": True, + } + } + + updated_message = replace_tool_calls( + message, + [ + ToolCall( + id="call-1", + name="log", + args={"message": "changed"}, + type="tool_call", + ) + ], + ) + payload = client._get_request_payload( + [ + HumanMessage("Log a message"), + updated_message, + ToolMessage("done", tool_call_id="call-1"), + ] + ) + + outgoing_call = payload["messages"][1]["tool_calls"][0] + assert outgoing_call["function"]["arguments"] == '{"message": "changed"}' + assert outgoing_call["provider_metadata"] == {"opaque_token": "token-1"} + assert outgoing_call["provider_flag"] is True + + +def test_provider_specific_fields_match_multiple_tool_calls_by_id() -> None: + client = _client() + completion = _completion( + [ + { + "id": "call-1", + "type": "function", + "function": { + "name": "first_tool", + "arguments": '{"value":"first"}', + }, + "extra_content": {"google": {"thought_signature": "signature-1"}}, + }, + { + "id": "call-2", + "type": "function", + "function": { + "name": "second_tool", + "arguments": '{"value":"second"}', + }, + "extra_content": {"google": {"thought_signature": "signature-2"}}, + }, + ] + ) + + message = client._create_chat_result(completion).generations[0].message + assert isinstance(message, AIMessage) + reordered_message = replace_tool_calls( + message, + [ + ToolCall( + id="call-2", + name="second_tool", + args={"value": "second"}, + type="tool_call", + ), + ToolCall( + id="call-1", + name="first_tool", + args={"value": "first"}, + type="tool_call", + ), + ], + ) + + payload = client._get_request_payload( + [ + HumanMessage("Call both tools"), + reordered_message, + ToolMessage("second result", tool_call_id="call-2"), + ToolMessage("first result", tool_call_id="call-1"), + ] + ) + + outgoing_calls = payload["messages"][1]["tool_calls"] + assert outgoing_calls[0]["extra_content"] == { + "google": {"thought_signature": "signature-2"} + } + assert outgoing_calls[1]["extra_content"] == { + "google": {"thought_signature": "signature-1"} + } + + +def test_streamed_provider_specific_fields_survive_chunk_merge() -> None: + client = _client() + raw_chunks = [ + { + "id": "response-1", + "model": "provider-model", + "object": "chat.completion.chunk", + "created": 1, + "choices": [ + { + "index": 0, + "finish_reason": None, + "delta": { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "index": 0, + "id": "call-1", + "type": "function", + "function": { + "name": "log", + "arguments": '{"message":', + }, + "extra_content": { + "google": {"thought_signature": "signature-1"} + }, + } + ], + }, + } + ], + }, + { + "id": "response-1", + "model": "provider-model", + "object": "chat.completion.chunk", + "created": 1, + "choices": [ + { + "index": 0, + "finish_reason": None, + "delta": { + "tool_calls": [ + { + "index": 0, + "function": {"arguments": '"original"}'}, + } + ] + }, + } + ], + }, + { + "id": "response-1", + "model": "provider-model", + "object": "chat.completion.chunk", + "created": 1, + "choices": [ + { + "index": 0, + "finish_reason": "tool_calls", + "delta": {}, + } + ], + }, + ] + + def generations() -> Iterator[ChatGenerationChunk]: + default_chunk_class: type[BaseMessageChunk] = AIMessageChunk + for raw_chunk in raw_chunks: + chunk = ChatCompletionChunk.model_validate(raw_chunk).model_dump() + generation = client._convert_chunk_to_generation_chunk( + chunk, default_chunk_class, None + ) + if generation is not None: + default_chunk_class = generation.message.__class__ + yield generation + + message = generate_from_stream(generations()).generations[0].message + assert message.additional_kwargs[_OPENAI_TOOL_CALL_EXTRAS_KEY] == { + "call-1": {"extra_content": {"google": {"thought_signature": "signature-1"}}} + } + + payload = client._get_request_payload( + [ + HumanMessage("Log a message"), + message, + ToolMessage("done", tool_call_id="call-1"), + ] + ) + assert payload["messages"][1]["tool_calls"][0]["extra_content"] == { + "google": {"thought_signature": "signature-1"} + } + + +def test_stream_final_completion_does_not_duplicate_tool_call_extras() -> None: + client = _client() + completion = _completion( + [ + { + "id": "call-1", + "type": "function", + "function": {"name": "log", "arguments": "{}"}, + "provider_signature": "signature-1", + } + ] + ) + + final_chunk = client._get_generation_chunk_from_completion(completion) + + assert _OPENAI_TOOL_CALL_EXTRAS_KEY not in final_chunk.message.additional_kwargs From 60933f2a94f622c3011df100040f70d204e42efb Mon Sep 17 00:00:00 2001 From: Andrei Tava Date: Thu, 6 Aug 2026 13:52:36 +0300 Subject: [PATCH 2/4] chore: bump version to 0.15.4 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8d2eebf6f..016984614 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.15.3" +version = "0.15.4" 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" diff --git a/uv.lock b/uv.lock index d47c30bbc..d754aa481 100644 --- a/uv.lock +++ b/uv.lock @@ -4498,7 +4498,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.15.3" +version = "0.15.4" source = { editable = "." } dependencies = [ { name = "a2a-sdk" }, From 2a8a632c09e927fca69d2e27186855ff6d964a8f Mon Sep 17 00:00:00 2001 From: Andrei Tava Date: Thu, 6 Aug 2026 17:52:11 +0300 Subject: [PATCH 3/4] docs: clarify streaming metadata aggregation --- src/uipath_langchain/chat/_legacy/openai.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/uipath_langchain/chat/_legacy/openai.py b/src/uipath_langchain/chat/_legacy/openai.py index b40e3470f..3e2b44058 100644 --- a/src/uipath_langchain/chat/_legacy/openai.py +++ b/src/uipath_langchain/chat/_legacy/openai.py @@ -103,6 +103,9 @@ def _convert_chunk_to_generation_chunk( if choices and choices[0].get("delta"): raw_tool_calls = choices[0]["delta"].get("tool_calls") or [] if raw_tool_calls: + # LangChain's generic chunk aggregation may combine repeated opaque + # extension values in unexpected ways. Provider behavior here is + # speculative, so avoid introducing unverified merge semantics. _store_tool_call_extras(generation.message, raw_tool_calls) return cast(ChatGenerationChunk, generation) From 1b7d70f9a58896bae1100b239cee7f238c690ae0 Mon Sep 17 00:00:00 2001 From: Andrei Tava Date: Thu, 6 Aug 2026 18:08:26 +0300 Subject: [PATCH 4/4] chore: require uipath langchain client 1.17.3 --- pyproject.toml | 14 +++++++------- uv.lock | 20 ++++++++++---------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 016984614..8413ce40a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ dependencies = [ "pillow>=12.1.1", "rdflib>=7.0.0, <8.0.0", "a2a-sdk>=0.2.0,<1.0.0", - "uipath-langchain-client[openai]>=1.17.1,<1.18.0", + "uipath-langchain-client[openai]>=1.17.3,<1.18.0", ] classifiers = [ @@ -43,21 +43,21 @@ maintainers = [ [project.optional-dependencies] anthropic = [ - "uipath-langchain-client[anthropic]>=1.17.1,<1.18.0", + "uipath-langchain-client[anthropic]>=1.17.3,<1.18.0", ] vertex = [ - "uipath-langchain-client[google]>=1.17.1,<1.18.0", - "uipath-langchain-client[vertexai]>=1.17.1,<1.18.0", + "uipath-langchain-client[google]>=1.17.3,<1.18.0", + "uipath-langchain-client[vertexai]>=1.17.3,<1.18.0", ] bedrock = [ - "uipath-langchain-client[bedrock]>=1.17.1,<1.18.0", + "uipath-langchain-client[bedrock]>=1.17.3,<1.18.0", "boto3-stubs>=1.41.4", ] fireworks = [ - "uipath-langchain-client[fireworks]>=1.17.1,<1.18.0", + "uipath-langchain-client[fireworks]>=1.17.3,<1.18.0", ] all = [ - "uipath-langchain-client[all]>=1.17.1,<1.18.0", + "uipath-langchain-client[all]>=1.17.3,<1.18.0", ] [project.entry-points."uipath.middlewares"] diff --git a/uv.lock b/uv.lock index d754aa481..f0c888053 100644 --- a/uv.lock +++ b/uv.lock @@ -4580,13 +4580,13 @@ requires-dist = [ { name = "rdflib", specifier = ">=7.0.0,<8.0.0" }, { name = "uipath", specifier = ">=2.13.16,<2.14.0" }, { name = "uipath-core", specifier = ">=0.5.29,<0.6.0" }, - { name = "uipath-langchain-client", extras = ["all"], marker = "extra == 'all'", specifier = ">=1.17.1,<1.18.0" }, - { name = "uipath-langchain-client", extras = ["anthropic"], marker = "extra == 'anthropic'", specifier = ">=1.17.1,<1.18.0" }, - { name = "uipath-langchain-client", extras = ["bedrock"], marker = "extra == 'bedrock'", specifier = ">=1.17.1,<1.18.0" }, - { name = "uipath-langchain-client", extras = ["fireworks"], marker = "extra == 'fireworks'", specifier = ">=1.17.1,<1.18.0" }, - { name = "uipath-langchain-client", extras = ["google"], marker = "extra == 'vertex'", specifier = ">=1.17.1,<1.18.0" }, - { name = "uipath-langchain-client", extras = ["openai"], specifier = ">=1.17.1,<1.18.0" }, - { name = "uipath-langchain-client", extras = ["vertexai"], marker = "extra == 'vertex'", specifier = ">=1.17.1,<1.18.0" }, + { name = "uipath-langchain-client", extras = ["all"], marker = "extra == 'all'", specifier = ">=1.17.3,<1.18.0" }, + { name = "uipath-langchain-client", extras = ["anthropic"], marker = "extra == 'anthropic'", specifier = ">=1.17.3,<1.18.0" }, + { name = "uipath-langchain-client", extras = ["bedrock"], marker = "extra == 'bedrock'", specifier = ">=1.17.3,<1.18.0" }, + { name = "uipath-langchain-client", extras = ["fireworks"], marker = "extra == 'fireworks'", specifier = ">=1.17.3,<1.18.0" }, + { name = "uipath-langchain-client", extras = ["google"], marker = "extra == 'vertex'", specifier = ">=1.17.3,<1.18.0" }, + { name = "uipath-langchain-client", extras = ["openai"], specifier = ">=1.17.3,<1.18.0" }, + { name = "uipath-langchain-client", extras = ["vertexai"], marker = "extra == 'vertex'", specifier = ">=1.17.3,<1.18.0" }, { name = "uipath-llm-client", specifier = ">=1.17.1,<1.18.0" }, { name = "uipath-platform", specifier = ">=0.2.15,<0.3.0" }, { name = "uipath-runtime", specifier = ">=0.12.5,<0.13.0" }, @@ -4611,15 +4611,15 @@ dev = [ [[package]] name = "uipath-langchain-client" -version = "1.17.1" +version = "1.17.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain" }, { name = "uipath-llm-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3b/d4/89045b56b1279fb93ce1202b54b50d5f6e2717c790edc2db40486a48d3cd/uipath_langchain_client-1.17.1.tar.gz", hash = "sha256:bebf320bdb6846ce63881a076e84efea916b7c692c736658296ba20c5a0a3865", size = 39707, upload-time = "2026-07-20T08:19:16.216Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/5e/5e96b1c6493ff08e6b742d495c6dc535f2d728464976d8e406473435d0fd/uipath_langchain_client-1.17.3.tar.gz", hash = "sha256:8971eaf6fadd50905000ad618be10b30837d8281794a52997b4d1a14a956cc55", size = 41395, upload-time = "2026-08-06T14:57:49.286Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/97/b2/f5981e4066f1d1a1da13d50d4be698490d25cff82a68859ab401164e5b51/uipath_langchain_client-1.17.1-py3-none-any.whl", hash = "sha256:3c4b02abfad06ce6a00da31d969f8fca5e8b07eff1e3aef8e57a1b5fcf71f8bb", size = 47614, upload-time = "2026-07-20T08:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/3d/71/c8a86ee204c0b6395d66442b50ce26dce7b76b63a5125e853d96d1db942c/uipath_langchain_client-1.17.3-py3-none-any.whl", hash = "sha256:e918bb28ad742b508a402d44f005936a7fa901939f7f6efde5a6e3bba82b8ce5", size = 49709, upload-time = "2026-08-06T14:57:50.258Z" }, ] [package.optional-dependencies]