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

All notable changes to `uipath_llm_client` (core package) will be documented in this file.

## [1.9.2] - 2026-04-17

### Changed
- `PlatformBaseSettings.build_auth_headers()` now uses the header-name constants from `uipath.platform.common.constants` (lowercase canonical form). HTTP header names are case-insensitive so wire-level behavior is unchanged.
- `UIPATH_PROCESS_KEY` is now URL-encoded (`urllib.parse.quote(..., safe="")`) before being placed in `X-UiPath-ProcessKey`, matching the platform-wide convention.

### Added
- `HEADER_LICENSING_CONTEXT` header populated dynamically from `UiPathConfig.licensing_context` when set.

## [1.9.1] - 2026-04-17

### Added
Expand Down
6 changes: 6 additions & 0 deletions packages/uipath_langchain_client/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

All notable changes to `uipath_langchain_client` will be documented in this file.

## [1.9.2] - 2026-04-17

### Changed
- **Breaking:** captured gateway headers are now exposed on `AIMessage.response_metadata` under the `headers` key (previously `uipath_llmgateway_headers`). Update any consumers that read this key.
- Minimum `uipath-llm-client` bumped to 1.9.2 for the platform-headers refactor and licensing-context support.

## [1.9.1] - 2026-04-17

### Fixed
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.1",
"uipath-llm-client>=1.9.2",
]

