From 1bbc2f7c8f3bc05cdaeccc5b9e223e077c7ccd6e Mon Sep 17 00:00:00 2001 From: Cosmin Maria Date: Tue, 21 Apr 2026 00:43:42 +0300 Subject: [PATCH 1/2] Feat: additive default_headers on UiPathBaseLLMClient Class-level built-in defaults (timeout + AllowFull4xxResponse) now live on `class_default_headers: ClassVar`. Caller-supplied `default_headers` are merged on top, so user values override on collision but never remove the built-ins. Previously passing any `default_headers={...}` dropped the factory defaults. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/uipath_langchain_client/CHANGELOG.md | 5 ++ .../uipath_langchain_client/__version__.py | 2 +- .../uipath_langchain_client/base_client.py | 17 +++-- .../features/test_default_headers_merge.py | 74 +++++++++++++++++++ 4 files changed, 91 insertions(+), 7 deletions(-) create mode 100644 tests/langchain/features/test_default_headers_merge.py diff --git a/packages/uipath_langchain_client/CHANGELOG.md b/packages/uipath_langchain_client/CHANGELOG.md index 1924e21..9711039 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.9.5] - 2026-04-21 + +### Changed +- `UiPathBaseLLMClient.default_headers` is now additive: caller-supplied headers are merged on top of a class-level `class_default_headers` (timeout and `AllowFull4xxResponse` policy) instead of replacing them. User values still win on key collisions. Previously, passing any `default_headers={...}` caused the built-in defaults to be dropped from `self.default_headers` (though the core httpx client's class defaults kept them on the wire). + ## [1.9.4] - 2026-04-21 ### 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 06d293f..d3e67cc 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.9.4" +__version__ = "1.9.5" diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/base_client.py b/packages/uipath_langchain_client/src/uipath_langchain_client/base_client.py index d1c1066..9349590 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/base_client.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/base_client.py @@ -27,7 +27,7 @@ from abc import ABC from collections.abc import AsyncGenerator, Generator, Mapping, Sequence from functools import cached_property -from typing import Any, Literal +from typing import Any, ClassVar, Literal from httpx import URL, Response from langchain_core.callbacks import ( @@ -86,6 +86,11 @@ class UiPathBaseLLMClient(BaseModel, ABC): validate_default=True, ) + class_default_headers: ClassVar[dict[str, str]] = { + "X-UiPath-LLMGateway-TimeoutSeconds": "295", # server side timeout, default is 10, maximum is 300 + "X-UiPath-LLMGateway-AllowFull4xxResponse": "false", # allow full 4xx responses (default is false) — removed from default to avoid PII leakage in logs + } + model_name: str = Field( alias="model", description="the LLM model name (completions or embeddings)" ) @@ -106,11 +111,9 @@ class UiPathBaseLLMClient(BaseModel, ABC): ) default_headers: Mapping[str, str] | None = Field( - default_factory=lambda: { - "X-UiPath-LLMGateway-TimeoutSeconds": "295", # server side timeout, default is 10, maximum is 300 - # "X-UiPath-LLMGateway-AllowFull4xxResponse": "true", # allow full 4xx responses (default is false) — removed from default to avoid PII leakage in logs - }, - description="Default request headers to include in requests", + default=None, + description="Caller-supplied request headers. Merged on top of `class_default_headers`; " + "user values win on key collisions. Does not remove built-in defaults.", ) captured_headers: tuple[str, ...] = Field( default=("x-uipath-",), @@ -151,6 +154,7 @@ def uipath_sync_client(self) -> UiPathHttpxClient: model_name=self.model_name, api_config=self.api_config ), headers={ + **self.class_default_headers, **(self.default_headers or {}), **self.client_settings.build_auth_headers( model_name=self.model_name, api_config=self.api_config @@ -175,6 +179,7 @@ def uipath_async_client(self) -> UiPathHttpxAsyncClient: model_name=self.model_name, api_config=self.api_config ), headers={ + **self.class_default_headers, **(self.default_headers or {}), **self.client_settings.build_auth_headers( model_name=self.model_name, api_config=self.api_config diff --git a/tests/langchain/features/test_default_headers_merge.py b/tests/langchain/features/test_default_headers_merge.py new file mode 100644 index 0000000..9984b85 --- /dev/null +++ b/tests/langchain/features/test_default_headers_merge.py @@ -0,0 +1,74 @@ +"""Tests for the merge behavior between class-level and instance default headers +on the langchain `UiPathBaseLLMClient`. + +The class-level `class_default_headers` (timeout, 4xx-response policy) must +always be present. User-supplied `default_headers` are merged on top and win on +key collisions, but they must not remove class-level defaults. +""" + +import os +from unittest.mock import patch + +from uipath_langchain_client.clients.normalized.chat_models import UiPathChat + +from uipath.llm_client.settings import LLMGatewaySettings +from uipath.llm_client.settings.utils import SingletonMeta + +LLMGW_ENV = { + "LLMGW_URL": "https://cloud.uipath.com", + "LLMGW_SEMANTIC_ORG_ID": "test-org-id", + "LLMGW_SEMANTIC_TENANT_ID": "test-tenant-id", + "LLMGW_REQUESTING_PRODUCT": "test-product", + "LLMGW_REQUESTING_FEATURE": "test-feature", + "LLMGW_ACCESS_TOKEN": "test-access-token", +} + + +class TestClassDefaultHeadersAlwaysPresent: + def setup_method(self): + SingletonMeta._instances.clear() + + def teardown_method(self): + SingletonMeta._instances.clear() + + def test_no_user_headers_preserves_class_defaults(self): + with patch.dict(os.environ, LLMGW_ENV, clear=True): + chat = UiPathChat(model="gpt-4o", settings=LLMGatewaySettings()) + headers = chat.uipath_sync_client.headers + assert headers.get("x-uipath-llmgateway-timeoutseconds") == "295" + assert headers.get("x-uipath-llmgateway-allowfull4xxresponse") == "false" + + def test_user_headers_do_not_remove_class_defaults(self): + with patch.dict(os.environ, LLMGW_ENV, clear=True): + chat = UiPathChat( + model="gpt-4o", + settings=LLMGatewaySettings(), + default_headers={"x-my-custom": "value"}, + ) + headers = chat.uipath_sync_client.headers + assert headers.get("x-uipath-llmgateway-timeoutseconds") == "295" + assert headers.get("x-uipath-llmgateway-allowfull4xxresponse") == "false" + assert headers.get("x-my-custom") == "value" + + def test_user_headers_override_class_defaults_on_collision(self): + with patch.dict(os.environ, LLMGW_ENV, clear=True): + chat = UiPathChat( + model="gpt-4o", + settings=LLMGatewaySettings(), + default_headers={"X-UiPath-LLMGateway-TimeoutSeconds": "60"}, + ) + headers = chat.uipath_sync_client.headers + assert headers.get("x-uipath-llmgateway-timeoutseconds") == "60" + assert headers.get("x-uipath-llmgateway-allowfull4xxresponse") == "false" + + def test_async_client_also_merges(self): + with patch.dict(os.environ, LLMGW_ENV, clear=True): + chat = UiPathChat( + model="gpt-4o", + settings=LLMGatewaySettings(), + default_headers={"x-my-custom": "async-value"}, + ) + headers = chat.uipath_async_client.headers + assert headers.get("x-uipath-llmgateway-timeoutseconds") == "295" + assert headers.get("x-uipath-llmgateway-allowfull4xxresponse") == "false" + assert headers.get("x-my-custom") == "async-value" From 5ec3086c861f1b40cdc8b0fbf284c67e1b3e4df1 Mon Sep 17 00:00:00 2001 From: Cosmin Maria Date: Tue, 21 Apr 2026 01:01:38 +0300 Subject: [PATCH 2/2] Refactor: lift built-in request headers to a shared constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `UIPATH_DEFAULT_REQUEST_HEADERS` in `utils/headers.py` as the single source of truth for the gateway's built-in request headers. `UiPathHttpxClient._default_headers` and the langchain `UiPathBaseLLMClient.class_default_headers` both reference it, so the two copies in core + langchain stay in sync by construction. Core 1.9.4 → 1.9.5, langchain's core floor raised to match. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 5 +++++ packages/uipath_langchain_client/CHANGELOG.md | 2 ++ packages/uipath_langchain_client/pyproject.toml | 2 +- .../src/uipath_langchain_client/base_client.py | 6 ++---- src/uipath/llm_client/__version__.py | 2 +- src/uipath/llm_client/httpx_client.py | 11 +++-------- src/uipath/llm_client/utils/headers.py | 5 +++++ 7 files changed, 19 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cc858e..5bd61a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to `uipath_llm_client` (core package) will be documented in this file. +## [1.9.5] - 2026-04-21 + +### Added +- `utils.headers.UIPATH_DEFAULT_REQUEST_HEADERS` public constant — the single source of truth for built-in gateway request headers (`X-UiPath-LLMGateway-TimeoutSeconds=295`, `X-UiPath-LLMGateway-AllowFull4xxResponse=false`). `UiPathHttpxClient._default_headers` now references this constant; the langchain base client reuses the same constant for its `class_default_headers`. + ## [1.9.4] - 2026-04-21 ### Changed diff --git a/packages/uipath_langchain_client/CHANGELOG.md b/packages/uipath_langchain_client/CHANGELOG.md index 9711039..18a6e3d 100644 --- a/packages/uipath_langchain_client/CHANGELOG.md +++ b/packages/uipath_langchain_client/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to `uipath_langchain_client` will be documented in this file ### Changed - `UiPathBaseLLMClient.default_headers` is now additive: caller-supplied headers are merged on top of a class-level `class_default_headers` (timeout and `AllowFull4xxResponse` policy) instead of replacing them. User values still win on key collisions. Previously, passing any `default_headers={...}` caused the built-in defaults to be dropped from `self.default_headers` (though the core httpx client's class defaults kept them on the wire). +- `UiPathBaseLLMClient.class_default_headers` now points at the shared `uipath.llm_client.utils.headers.UIPATH_DEFAULT_REQUEST_HEADERS` constant (single source of truth with core's `UiPathHttpxClient._default_headers`). +- Minimum `uipath-llm-client` bumped to 1.9.5 for the shared `UIPATH_DEFAULT_REQUEST_HEADERS` constant. ## [1.9.4] - 2026-04-21 diff --git a/packages/uipath_langchain_client/pyproject.toml b/packages/uipath_langchain_client/pyproject.toml index 79902de..d9fd762 100644 --- a/packages/uipath_langchain_client/pyproject.toml +++ b/packages/uipath_langchain_client/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" requires-python = ">=3.11" dependencies = [ "langchain>=1.2.15", - "uipath-llm-client>=1.9.4", + "uipath-llm-client>=1.9.5", ] [project.optional-dependencies] diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/base_client.py b/packages/uipath_langchain_client/src/uipath_langchain_client/base_client.py index 9349590..91274bd 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/base_client.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/base_client.py @@ -45,6 +45,7 @@ UiPathHttpxClient, ) from uipath.llm_client.utils.headers import ( + UIPATH_DEFAULT_REQUEST_HEADERS, get_captured_response_headers, set_captured_response_headers, ) @@ -86,10 +87,7 @@ class UiPathBaseLLMClient(BaseModel, ABC): validate_default=True, ) - class_default_headers: ClassVar[dict[str, str]] = { - "X-UiPath-LLMGateway-TimeoutSeconds": "295", # server side timeout, default is 10, maximum is 300 - "X-UiPath-LLMGateway-AllowFull4xxResponse": "false", # allow full 4xx responses (default is false) — removed from default to avoid PII leakage in logs - } + class_default_headers: ClassVar[dict[str, str]] = UIPATH_DEFAULT_REQUEST_HEADERS model_name: str = Field( alias="model", description="the LLM model name (completions or embeddings)" diff --git a/src/uipath/llm_client/__version__.py b/src/uipath/llm_client/__version__.py index b98b558..6301e77 100644 --- a/src/uipath/llm_client/__version__.py +++ b/src/uipath/llm_client/__version__.py @@ -1,3 +1,3 @@ __title__ = "UiPath LLM Client" __description__ = "A Python client for interacting with UiPath's LLM services." -__version__ = "1.9.4" +__version__ = "1.9.5" diff --git a/src/uipath/llm_client/httpx_client.py b/src/uipath/llm_client/httpx_client.py index d422799..2969fe8 100644 --- a/src/uipath/llm_client/httpx_client.py +++ b/src/uipath/llm_client/httpx_client.py @@ -51,6 +51,7 @@ from uipath.llm_client.settings.base import UiPathAPIConfig, UiPathBaseSettings from uipath.llm_client.utils.exceptions import patch_raise_for_status from uipath.llm_client.utils.headers import ( + UIPATH_DEFAULT_REQUEST_HEADERS, build_routing_headers, extract_matching_headers, get_dynamic_request_headers, @@ -84,10 +85,7 @@ class UiPathHttpxClient(Client): """ _streaming_header: str = "X-UiPath-Streaming-Enabled" - _default_headers: dict[str, str] = { - "X-UiPath-LLMGateway-TimeoutSeconds": "295", # server side timeout, default is 10, maximum is 300 - "X-UiPath-LLMGateway-AllowFull4xxResponse": "false", # allow full 4xx responses (default is false) — removed from default to avoid PII leakage in logs - } + _default_headers: dict[str, str] = UIPATH_DEFAULT_REQUEST_HEADERS def __init__( self, @@ -286,10 +284,7 @@ class UiPathHttpxAsyncClient(AsyncClient): """ _streaming_header: str = "X-UiPath-Streaming-Enabled" - _default_headers: dict[str, str] = { - "X-UiPath-LLMGateway-TimeoutSeconds": "295", # server side timeout, default is 10, maximum is 300 - "X-UiPath-LLMGateway-AllowFull4xxResponse": "false", # allow full 4xx responses (default is false) — removed from default to avoid PII leakage in logs - } + _default_headers: dict[str, str] = UIPATH_DEFAULT_REQUEST_HEADERS def __init__( self, diff --git a/src/uipath/llm_client/utils/headers.py b/src/uipath/llm_client/utils/headers.py index 8bb7fdd..0b3dfb1 100644 --- a/src/uipath/llm_client/utils/headers.py +++ b/src/uipath/llm_client/utils/headers.py @@ -6,6 +6,11 @@ from uipath.llm_client.settings.base import UiPathAPIConfig from uipath.llm_client.settings.constants import ApiType, RoutingMode +UIPATH_DEFAULT_REQUEST_HEADERS: dict[str, str] = { + "X-UiPath-LLMGateway-TimeoutSeconds": "295", # server side timeout, default is 10, maximum is 300 + "X-UiPath-LLMGateway-AllowFull4xxResponse": "false", # allow full 4xx responses (default is false) — kept false to avoid PII leakage in logs +} + _CAPTURED_RESPONSE_HEADERS: contextvars.ContextVar[dict[str, str] | None] = contextvars.ContextVar( "_captured_response_headers", default=None )