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.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
Expand Down
7 changes: 7 additions & 0 deletions packages/uipath_langchain_client/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

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).
- `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

### Changed
Expand Down
2 changes: 1 addition & 1 deletion packages/uipath_langchain_client/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -45,6 +45,7 @@
UiPathHttpxClient,
)
from uipath.llm_client.utils.headers import (
UIPATH_DEFAULT_REQUEST_HEADERS,
get_captured_response_headers,
set_captured_response_headers,
)
Expand Down Expand Up @@ -86,6 +87,8 @@ class UiPathBaseLLMClient(BaseModel, ABC):
validate_default=True,
)

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)"
)
Expand All @@ -106,11 +109,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-",),
Expand Down Expand Up @@ -151,6 +152,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
Expand All @@ -175,6 +177,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
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.9.4"
__version__ = "1.9.5"
11 changes: 3 additions & 8 deletions src/uipath/llm_client/httpx_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions src/uipath/llm_client/utils/headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
74 changes: 74 additions & 0 deletions tests/langchain/features/test_default_headers_merge.py
Original file line number Diff line number Diff line change
@@ -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"