diff --git a/CHANGELOG.md b/CHANGELOG.md index a91f16f..9205eb4 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 8764d70..859435b 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" diff --git a/src/uipath/llm_client/httpx_client.py b/src/uipath/llm_client/httpx_client.py index dc40ced..9e544aa 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 @@ -264,6 +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) response = super().send(request, stream=stream, **kwargs) if self._captured_headers: captured = extract_matching_headers(response.headers, self._captured_headers) @@ -425,6 +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) 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 87c74f4..8225638 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 50fcb90..74eaedc 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 b19bd38..91d0068 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,16 @@ ) +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 + + class TestUiPathHttpxClient: """Tests for UiPathHttpxClient.""" @@ -239,6 +250,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 +386,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()