diff --git a/CHANGELOG.md b/CHANGELOG.md index bdee1d0..2b93268 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/uipath_langchain_client/CHANGELOG.md b/packages/uipath_langchain_client/CHANGELOG.md index b26b1a5..3061ab8 100644 --- a/packages/uipath_langchain_client/CHANGELOG.md +++ b/packages/uipath_langchain_client/CHANGELOG.md @@ -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 diff --git a/packages/uipath_langchain_client/pyproject.toml b/packages/uipath_langchain_client/pyproject.toml index edfc140..aa11d84 100644 --- a/packages/uipath_langchain_client/pyproject.toml +++ b/packages/uipath_langchain_client/pyproject.toml @@ -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] diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/__version__.py b/packages/uipath_langchain_client/src/uipath_langchain_client/__version__.py index 93fe019..84b41a2 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/__version__.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/__version__.py @@ -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" diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/base_client.py b/packages/uipath_langchain_client/src/uipath_langchain_client/base_client.py index 7e7badd..d1c1066 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/base_client.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/base_client.py @@ -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.", ) @@ -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 @@ -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): diff --git a/src/uipath/llm_client/__version__.py b/src/uipath/llm_client/__version__.py index e2f783d..771bdf0 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.9.1" +__version__ = "1.9.2" diff --git a/src/uipath/llm_client/settings/platform/settings.py b/src/uipath/llm_client/settings/platform/settings.py index 87bc121..a56ab2c 100644 --- a/src/uipath/llm_client/settings/platform/settings.py +++ b/src/uipath/llm_client/settings/platform/settings.py @@ -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 @@ -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) @@ -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: @@ -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 diff --git a/tests/core/features/settings/test_platform.py b/tests/core/features/settings/test_platform.py index 8e2a53a..b1477e8 100644 --- a/tests/core/features/settings/test_platform.py +++ b/tests/core/features/settings/test_platform.py @@ -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): @@ -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.""" @@ -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): diff --git a/tests/langchain/features/test_captured_headers.py b/tests/langchain/features/test_captured_headers.py index 6a80542..0418779 100644 --- a/tests/langchain/features/test_captured_headers.py +++ b/tests/langchain/features/test_captured_headers.py @@ -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 @@ -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): @@ -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): @@ -364,7 +364,7 @@ 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( @@ -372,14 +372,14 @@ def test_custom_prefixes(self, 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( @@ -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 # ============================================================================ @@ -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({}) @@ -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({})