From e89adb11c8d0e48ddf7a595e81294f1b51f78119 Mon Sep 17 00:00:00 2001 From: Andrei Tava Date: Thu, 6 Aug 2026 13:45:17 +0300 Subject: [PATCH 1/3] fix: preserve provider tool call metadata in OpenAI chat completions --- .../clients/openai/chat_models.py | 5 +- .../clients/openai/tool_call_extras.py | 134 +++++++++ .../clients/openai/test_tool_call_extras.py | 276 ++++++++++++++++++ 3 files changed, 414 insertions(+), 1 deletion(-) create mode 100644 packages/uipath_langchain_client/src/uipath_langchain_client/clients/openai/tool_call_extras.py create mode 100644 tests/langchain/clients/openai/test_tool_call_extras.py diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/clients/openai/chat_models.py b/packages/uipath_langchain_client/src/uipath_langchain_client/clients/openai/chat_models.py index 5eb96c4d..6e203b00 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/clients/openai/chat_models.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/clients/openai/chat_models.py @@ -5,6 +5,9 @@ from pydantic import Field, SecretStr, model_validator from uipath_langchain_client.base_client import UiPathBaseChatModel +from uipath_langchain_client.clients.openai.tool_call_extras import ( + OpenAIToolCallExtrasMixin, +) from uipath_langchain_client.clients.openai.utils import fix_url_and_api_flavor_header from uipath_langchain_client.settings import ( ApiFlavor, @@ -24,7 +27,7 @@ ) from e -class UiPathChatOpenAI(UiPathBaseChatModel, ChatOpenAI): # type: ignore[override] +class UiPathChatOpenAI(UiPathBaseChatModel, OpenAIToolCallExtrasMixin, ChatOpenAI): # type: ignore[override] api_config: UiPathAPIConfig = UiPathAPIConfig( api_type=ApiType.COMPLETIONS, routing_mode=RoutingMode.PASSTHROUGH, diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/clients/openai/tool_call_extras.py b/packages/uipath_langchain_client/src/uipath_langchain_client/clients/openai/tool_call_extras.py new file mode 100644 index 00000000..660cb4a7 --- /dev/null +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/clients/openai/tool_call_extras.py @@ -0,0 +1,134 @@ +"""Preserve provider-specific tool-call fields in OpenAI-compatible chats.""" + +from collections.abc import Mapping +from typing import Any, cast + +import openai +from langchain_core.language_models import LanguageModelInput +from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessageChunk +from langchain_core.outputs import ChatGenerationChunk, ChatResult + +_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) diff --git a/tests/langchain/clients/openai/test_tool_call_extras.py b/tests/langchain/clients/openai/test_tool_call_extras.py new file mode 100644 index 00000000..49ec65c8 --- /dev/null +++ b/tests/langchain/clients/openai/test_tool_call_extras.py @@ -0,0 +1,276 @@ +from collections.abc import Iterator +from typing import Any + +from langchain_core.language_models.chat_models import generate_from_stream +from langchain_core.messages import ( + AIMessage, + AIMessageChunk, + BaseMessageChunk, + HumanMessage, + ToolCall, + ToolMessage, +) +from langchain_core.outputs import ChatGenerationChunk +from openai.types.chat import ChatCompletion, ChatCompletionChunk +from uipath_langchain_client.clients.openai.chat_models import UiPathChatOpenAI +from uipath_langchain_client.clients.openai.tool_call_extras import ( + _OPENAI_TOOL_CALL_EXTRAS_KEY, +) + + +def _client() -> UiPathChatOpenAI: + return UiPathChatOpenAI.model_construct( + model_name="provider-model", + client_settings=object(), + 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 = AIMessage( + content=message.content, + additional_kwargs=dict(message.additional_kwargs), + tool_calls=[ + 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 = AIMessage( + content=message.content, + additional_kwargs=dict(message.additional_kwargs), + tool_calls=[ + 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 40beb1fb13ac1fb656c92e43b5fcef939db2c0a0 Mon Sep 17 00:00:00 2001 From: Andrei Tava Date: Thu, 6 Aug 2026 13:52:36 +0300 Subject: [PATCH 2/3] chore: bump langchain client version to 1.17.3 --- packages/uipath_langchain_client/CHANGELOG.md | 5 +++++ .../src/uipath_langchain_client/__version__.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/uipath_langchain_client/CHANGELOG.md b/packages/uipath_langchain_client/CHANGELOG.md index 3b576eef..03c83820 100644 --- a/packages/uipath_langchain_client/CHANGELOG.md +++ b/packages/uipath_langchain_client/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to `uipath_langchain_client` will be documented in this file. +## [1.17.3] - 2026-08-06 + +### Fixed +- Preserve provider-specific tool-call metadata across OpenAI-compatible Chat Completions message round trips, including streaming and updated tool calls. + ## [1.17.2] - 2026-08-06 ### Added 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 724013c5..b8851fcc 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.2" +__version__ = "1.17.3" From fd303209efe818b6b372e26574f051defebdcc4d Mon Sep 17 00:00:00 2001 From: Andrei Tava Date: Thu, 6 Aug 2026 17:52:11 +0300 Subject: [PATCH 3/3] docs: clarify streaming metadata aggregation --- .../uipath_langchain_client/clients/openai/tool_call_extras.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/clients/openai/tool_call_extras.py b/packages/uipath_langchain_client/src/uipath_langchain_client/clients/openai/tool_call_extras.py index 660cb4a7..905cc19c 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/clients/openai/tool_call_extras.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/clients/openai/tool_call_extras.py @@ -81,6 +81,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)