[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.1"
__version__ = "1.9.2"
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ class UiPathBaseLLMClient(BaseModel, ABC):
captured_headers: tuple[str, ...] = Field(
default=("x-uipath-",),
description="Case-insensitive response header prefixes to capture from LLM Gateway responses. "
"Captured headers appear in response_metadata under the 'uipath_llmgateway_headers' key. "
"Captured headers appear in response_metadata under the 'headers' key. "
"Set to an empty tuple to disable.",
)

Expand Down Expand Up @@ -343,7 +343,7 @@ class UiPathBaseChatModel(UiPathBaseLLMClient, BaseChatModel):

Wraps _generate/_agenerate/_stream/_astream to automatically read captured headers
from the ContextVar (populated by the httpx client's send()) and inject them into
the AIMessage's response_metadata under the 'uipath_llmgateway_headers' key.
the AIMessage's response_metadata under the 'headers' key.

Dynamic request headers are injected via UiPathDynamicHeadersCallback: set
``run_inline = True`` (already the default) so LangChain calls
Expand Down Expand Up @@ -475,7 +475,7 @@ def _inject_gateway_headers(self, generations: Sequence[ChatGeneration]) -> None
if not headers:
return
for generation in generations:
generation.message.response_metadata["uipath_llmgateway_headers"] = headers
generation.message.response_metadata["headers"] = headers


class UiPathBaseEmbeddings(UiPathBaseLLMClient, Embeddings):
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.1"
__version__ = "1.9.2"
62 changes: 46 additions & 16 deletions src/uipath/llm_client/settings/platform/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,31 @@

from collections.abc import Mapping
from typing import Any, Self
from urllib.parse import quote

from pydantic import Field, SecretStr, model_validator
from typing_extensions import override
from uipath.platform import UiPath
from uipath.platform.common import EndpointManager
from uipath.platform.common._config import UiPathConfig
from uipath.platform.common.constants import (
ENV_BASE_URL,
ENV_FOLDER_KEY,
ENV_JOB_KEY,
ENV_ORGANIZATION_ID,
ENV_PROCESS_KEY,
ENV_TENANT_ID,
ENV_UIPATH_ACCESS_TOKEN,
ENV_UIPATH_TRACE_ID,
HEADER_AGENTHUB_CONFIG,
HEADER_FOLDER_KEY,
HEADER_INTERNAL_ACCOUNT_ID,
HEADER_INTERNAL_TENANT_ID,
HEADER_JOB_KEY,
HEADER_LICENSING_CONTEXT,
HEADER_PROCESS_KEY,
HEADER_TRACE_ID,
)

from uipath.llm_client.settings.base import UiPathAPIConfig, UiPathBaseSettings
from uipath.llm_client.settings.constants import ApiType, RoutingMode
Expand Down Expand Up @@ -34,10 +54,10 @@ class PlatformBaseSettings(UiPathBaseSettings):
"""

# Authentication fields - retrieved from uipath auth as well
access_token: SecretStr = Field(default=..., validation_alias="UIPATH_ACCESS_TOKEN")
base_url: str = Field(default=..., validation_alias="UIPATH_URL")
tenant_id: str = Field(default=..., validation_alias="UIPATH_TENANT_ID")
organization_id: str = Field(default=..., validation_alias="UIPATH_ORGANIZATION_ID")
access_token: SecretStr = Field(default=..., validation_alias=ENV_UIPATH_ACCESS_TOKEN)
base_url: str = Field(default=..., validation_alias=ENV_BASE_URL)
tenant_id: str = Field(default=..., validation_alias=ENV_TENANT_ID)
organization_id: str = Field(default=..., validation_alias=ENV_ORGANIZATION_ID)

# Credentials used for refreshing the access token
client_id: str | None = Field(default=None)
Expand All @@ -49,10 +69,10 @@ class PlatformBaseSettings(UiPathBaseSettings):
)

# Tracing configuration
process_key: str | None = Field(default=None, validation_alias="UIPATH_PROCESS_KEY")
folder_key: str | None = Field(default=None, validation_alias="UIPATH_FOLDER_KEY")
job_key: str | None = Field(default=None, validation_alias="UIPATH_JOB_KEY")
trace_id: str | None = Field(default=None, validation_alias="UIPATH_TRACE_ID")
process_key: str | None = Field(default=None, validation_alias=ENV_PROCESS_KEY)
folder_key: str | None = Field(default=None, validation_alias=ENV_FOLDER_KEY)
job_key: str | None = Field(default=None, validation_alias=ENV_JOB_KEY)
trace_id: str | None = Field(default=None, validation_alias=ENV_UIPATH_TRACE_ID)

@model_validator(mode="after")
def validate_environment(self) -> Self:
Expand Down Expand Up @@ -134,21 +154,31 @@ def build_auth_headers(
model_name: str | None = None,
api_config: UiPathAPIConfig | None = None,
) -> Mapping[str, str]:
"""Build authentication and routing headers for API requests."""
"""Build authentication and routing headers for API requests.

Mirrors the platform-wide header convention (see
``uipath.platform.common.constants``): routing headers come from the
configured org/tenant, tracing headers come from pydantic fields
(which pull from env vars), and licensing context is read dynamically
from ``UiPathConfig`` at call time so updates are picked up without
rebuilding settings.
"""
headers: dict[str, str] = {
"X-UiPath-Internal-AccountId": self.organization_id,
"X-UiPath-Internal-TenantId": self.tenant_id,
HEADER_INTERNAL_ACCOUNT_ID: self.organization_id,
HEADER_INTERNAL_TENANT_ID: self.tenant_id,
}
if self.agenthub_config:
headers["X-UiPath-AgentHub-Config"] = self.agenthub_config
headers[HEADER_AGENTHUB_CONFIG] = self.agenthub_config
if self.process_key:
headers["X-UiPath-ProcessKey"] = self.process_key
headers[HEADER_PROCESS_KEY] = quote(self.process_key, safe="")
if self.folder_key:
headers["X-UiPath-FolderKey"] = self.folder_key
headers[HEADER_FOLDER_KEY] = self.folder_key
if self.job_key:
headers["X-UiPath-JobKey"] = self.job_key
headers[HEADER_JOB_KEY] = self.job_key
if self.trace_id:
headers["X-UiPath-TraceId"] = self.trace_id
headers[HEADER_TRACE_ID] = self.trace_id
if licensing_context := UiPathConfig.licensing_context:
headers[HEADER_LICENSING_CONTEXT] = licensing_context
return headers

@override
Expand Down
29 changes: 21 additions & 8 deletions tests/core/features/settings/test_platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,9 @@ def test_build_auth_headers_has_default_config(self, platform_env_vars, mock_pla
settings = PlatformSettings()
headers = settings.build_auth_headers()
assert headers == {
"X-UiPath-Internal-AccountId": "test-org-id",
"X-UiPath-Internal-TenantId": "test-tenant-id",
"X-UiPath-AgentHub-Config": "agentsruntime",
"x-uipath-internal-accountid": "test-org-id",
"x-uipath-internal-tenantid": "test-tenant-id",
"x-uipath-agenthub-config": "agentsruntime",
}

def test_build_auth_headers_with_tracing(self, platform_env_vars, mock_platform_auth):
Expand All @@ -79,9 +79,22 @@ def test_build_auth_headers_with_tracing(self, platform_env_vars, mock_platform_
with patch.dict(os.environ, env, clear=True):
settings = PlatformSettings()
headers = settings.build_auth_headers()
assert headers["X-UiPath-AgentHub-Config"] == "test-config"
assert headers["X-UiPath-ProcessKey"] == "test-process"
assert headers["X-UiPath-JobKey"] == "test-job"
assert headers["x-uipath-agenthub-config"] == "test-config"
assert headers["x-uipath-processkey"] == "test-process"
assert headers["x-uipath-jobkey"] == "test-job"

def test_build_auth_headers_process_key_is_url_encoded(
self, platform_env_vars, mock_platform_auth
):
"""Process key must be URL-encoded for safe transport in headers."""
env = {
**platform_env_vars,
"UIPATH_PROCESS_KEY": "path/with+special=chars",
}
with patch.dict(os.environ, env, clear=True):
settings = PlatformSettings()
headers = settings.build_auth_headers()
assert headers["x-uipath-processkey"] == "path%2Fwith%2Bspecial%3Dchars"

def test_build_auth_pipeline_returns_auth(self, platform_env_vars, mock_platform_auth):
"""Test build_auth_pipeline returns an Auth instance."""
Expand Down Expand Up @@ -199,8 +212,8 @@ def test_build_auth_headers_only_required_when_no_optional(
settings.job_key = None
headers = settings.build_auth_headers()
assert headers == {
"X-UiPath-Internal-AccountId": "test-org-id",
"X-UiPath-Internal-TenantId": "test-tenant-id",
"x-uipath-internal-accountid": "test-org-id",
"x-uipath-internal-tenantid": "test-tenant-id",
}

def test_validation_requires_all_fields(self, mock_platform_auth):
Expand Down
26 changes: 13 additions & 13 deletions tests/langchain/features/test_captured_headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,8 +329,8 @@ class TestNormalizedClientHeaderCapture:
def test_generate_captures_headers(self, llmgw_settings):
chat = _make_normalized_chat(llmgw_settings)
result = chat.invoke("Hello")
assert "uipath_llmgateway_headers" in result.response_metadata
gateway_headers = result.response_metadata["uipath_llmgateway_headers"]
assert "headers" in result.response_metadata
gateway_headers = result.response_metadata["headers"]
assert "x-uipath-requestid" in gateway_headers
assert "x-uipath-traceid" in gateway_headers
assert "content-type" not in gateway_headers
Expand All @@ -339,8 +339,8 @@ def test_generate_captures_headers(self, llmgw_settings):
async def test_agenerate_captures_headers(self, llmgw_settings):
chat = _make_normalized_chat(llmgw_settings)
result = await chat.ainvoke("Hello")
assert "uipath_llmgateway_headers" in result.response_metadata
gateway_headers = result.response_metadata["uipath_llmgateway_headers"]
assert "headers" in result.response_metadata
gateway_headers = result.response_metadata["headers"]
assert "x-uipath-requestid" in gateway_headers

def test_stream_captures_headers_on_first_chunk(self, llmgw_settings):
Expand All @@ -349,12 +349,12 @@ def test_stream_captures_headers_on_first_chunk(self, llmgw_settings):
assert len(chunks) >= 1
# First chunk should have gateway headers
first_chunk = chunks[0]
assert "uipath_llmgateway_headers" in first_chunk.response_metadata
gateway_headers = first_chunk.response_metadata["uipath_llmgateway_headers"]
assert "headers" in first_chunk.response_metadata
gateway_headers = first_chunk.response_metadata["headers"]
assert "x-uipath-requestid" in gateway_headers
# Later chunks should not have gateway headers
if len(chunks) > 1:
assert "uipath_llmgateway_headers" not in chunks[1].response_metadata
assert "headers" not in chunks[1].response_metadata

@pytest.mark.asyncio
async def test_astream_captures_headers_on_first_chunk(self, llmgw_settings):
Expand All @@ -364,22 +364,22 @@ async def test_astream_captures_headers_on_first_chunk(self, llmgw_settings):
chunks.append(chunk)
assert len(chunks) >= 1
first_chunk = chunks[0]
assert "uipath_llmgateway_headers" in first_chunk.response_metadata
assert "headers" in first_chunk.response_metadata

def test_custom_prefixes(self, llmgw_settings):
chat = _make_normalized_chat(
llmgw_settings,
captured_headers=("x-uipath-", "x-ratelimit-"),
)
result = chat.invoke("Hello")
gateway_headers = result.response_metadata["uipath_llmgateway_headers"]
gateway_headers = result.response_metadata["headers"]
assert "x-uipath-requestid" in gateway_headers
assert "x-ratelimit-remaining" in gateway_headers

def test_disabled_capture(self, llmgw_settings):
chat = _make_normalized_chat(llmgw_settings, captured_headers=())
result = chat.invoke("Hello")
assert "uipath_llmgateway_headers" not in result.response_metadata
assert "headers" not in result.response_metadata

def test_no_matching_headers(self, llmgw_settings):
chat = _make_normalized_chat(
Expand All @@ -388,7 +388,7 @@ def test_no_matching_headers(self, llmgw_settings):
)
result = chat.invoke("Hello")
# No matching headers, so the key should not be present
assert "uipath_llmgateway_headers" not in result.response_metadata
assert "headers" not in result.response_metadata


# ============================================================================
Expand Down Expand Up @@ -417,7 +417,7 @@ def test_inject_gateway_headers_populates_result(self, llmgw_settings):
generations=[ChatGeneration(message=AIMessage(content="test", response_metadata={}))]
)
chat._inject_gateway_headers(result.generations)
assert result.generations[0].message.response_metadata["uipath_llmgateway_headers"] == {
assert result.generations[0].message.response_metadata["headers"] == {
"x-uipath-requestid": "test-123"
}
set_captured_response_headers({})
Expand All @@ -434,5 +434,5 @@ def test_inject_gateway_headers_skipped_when_disabled(self, llmgw_settings):
generations=[ChatGeneration(message=AIMessage(content="test", response_metadata={}))]
)
chat._inject_gateway_headers(result.generations)
assert "uipath_llmgateway_headers" not in result.generations[0].message.response_metadata
assert "headers" not in result.generations[0].message.response_metadata
set_captured_response_headers({})