From 24802d9fa736d49bee07f71dc12e2490fb716db2 Mon Sep 17 00:00:00 2001 From: Andrei Tava Date: Mon, 13 Jul 2026 16:03:38 +0300 Subject: [PATCH 1/3] fix: merge request headers case-insensitively --- src/uipath/llm_client/httpx_client.py | 36 +++++ tests/core/features/test_httpx_client.py | 187 ++++++++++++++++++++++- 2 files changed, 222 insertions(+), 1 deletion(-) diff --git a/src/uipath/llm_client/httpx_client.py b/src/uipath/llm_client/httpx_client.py index dc40ced8..d58ba663 100644 --- a/src/uipath/llm_client/httpx_client.py +++ b/src/uipath/llm_client/httpx_client.py @@ -73,6 +73,40 @@ _DEFAULT_MAX_RETRIES: typing.Final[int] = 3 +def _merge_headers_case_insensitively(headers: Headers) -> Headers: + """Collapse differently-cased copies of a header with later-value-wins semantics. + + Header mappings are sometimes composed as plain dictionaries by provider SDKs. + Since dictionaries compare string keys case-sensitively, that can produce raw + duplicates such as ``User-Agent`` and ``user-agent`` even though HTTP field names + are case-insensitive. Preserve deliberately repeated fields that use the exact same + spelling, but merge casing variants as a dictionary merge should have done. + """ + raw_headers = headers.raw + spellings: dict[bytes, set[bytes]] = {} + for name, _ in raw_headers: + spellings.setdefault(name.lower(), set()).add(name) + + collisions = {name for name, variants in spellings.items() if len(variants) > 1} + if not collisions: + return headers + + winning_spellings = { + name.lower(): name for name, _ in raw_headers if name.lower() in collisions + } + return Headers( + [ + (name, value) + for name, value in raw_headers + if name.lower() not in collisions or winning_spellings[name.lower()] == name + ] + ) + + +def _merge_request_headers_case_insensitively(request: Request) -> None: + request.headers = _merge_headers_case_insensitively(request.headers) + + class UiPathHttpxClient(Client): """Synchronous HTTP client configured for UiPath LLM services. @@ -264,6 +298,7 @@ def send(self, request: Request, *, stream: bool = False, **kwargs: Any) -> Resp dynamic_headers = get_dynamic_request_headers() if dynamic_headers: request.headers.update(dynamic_headers) + _merge_request_headers_case_insensitively(request) response = super().send(request, stream=stream, **kwargs) if self._captured_headers: captured = extract_matching_headers(response.headers, self._captured_headers) @@ -425,6 +460,7 @@ async def send(self, request: Request, *, stream: bool = False, **kwargs: Any) - dynamic_headers = get_dynamic_request_headers() if dynamic_headers: request.headers.update(dynamic_headers) + _merge_request_headers_case_insensitively(request) response = await super().send(request, stream=stream, **kwargs) if self._captured_headers: captured = extract_matching_headers(response.headers, self._captured_headers) diff --git a/tests/core/features/test_httpx_client.py b/tests/core/features/test_httpx_client.py index b19bd38b..1fedc4fd 100644 --- a/tests/core/features/test_httpx_client.py +++ b/tests/core/features/test_httpx_client.py @@ -2,7 +2,8 @@ from unittest.mock import MagicMock, patch -from httpx import Client, Headers, Request, Response +import pytest +from httpx import Auth, Client, Headers, MockTransport, Request, Response from uipath.llm_client.settings import UiPathAPIConfig from uipath.llm_client.settings.constants import ApiType, RoutingMode @@ -12,6 +13,65 @@ ) +class RecordingHeaderAuth(Auth): + def __init__(self): + self.signed_user_agents = [] + + def auth_flow(self, request): + self.signed_user_agents = request.headers.get_list("user-agent") + request.headers["x-signed-user-agents"] = "|".join(self.signed_user_agents) + yield request + + +def test_case_insensitive_header_merge_keeps_later_value(): + from uipath.llm_client.httpx_client import _merge_headers_case_insensitively + + headers = Headers( + [ + (b"User-Agent", b"provider-sdk"), + (b"user-agent", b"custom-httpx-client"), + ] + ) + + merged = _merge_headers_case_insensitively(headers) + + assert merged.raw == [(b"user-agent", b"custom-httpx-client")] + + +def test_case_insensitive_header_merge_preserves_intentional_repeats(): + from uipath.llm_client.httpx_client import _merge_headers_case_insensitively + + headers = Headers( + [ + (b"x-test-value", b"first"), + (b"x-test-value", b"second"), + ] + ) + + merged = _merge_headers_case_insensitively(headers) + + assert merged.raw == headers.raw + + +def test_case_insensitive_header_merge_preserves_repeated_winning_spelling(): + from uipath.llm_client.httpx_client import _merge_headers_case_insensitively + + headers = Headers( + [ + (b"X-Test-Value", b"losing-variant"), + (b"x-test-value", b"first"), + (b"x-test-value", b"second"), + ] + ) + + merged = _merge_headers_case_insensitively(headers) + + assert merged.raw == [ + (b"x-test-value", b"first"), + (b"x-test-value", b"second"), + ] + + class TestUiPathHttpxClient: """Tests for UiPathHttpxClient.""" @@ -239,6 +299,67 @@ def test_streaming_header_injected_true(self): assert sent_request.headers["X-UiPath-Streaming-Enabled"] == "true" client.close() + def test_case_variant_headers_are_merged_before_send(self): + from uipath.llm_client.httpx_client import UiPathHttpxClient + + sent_requests = [] + + def handler(request: Request) -> Response: + sent_requests.append(request) + return Response(200, request=request) + + client = UiPathHttpxClient( + base_url="https://example.com", + transport=MockTransport(handler), + ) + request = Request( + "POST", + "https://example.com/test", + headers=Headers( + [ + (b"User-Agent", b"provider-sdk"), + (b"user-agent", b"custom-httpx-client"), + ] + ), + ) + + client.send(request) + + sent_request = sent_requests[0] + assert sent_request.headers.get_list("user-agent") == ["custom-httpx-client"] + client.close() + + def test_case_variant_headers_are_merged_before_auth(self): + from uipath.llm_client.httpx_client import UiPathHttpxClient + + auth = RecordingHeaderAuth() + + def handler(request: Request) -> Response: + assert request.headers.get_list("user-agent") == auth.signed_user_agents + assert request.headers["x-signed-user-agents"] == "custom-httpx-client" + return Response(200, request=request) + + client = UiPathHttpxClient( + auth=auth, + base_url="https://example.com", + transport=MockTransport(handler), + ) + request = Request( + "POST", + "https://example.com/test", + headers=Headers( + [ + (b"User-Agent", b"provider-sdk"), + (b"user-agent", b"custom-httpx-client"), + ] + ), + ) + + client.send(request) + + assert auth.signed_user_agents == ["custom-httpx-client"] + client.close() + def test_url_freezing_when_enabled(self): from uipath.llm_client.httpx_client import UiPathHttpxClient @@ -314,3 +435,67 @@ def test_response_patched_with_raise_for_status(self): # raise_for_status should have been replaced by patch_raise_for_status assert result.raise_for_status is not original_raise client.close() + + +class TestUiPathHttpxAsyncClientSend: + @pytest.mark.asyncio + async def test_case_variant_headers_are_merged_before_send(self): + from uipath.llm_client.httpx_client import UiPathHttpxAsyncClient + + sent_requests = [] + + async def handler(request: Request) -> Response: + sent_requests.append(request) + return Response(200, request=request) + + client = UiPathHttpxAsyncClient( + base_url="https://example.com", + transport=MockTransport(handler), + ) + request = Request( + "POST", + "https://example.com/test", + headers=Headers( + [ + (b"User-Agent", b"provider-sdk"), + (b"user-agent", b"custom-httpx-client"), + ] + ), + ) + await client.send(request) + + sent_request = sent_requests[0] + assert sent_request.headers.get_list("user-agent") == ["custom-httpx-client"] + await client.aclose() + + @pytest.mark.asyncio + async def test_case_variant_headers_are_merged_before_auth(self): + from uipath.llm_client.httpx_client import UiPathHttpxAsyncClient + + auth = RecordingHeaderAuth() + + async def handler(request: Request) -> Response: + assert request.headers.get_list("user-agent") == auth.signed_user_agents + assert request.headers["x-signed-user-agents"] == "custom-httpx-client" + return Response(200, request=request) + + client = UiPathHttpxAsyncClient( + auth=auth, + base_url="https://example.com", + transport=MockTransport(handler), + ) + request = Request( + "POST", + "https://example.com/test", + headers=Headers( + [ + (b"User-Agent", b"provider-sdk"), + (b"user-agent", b"custom-httpx-client"), + ] + ), + ) + + await client.send(request) + + assert auth.signed_user_agents == ["custom-httpx-client"] + await client.aclose() From 0bc7c6e0ba7deafb292a20932c7d86ee75904686 Mon Sep 17 00:00:00 2001 From: Andrei Tava Date: Mon, 13 Jul 2026 16:05:34 +0300 Subject: [PATCH 2/3] chore: bump core client version to 1.16.3 --- CHANGELOG.md | 5 +++++ src/uipath/llm_client/__version__.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a91f16f3..9205eb4f 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.16.3] - 2026-07-13 + +### Fixed +- Request headers that differ only by casing are now merged before authentication in the shared synchronous and asynchronous HTTPX clients. This prevents provider SDKs from emitting duplicate fields such as `User-Agent` and `user-agent`, while preserving intentionally repeated values that use the winning spelling. + ## [1.16.2] - 2026-07-06 ### Added diff --git a/src/uipath/llm_client/__version__.py b/src/uipath/llm_client/__version__.py index 8764d704..859435be 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.16.2" +__version__ = "1.16.3" From b689d4a1cd5ca3bde64c80860e5b044196f9b338 Mon Sep 17 00:00:00 2001 From: Andrei Tava Date: Mon, 13 Jul 2026 16:34:28 +0300 Subject: [PATCH 3/3] refactor: move header merging to header utilities --- src/uipath/llm_client/httpx_client.py | 39 ++----------------- src/uipath/llm_client/utils/headers.py | 37 +++++++++++++++++- tests/core/features/test_headers.py | 49 ++++++++++++++++++++++++ tests/core/features/test_httpx_client.py | 49 ------------------------ 4 files changed, 88 insertions(+), 86 deletions(-) diff --git a/src/uipath/llm_client/httpx_client.py b/src/uipath/llm_client/httpx_client.py index d58ba663..9e544aab 100644 --- a/src/uipath/llm_client/httpx_client.py +++ b/src/uipath/llm_client/httpx_client.py @@ -55,6 +55,7 @@ build_routing_headers, extract_matching_headers, get_dynamic_request_headers, + merge_request_headers_case_insensitively, set_captured_response_headers, ) from uipath.llm_client.utils.logging import LoggingConfig @@ -73,40 +74,6 @@ _DEFAULT_MAX_RETRIES: typing.Final[int] = 3 -def _merge_headers_case_insensitively(headers: Headers) -> Headers: - """Collapse differently-cased copies of a header with later-value-wins semantics. - - Header mappings are sometimes composed as plain dictionaries by provider SDKs. - Since dictionaries compare string keys case-sensitively, that can produce raw - duplicates such as ``User-Agent`` and ``user-agent`` even though HTTP field names - are case-insensitive. Preserve deliberately repeated fields that use the exact same - spelling, but merge casing variants as a dictionary merge should have done. - """ - raw_headers = headers.raw - spellings: dict[bytes, set[bytes]] = {} - for name, _ in raw_headers: - spellings.setdefault(name.lower(), set()).add(name) - - collisions = {name for name, variants in spellings.items() if len(variants) > 1} - if not collisions: - return headers - - winning_spellings = { - name.lower(): name for name, _ in raw_headers if name.lower() in collisions - } - return Headers( - [ - (name, value) - for name, value in raw_headers - if name.lower() not in collisions or winning_spellings[name.lower()] == name - ] - ) - - -def _merge_request_headers_case_insensitively(request: Request) -> None: - request.headers = _merge_headers_case_insensitively(request.headers) - - class UiPathHttpxClient(Client): """Synchronous HTTP client configured for UiPath LLM services. @@ -298,7 +265,7 @@ def send(self, request: Request, *, stream: bool = False, **kwargs: Any) -> Resp dynamic_headers = get_dynamic_request_headers() if dynamic_headers: request.headers.update(dynamic_headers) - _merge_request_headers_case_insensitively(request) + merge_request_headers_case_insensitively(request) response = super().send(request, stream=stream, **kwargs) if self._captured_headers: captured = extract_matching_headers(response.headers, self._captured_headers) @@ -460,7 +427,7 @@ async def send(self, request: Request, *, stream: bool = False, **kwargs: Any) - dynamic_headers = get_dynamic_request_headers() if dynamic_headers: request.headers.update(dynamic_headers) - _merge_request_headers_case_insensitively(request) + merge_request_headers_case_insensitively(request) response = await super().send(request, stream=stream, **kwargs) if self._captured_headers: captured = extract_matching_headers(response.headers, self._captured_headers) diff --git a/src/uipath/llm_client/utils/headers.py b/src/uipath/llm_client/utils/headers.py index 87c74f4e..82256389 100644 --- a/src/uipath/llm_client/utils/headers.py +++ b/src/uipath/llm_client/utils/headers.py @@ -1,7 +1,7 @@ import contextvars from collections.abc import Mapping, Sequence -from httpx import Headers +from httpx import Headers, Request from uipath.llm_client.settings.base import UiPathAPIConfig from uipath.llm_client.settings.constants import ApiType, RoutingMode @@ -20,6 +20,41 @@ ) +def merge_headers_case_insensitively(headers: Headers) -> Headers: + """Collapse differently-cased copies of a header with later-value-wins semantics. + + Header mappings are sometimes composed as plain dictionaries by provider SDKs. + Since dictionaries compare string keys case-sensitively, that can produce raw + duplicates such as ``User-Agent`` and ``user-agent`` even though HTTP field names + are case-insensitive. Preserve deliberately repeated fields that use the exact same + spelling, but merge casing variants as a dictionary merge should have done. + """ + raw_headers = headers.raw + spellings: dict[bytes, set[bytes]] = {} + for name, _ in raw_headers: + spellings.setdefault(name.lower(), set()).add(name) + + collisions = {name for name, variants in spellings.items() if len(variants) > 1} + if not collisions: + return headers + + winning_spellings = { + name.lower(): name for name, _ in raw_headers if name.lower() in collisions + } + return Headers( + [ + (name, value) + for name, value in raw_headers + if name.lower() not in collisions or winning_spellings[name.lower()] == name + ] + ) + + +def merge_request_headers_case_insensitively(request: Request) -> None: + """Merge differently-cased header copies on an HTTPX request in place.""" + request.headers = merge_headers_case_insensitively(request.headers) + + def get_captured_response_headers() -> dict[str, str]: """Get response headers captured from the most recent request in this context. diff --git a/tests/core/features/test_headers.py b/tests/core/features/test_headers.py index 50fcb90c..74eaedc6 100644 --- a/tests/core/features/test_headers.py +++ b/tests/core/features/test_headers.py @@ -6,6 +6,55 @@ from uipath.llm_client.settings.constants import ApiType, RoutingMode +def test_case_insensitive_header_merge_keeps_later_value(): + from uipath.llm_client.utils.headers import merge_headers_case_insensitively + + headers = Headers( + [ + (b"User-Agent", b"provider-sdk"), + (b"user-agent", b"custom-httpx-client"), + ] + ) + + merged = merge_headers_case_insensitively(headers) + + assert merged.raw == [(b"user-agent", b"custom-httpx-client")] + + +def test_case_insensitive_header_merge_preserves_intentional_repeats(): + from uipath.llm_client.utils.headers import merge_headers_case_insensitively + + headers = Headers( + [ + (b"x-test-value", b"first"), + (b"x-test-value", b"second"), + ] + ) + + merged = merge_headers_case_insensitively(headers) + + assert merged.raw == headers.raw + + +def test_case_insensitive_header_merge_preserves_repeated_winning_spelling(): + from uipath.llm_client.utils.headers import merge_headers_case_insensitively + + headers = Headers( + [ + (b"X-Test-Value", b"losing-variant"), + (b"x-test-value", b"first"), + (b"x-test-value", b"second"), + ] + ) + + merged = merge_headers_case_insensitively(headers) + + assert merged.raw == [ + (b"x-test-value", b"first"), + (b"x-test-value", b"second"), + ] + + class TestHeaderUtilities: """Tests for extract_matching_headers and context var functions.""" diff --git a/tests/core/features/test_httpx_client.py b/tests/core/features/test_httpx_client.py index 1fedc4fd..91d00683 100644 --- a/tests/core/features/test_httpx_client.py +++ b/tests/core/features/test_httpx_client.py @@ -23,55 +23,6 @@ def auth_flow(self, request): yield request -def test_case_insensitive_header_merge_keeps_later_value(): - from uipath.llm_client.httpx_client import _merge_headers_case_insensitively - - headers = Headers( - [ - (b"User-Agent", b"provider-sdk"), - (b"user-agent", b"custom-httpx-client"), - ] - ) - - merged = _merge_headers_case_insensitively(headers) - - assert merged.raw == [(b"user-agent", b"custom-httpx-client")] - - -def test_case_insensitive_header_merge_preserves_intentional_repeats(): - from uipath.llm_client.httpx_client import _merge_headers_case_insensitively - - headers = Headers( - [ - (b"x-test-value", b"first"), - (b"x-test-value", b"second"), - ] - ) - - merged = _merge_headers_case_insensitively(headers) - - assert merged.raw == headers.raw - - -def test_case_insensitive_header_merge_preserves_repeated_winning_spelling(): - from uipath.llm_client.httpx_client import _merge_headers_case_insensitively - - headers = Headers( - [ - (b"X-Test-Value", b"losing-variant"), - (b"x-test-value", b"first"), - (b"x-test-value", b"second"), - ] - ) - - merged = _merge_headers_case_insensitively(headers) - - assert merged.raw == [ - (b"x-test-value", b"first"), - (b"x-test-value", b"second"), - ] - - class TestUiPathHttpxClient: """Tests for UiPathHttpxClient."""