Skip to content

Commit f5a09a9

Browse files
cosminachoclaude
andauthored
Refactor platform headers; rename captured-headers metadata key (#65)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 96b2a50 commit f5a09a9

9 files changed

Lines changed: 101 additions & 43 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22

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

5+
## [1.9.2] - 2026-04-17
6+
7+
### Changed
8+
- `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.
9+
- `UIPATH_PROCESS_KEY` is now URL-encoded (`urllib.parse.quote(..., safe="")`) before being placed in `X-UiPath-ProcessKey`, matching the platform-wide convention.
10+
11+
### Added
12+
- `HEADER_LICENSING_CONTEXT` header populated dynamically from `UiPathConfig.licensing_context` when set.
13+
514
## [1.9.1] - 2026-04-17
615

716
### Added

packages/uipath_langchain_client/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

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

5+
## [1.9.2] - 2026-04-17
6+
7+
### Changed
8+
- **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.
9+
- Minimum `uipath-llm-client` bumped to 1.9.2 for the platform-headers refactor and licensing-context support.
10+
511
## [1.9.1] - 2026-04-17
612

713
### Fixed

packages/uipath_langchain_client/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ readme = "README.md"
66
requires-python = ">=3.11"
77
dependencies = [
88
"langchain>=1.2.15",
9-
"uipath-llm-client>=1.9.1",
9+
"uipath-llm-client>=1.9.2",
1010
]
1111

1212
[project.optional-dependencies]
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
__title__ = "UiPath LangChain Client"
22
__description__ = "A Python client for interacting with UiPath's LLM services via LangChain."
3-
__version__ = "1.9.1"
3+
__version__ = "1.9.2"

packages/uipath_langchain_client/src/uipath_langchain_client/base_client.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ class UiPathBaseLLMClient(BaseModel, ABC):
115115
captured_headers: tuple[str, ...] = Field(
116116
default=("x-uipath-",),
117117
description="Case-insensitive response header prefixes to capture from LLM Gateway responses. "
118-
"Captured headers appear in response_metadata under the 'uipath_llmgateway_headers' key. "
118+
"Captured headers appear in response_metadata under the 'headers' key. "
119119
"Set to an empty tuple to disable.",
120120
)
121121

@@ -343,7 +343,7 @@ class UiPathBaseChatModel(UiPathBaseLLMClient, BaseChatModel):
343343
344344
Wraps _generate/_agenerate/_stream/_astream to automatically read captured headers
345345
from the ContextVar (populated by the httpx client's send()) and inject them into
346-
the AIMessage's response_metadata under the 'uipath_llmgateway_headers' key.
346+
the AIMessage's response_metadata under the 'headers' key.
347347
348348
Dynamic request headers are injected via UiPathDynamicHeadersCallback: set
349349
``run_inline = True`` (already the default) so LangChain calls
@@ -475,7 +475,7 @@ def _inject_gateway_headers(self, generations: Sequence[ChatGeneration]) -> None
475475
if not headers:
476476
return
477477
for generation in generations:
478-
generation.message.response_metadata["uipath_llmgateway_headers"] = headers
478+
generation.message.response_metadata["headers"] = headers
479479

480480

481481
class UiPathBaseEmbeddings(UiPathBaseLLMClient, Embeddings):
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
__title__ = "UiPath LLM Client"
22
__description__ = "A Python client for interacting with UiPath's LLM services."
3-
__version__ = "1.9.1"
3+
__version__ = "1.9.2"

src/uipath/llm_client/settings/platform/settings.py

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,31 @@
22

33
from collections.abc import Mapping
44
from typing import Any, Self
5+
from urllib.parse import quote
56

67
from pydantic import Field, SecretStr, model_validator
78
from typing_extensions import override
89
from uipath.platform import UiPath
910
from uipath.platform.common import EndpointManager
11+
from uipath.platform.common._config import UiPathConfig
12+
from uipath.platform.common.constants import (
13+
ENV_BASE_URL,
14+
ENV_FOLDER_KEY,
15+
ENV_JOB_KEY,
16+
ENV_ORGANIZATION_ID,
17+
ENV_PROCESS_KEY,
18+
ENV_TENANT_ID,
19+
ENV_UIPATH_ACCESS_TOKEN,
20+
ENV_UIPATH_TRACE_ID,
21+
HEADER_AGENTHUB_CONFIG,
22+
HEADER_FOLDER_KEY,
23+
HEADER_INTERNAL_ACCOUNT_ID,
24+
HEADER_INTERNAL_TENANT_ID,
25+
HEADER_JOB_KEY,
26+
HEADER_LICENSING_CONTEXT,
27+
HEADER_PROCESS_KEY,
28+
HEADER_TRACE_ID,
29+
)
1030

1131
from uipath.llm_client.settings.base import UiPathAPIConfig, UiPathBaseSettings
1232
from uipath.llm_client.settings.constants import ApiType, RoutingMode
@@ -34,10 +54,10 @@ class PlatformBaseSettings(UiPathBaseSettings):
3454
"""
3555

3656
# Authentication fields - retrieved from uipath auth as well
37-
access_token: SecretStr = Field(default=..., validation_alias="UIPATH_ACCESS_TOKEN")
38-
base_url: str = Field(default=..., validation_alias="UIPATH_URL")
39-
tenant_id: str = Field(default=..., validation_alias="UIPATH_TENANT_ID")
40-
organization_id: str = Field(default=..., validation_alias="UIPATH_ORGANIZATION_ID")
57+
access_token: SecretStr = Field(default=..., validation_alias=ENV_UIPATH_ACCESS_TOKEN)
58+
base_url: str = Field(default=..., validation_alias=ENV_BASE_URL)
59+
tenant_id: str = Field(default=..., validation_alias=ENV_TENANT_ID)
60+
organization_id: str = Field(default=..., validation_alias=ENV_ORGANIZATION_ID)
4161

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

5171
# Tracing configuration
52-
process_key: str | None = Field(default=None, validation_alias="UIPATH_PROCESS_KEY")
53-
folder_key: str | None = Field(default=None, validation_alias="UIPATH_FOLDER_KEY")
54-
job_key: str | None = Field(default=None, validation_alias="UIPATH_JOB_KEY")
55-
trace_id: str | None = Field(default=None, validation_alias="UIPATH_TRACE_ID")
72+
process_key: str | None = Field(default=None, validation_alias=ENV_PROCESS_KEY)
73+
folder_key: str | None = Field(default=None, validation_alias=ENV_FOLDER_KEY)
74+
job_key: str | None = Field(default=None, validation_alias=ENV_JOB_KEY)
75+
trace_id: str | None = Field(default=None, validation_alias=ENV_UIPATH_TRACE_ID)
5676

5777
@model_validator(mode="after")
5878
def validate_environment(self) -> Self:
@@ -134,21 +154,31 @@ def build_auth_headers(
134154
model_name: str | None = None,
135155
api_config: UiPathAPIConfig | None = None,
136156
) -> Mapping[str, str]:
137-
"""Build authentication and routing headers for API requests."""
157+
"""Build authentication and routing headers for API requests.
158+
159+
Mirrors the platform-wide header convention (see
160+
``uipath.platform.common.constants``): routing headers come from the
161+
configured org/tenant, tracing headers come from pydantic fields
162+
(which pull from env vars), and licensing context is read dynamically
163+
from ``UiPathConfig`` at call time so updates are picked up without
164+
rebuilding settings.
165+
"""
138166
headers: dict[str, str] = {
139-
"X-UiPath-Internal-AccountId": self.organization_id,
140-
"X-UiPath-Internal-TenantId": self.tenant_id,
167+
HEADER_INTERNAL_ACCOUNT_ID: self.organization_id,
168+
HEADER_INTERNAL_TENANT_ID: self.tenant_id,
141169
}
142170
if self.agenthub_config:
143-
headers["X-UiPath-AgentHub-Config"] = self.agenthub_config
171+
headers[HEADER_AGENTHUB_CONFIG] = self.agenthub_config
144172
if self.process_key:
145-
headers["X-UiPath-ProcessKey"] = self.process_key
173+
headers[HEADER_PROCESS_KEY] = quote(self.process_key, safe="")
146174
if self.folder_key:
147-
headers["X-UiPath-FolderKey"] = self.folder_key
175+
headers[HEADER_FOLDER_KEY] = self.folder_key
148176
if self.job_key:
149-
headers["X-UiPath-JobKey"] = self.job_key
177+
headers[HEADER_JOB_KEY] = self.job_key
150178
if self.trace_id:
151-
headers["X-UiPath-TraceId"] = self.trace_id
179+
headers[HEADER_TRACE_ID] = self.trace_id
180+
if licensing_context := UiPathConfig.licensing_context:
181+
headers[HEADER_LICENSING_CONTEXT] = licensing_context
152182
return headers
153183

154184
@override

tests/core/features/settings/test_platform.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,9 @@ def test_build_auth_headers_has_default_config(self, platform_env_vars, mock_pla
6363
settings = PlatformSettings()
6464
headers = settings.build_auth_headers()
6565
assert headers == {
66-
"X-UiPath-Internal-AccountId": "test-org-id",
67-
"X-UiPath-Internal-TenantId": "test-tenant-id",
68-
"X-UiPath-AgentHub-Config": "agentsruntime",
66+
"x-uipath-internal-accountid": "test-org-id",
67+
"x-uipath-internal-tenantid": "test-tenant-id",
68+
"x-uipath-agenthub-config": "agentsruntime",
6969
}
7070

7171
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_
7979
with patch.dict(os.environ, env, clear=True):
8080
settings = PlatformSettings()
8181
headers = settings.build_auth_headers()
82-
assert headers["X-UiPath-AgentHub-Config"] == "test-config"
83-
assert headers["X-UiPath-ProcessKey"] == "test-process"
84-
assert headers["X-UiPath-JobKey"] == "test-job"
82+
assert headers["x-uipath-agenthub-config"] == "test-config"
83+
assert headers["x-uipath-processkey"] == "test-process"
84+
assert headers["x-uipath-jobkey"] == "test-job"
85+
86+
def test_build_auth_headers_process_key_is_url_encoded(
87+
self, platform_env_vars, mock_platform_auth
88+
):
89+
"""Process key must be URL-encoded for safe transport in headers."""
90+
env = {
91+
**platform_env_vars,
92+
"UIPATH_PROCESS_KEY": "path/with+special=chars",
93+
}
94+
with patch.dict(os.environ, env, clear=True):
95+
settings = PlatformSettings()
96+
headers = settings.build_auth_headers()
97+
assert headers["x-uipath-processkey"] == "path%2Fwith%2Bspecial%3Dchars"
8598

8699
def test_build_auth_pipeline_returns_auth(self, platform_env_vars, mock_platform_auth):
87100
"""Test build_auth_pipeline returns an Auth instance."""
@@ -199,8 +212,8 @@ def test_build_auth_headers_only_required_when_no_optional(
199212
settings.job_key = None
200213
headers = settings.build_auth_headers()
201214
assert headers == {
202-
"X-UiPath-Internal-AccountId": "test-org-id",
203-
"X-UiPath-Internal-TenantId": "test-tenant-id",
215+
"x-uipath-internal-accountid": "test-org-id",
216+
"x-uipath-internal-tenantid": "test-tenant-id",
204217
}
205218

206219
def test_validation_requires_all_fields(self, mock_platform_auth):

tests/langchain/features/test_captured_headers.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -329,8 +329,8 @@ class TestNormalizedClientHeaderCapture:
329329
def test_generate_captures_headers(self, llmgw_settings):
330330
chat = _make_normalized_chat(llmgw_settings)
331331
result = chat.invoke("Hello")
332-
assert "uipath_llmgateway_headers" in result.response_metadata
333-
gateway_headers = result.response_metadata["uipath_llmgateway_headers"]
332+
assert "headers" in result.response_metadata
333+
gateway_headers = result.response_metadata["headers"]
334334
assert "x-uipath-requestid" in gateway_headers
335335
assert "x-uipath-traceid" in gateway_headers
336336
assert "content-type" not in gateway_headers
@@ -339,8 +339,8 @@ def test_generate_captures_headers(self, llmgw_settings):
339339
async def test_agenerate_captures_headers(self, llmgw_settings):
340340
chat = _make_normalized_chat(llmgw_settings)
341341
result = await chat.ainvoke("Hello")
342-
assert "uipath_llmgateway_headers" in result.response_metadata
343-
gateway_headers = result.response_metadata["uipath_llmgateway_headers"]
342+
assert "headers" in result.response_metadata
343+
gateway_headers = result.response_metadata["headers"]
344344
assert "x-uipath-requestid" in gateway_headers
345345

346346
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):
349349
assert len(chunks) >= 1
350350
# First chunk should have gateway headers
351351
first_chunk = chunks[0]
352-
assert "uipath_llmgateway_headers" in first_chunk.response_metadata
353-
gateway_headers = first_chunk.response_metadata["uipath_llmgateway_headers"]
352+
assert "headers" in first_chunk.response_metadata
353+
gateway_headers = first_chunk.response_metadata["headers"]
354354
assert "x-uipath-requestid" in gateway_headers
355355
# Later chunks should not have gateway headers
356356
if len(chunks) > 1:
357-
assert "uipath_llmgateway_headers" not in chunks[1].response_metadata
357+
assert "headers" not in chunks[1].response_metadata
358358

359359
@pytest.mark.asyncio
360360
async def test_astream_captures_headers_on_first_chunk(self, llmgw_settings):
@@ -364,22 +364,22 @@ async def test_astream_captures_headers_on_first_chunk(self, llmgw_settings):
364364
chunks.append(chunk)
365365
assert len(chunks) >= 1
366366
first_chunk = chunks[0]
367-
assert "uipath_llmgateway_headers" in first_chunk.response_metadata
367+
assert "headers" in first_chunk.response_metadata
368368

369369
def test_custom_prefixes(self, llmgw_settings):
370370
chat = _make_normalized_chat(
371371
llmgw_settings,
372372
captured_headers=("x-uipath-", "x-ratelimit-"),
373373
)
374374
result = chat.invoke("Hello")
375-
gateway_headers = result.response_metadata["uipath_llmgateway_headers"]
375+
gateway_headers = result.response_metadata["headers"]
376376
assert "x-uipath-requestid" in gateway_headers
377377
assert "x-ratelimit-remaining" in gateway_headers
378378

379379
def test_disabled_capture(self, llmgw_settings):
380380
chat = _make_normalized_chat(llmgw_settings, captured_headers=())
381381
result = chat.invoke("Hello")
382-
assert "uipath_llmgateway_headers" not in result.response_metadata
382+
assert "headers" not in result.response_metadata
383383

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

393393

394394
# ============================================================================
@@ -417,7 +417,7 @@ def test_inject_gateway_headers_populates_result(self, llmgw_settings):
417417
generations=[ChatGeneration(message=AIMessage(content="test", response_metadata={}))]
418418
)
419419
chat._inject_gateway_headers(result.generations)
420-
assert result.generations[0].message.response_metadata["uipath_llmgateway_headers"] == {
420+
assert result.generations[0].message.response_metadata["headers"] == {
421421
"x-uipath-requestid": "test-123"
422422
}
423423
set_captured_response_headers({})
@@ -434,5 +434,5 @@ def test_inject_gateway_headers_skipped_when_disabled(self, llmgw_settings):
434434
generations=[ChatGeneration(message=AIMessage(content="test", response_metadata={}))]
435435
)
436436
chat._inject_gateway_headers(result.generations)
437-
assert "uipath_llmgateway_headers" not in result.generations[0].message.response_metadata
437+
assert "headers" not in result.generations[0].message.response_metadata
438438
set_captured_response_headers({})

0 commit comments

Comments
 (0)