Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/uipath/llm_client/__version__.py
Original file line number Diff line number Diff line change
@@ -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"
3 changes: 3 additions & 0 deletions src/uipath/llm_client/httpx_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
37 changes: 36 additions & 1 deletion src/uipath/llm_client/utils/headers.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.

Expand Down
49 changes: 49 additions & 0 deletions tests/core/features/test_headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
138 changes: 137 additions & 1 deletion tests/core/features/test_httpx_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."""

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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()
Loading