Skip to content

Commit a48b8d6

Browse files
feat: add response headers to message metadata (#511)
1 parent 20f51b6 commit a48b8d6

7 files changed

Lines changed: 160 additions & 15 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-langchain"
3-
version = "0.5.26"
3+
version = "0.5.27"
44
description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

src/uipath_langchain/chat/bedrock.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from uipath._utils import resource_override
1111
from uipath.utils import EndpointManager
1212

13+
from .header_capture import HeaderCapture
1314
from .retryers.bedrock import AsyncBedrockRetryer, BedrockRetryer
1415
from .supported_models import BedrockModels
1516
from .types import APIFlavor, LLMProvider
@@ -62,6 +63,7 @@ def __init__(
6263
api_flavor: str,
6364
agenthub_config: Optional[str] = None,
6465
byo_connection_id: Optional[str] = None,
66+
header_capture: HeaderCapture | None = None,
6567
):
6668
self.model = model
6769
self.token = token
@@ -70,6 +72,7 @@ def __init__(
7072
self.byo_connection_id = byo_connection_id
7173
self._vendor = "awsbedrock"
7274
self._url: Optional[str] = None
75+
self.header_capture = header_capture
7376

7477
@property
7578
def endpoint(self) -> str:
@@ -91,6 +94,12 @@ def _build_base_url(self) -> str:
9194

9295
return self._url
9396

97+
def _capture_response_headers(self, parsed, model, **kwargs):
98+
if "ResponseMetadata" in parsed:
99+
headers = parsed["ResponseMetadata"].get("HTTPHeaders", {})
100+
if self.header_capture:
101+
self.header_capture.set(dict(headers))
102+
94103
def get_client(self):
95104
client = boto3.client(
96105
"bedrock-runtime",
@@ -106,6 +115,9 @@ def get_client(self):
106115
client.meta.events.register(
107116
"before-send.bedrock-runtime.*", self._modify_request
108117
)
118+
client.meta.events.register(
119+
"after-call.bedrock-runtime.*", self._capture_response_headers
120+
)
109121
return client
110122

111123
def _modify_request(self, request, **kwargs):
@@ -203,6 +215,7 @@ class UiPathChatBedrock(ChatBedrock):
203215
model: str = "" # For tracing serialization
204216
retryer: Optional[Retrying] = None
205217
aretryer: Optional[AsyncRetrying] = None
218+
header_capture: HeaderCapture
206219

207220
def __init__(
208221
self,
@@ -233,17 +246,21 @@ def __init__(
233246
"UIPATH_ACCESS_TOKEN environment variable or token parameter is required"
234247
)
235248

249+
header_capture = HeaderCapture(name=f"bedrock_headers_{id(self)}")
250+
236251
passthrough_client = AwsBedrockCompletionsPassthroughClient(
237252
model=model_name,
238253
token=token,
239254
api_flavor="invoke",
240255
agenthub_config=agenthub_config,
241256
byo_connection_id=byo_connection_id,
257+
header_capture=header_capture,
242258
)
243259

244260
client = passthrough_client.get_client()
245261
kwargs["client"] = client
246262
kwargs["model"] = model_name
263+
kwargs["header_capture"] = header_capture
247264
super().__init__(**kwargs)
248265
self.model = model_name
249266
self.retryer = retryer
@@ -297,7 +314,15 @@ def _generate(
297314
**kwargs: Any,
298315
) -> ChatResult:
299316
messages = self._convert_file_blocks_to_anthropic_documents(messages)
300-
return super()._generate(messages, stop=stop, run_manager=run_manager, **kwargs)
317+
result = super()._generate(
318+
messages,
319+
stop=stop,
320+
run_manager=run_manager,
321+
**kwargs,
322+
)
323+
self.header_capture.attach_to_chat_result(result)
324+
self.header_capture.clear()
325+
return result
301326

302327
def _stream(
303328
self,
@@ -307,9 +332,12 @@ def _stream(
307332
**kwargs: Any,
308333
) -> Iterator[ChatGenerationChunk]:
309334
messages = self._convert_file_blocks_to_anthropic_documents(messages)
310-
yield from super()._stream(
311-
messages, stop=stop, run_manager=run_manager, **kwargs
312-
)
335+
chunks = super()._stream(messages, stop=stop, run_manager=run_manager, **kwargs)
336+
337+
for chunk in chunks:
338+
self.header_capture.attach_to_chat_generation(chunk)
339+
yield chunk
340+
self.header_capture.clear()
313341

314342

315343
def _get_default_retryer() -> BedrockRetryer:
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from enum import StrEnum
2+
3+
4+
class LlmGatewayHeaders(StrEnum):
5+
"""LLM Gateway headers."""
6+
7+
IS_BYO_EXECUTION = "x-uipath-llmgateway-isbyoexecution"
8+
EXECUTION_DEPLOYMENT_TYPE = "x-uipath-llmgateway-executiondeploymenttype"
9+
IS_PII_MASKED = "x-uipath-llmgateway-ispiimasked"
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from contextvars import ContextVar
2+
from typing import Optional
3+
4+
from langchain_core.outputs import ChatGeneration, ChatResult
5+
6+
7+
class HeaderCapture:
8+
"""Captures HTTP response headers and applies them to LangChain generations."""
9+
10+
def __init__(self, name: str = "response_headers"):
11+
"""Initialize with a new context var.
12+
13+
Args:
14+
name: Name for the context var."""
15+
self._headers: ContextVar[Optional[dict[str, str]]] = ContextVar(
16+
name, default=None
17+
)
18+
19+
def set(self, headers: dict[str, str]) -> None:
20+
"""Store headers in this instance's context var."""
21+
self._headers.set(headers)
22+
23+
def clear(self) -> None:
24+
"""Clear stored headers from this instance's context var."""
25+
self._headers.set(None)
26+
27+
def attach_to_chat_generation(
28+
self, generation: ChatGeneration, metadata_key: str = "headers"
29+
) -> None:
30+
"""Attach captured headers to the generation message's response_metadata."""
31+
headers = self._headers.get()
32+
if headers:
33+
generation.message.response_metadata[metadata_key] = headers
34+
35+
def attach_to_chat_result(
36+
self, result: ChatResult, metadata_key: str = "headers"
37+
) -> ChatResult:
38+
"""Attach captured headers to the message response_metadata of each generation."""
39+
for generation in result.generations:
40+
self.attach_to_chat_generation(generation, metadata_key)
41+
return result

src/uipath_langchain/chat/openai.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ def __init__(
140140
api_version=api_version,
141141
validate_base_url=False,
142142
use_responses_api=use_responses_api,
143+
include_response_headers=True,
143144
**kwargs,
144145
)
145146

src/uipath_langchain/chat/vertex.py

Lines changed: 75 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22
import os
3+
from collections.abc import AsyncIterator, Iterator
34
from typing import Any, Optional
45

56
import httpx
@@ -8,12 +9,13 @@
89
CallbackManagerForLLMRun,
910
)
1011
from langchain_core.messages import BaseMessage
11-
from langchain_core.outputs import ChatResult
12+
from langchain_core.outputs import ChatGenerationChunk, ChatResult
1213
from tenacity import AsyncRetrying, Retrying
1314
from uipath._utils import resource_override
1415
from uipath._utils._ssl_context import get_httpx_client_kwargs
1516
from uipath.utils import EndpointManager
1617

18+
from .header_capture import HeaderCapture
1719
from .retryers.vertex import AsyncVertexRetryer, VertexRetryer
1820
from .supported_models import GeminiModels
1921
from .types import APIFlavor, LLMProvider
@@ -70,9 +72,15 @@ def _rewrite_vertex_url(original_url: str, gateway_url: str) -> httpx.URL | None
7072
class _UrlRewriteTransport(httpx.HTTPTransport):
7173
"""Transport that rewrites URLs to redirect to UiPath gateway."""
7274

73-
def __init__(self, gateway_url: str, verify: bool = True):
75+
def __init__(
76+
self,
77+
gateway_url: str,
78+
verify: bool = True,
79+
header_capture: HeaderCapture | None = None,
80+
):
7481
super().__init__(verify=verify)
7582
self.gateway_url = gateway_url
83+
self.header_capture = header_capture
7684

7785
def handle_request(self, request: httpx.Request) -> httpx.Response:
7886
original_url = str(request.url)
@@ -86,15 +94,26 @@ def handle_request(self, request: httpx.Request) -> httpx.Response:
8694
# Update host header to match the new URL
8795
request.headers["host"] = new_url.host
8896
request.url = new_url
89-
return super().handle_request(request)
97+
98+
response = super().handle_request(request)
99+
if self.header_capture:
100+
self.header_capture.set(dict(response.headers))
101+
102+
return response
90103

91104

92105
class _AsyncUrlRewriteTransport(httpx.AsyncHTTPTransport):
93106
"""Async transport that rewrites URLs to redirect to UiPath gateway."""
94107

95-
def __init__(self, gateway_url: str, verify: bool = True):
108+
def __init__(
109+
self,
110+
gateway_url: str,
111+
verify: bool = True,
112+
header_capture: HeaderCapture | None = None,
113+
):
96114
super().__init__(verify=verify)
97115
self.gateway_url = gateway_url
116+
self.header_capture = header_capture
98117

99118
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
100119
original_url = str(request.url)
@@ -108,7 +127,12 @@ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
108127
# Update host header to match the new URL
109128
request.headers["host"] = new_url.host
110129
request.url = new_url
111-
return await super().handle_async_request(request)
130+
131+
response = await super().handle_async_request(request)
132+
if self.header_capture:
133+
self.header_capture.set(dict(response.headers))
134+
135+
return response
112136

113137

114138
class UiPathChatVertex(ChatGoogleGenerativeAI):
@@ -162,17 +186,22 @@ def __init__(
162186
uipath_url = self._build_base_url(model_name)
163187
headers = self._build_headers(token, agenthub_config, byo_connection_id)
164188

189+
header_capture = HeaderCapture(name=f"vertex_headers_{id(self)}")
165190
client_kwargs = get_httpx_client_kwargs()
166191
verify = client_kwargs.get("verify", True)
167192

168193
http_options = genai_types.HttpOptions(
169194
httpx_client=httpx.Client(
170-
transport=_UrlRewriteTransport(uipath_url, verify=verify),
195+
transport=_UrlRewriteTransport(
196+
uipath_url, verify=verify, header_capture=header_capture
197+
),
171198
headers=headers,
172199
**client_kwargs,
173200
),
174201
httpx_async_client=httpx.AsyncClient(
175-
transport=_AsyncUrlRewriteTransport(uipath_url, verify=verify),
202+
transport=_AsyncUrlRewriteTransport(
203+
uipath_url, verify=verify, header_capture=header_capture
204+
),
176205
headers=headers,
177206
**client_kwargs,
178207
),
@@ -205,6 +234,7 @@ def __init__(
205234
self._byo_connection_id = byo_connection_id
206235
self._retryer = retryer
207236
self._aretryer = aretryer
237+
self._header_capture = header_capture
208238

209239
if self.temperature is not None and not 0 <= self.temperature <= 2.0:
210240
raise ValueError("temperature must be in the range [0.0, 2.0]")
@@ -295,7 +325,10 @@ def _generate(
295325
result = super()._generate(
296326
messages, stop=stop, run_manager=run_manager, **kwargs
297327
)
298-
return self._merge_finish_reason_to_response_metadata(result)
328+
result = self._merge_finish_reason_to_response_metadata(result)
329+
self._header_capture.attach_to_chat_result(result)
330+
self._header_capture.clear()
331+
return result
299332

300333
async def _agenerate(
301334
self,
@@ -308,7 +341,40 @@ async def _agenerate(
308341
result = await super()._agenerate(
309342
messages, stop=stop, run_manager=run_manager, **kwargs
310343
)
311-
return self._merge_finish_reason_to_response_metadata(result)
344+
result = self._merge_finish_reason_to_response_metadata(result)
345+
self._header_capture.attach_to_chat_result(result)
346+
self._header_capture.clear()
347+
return result
348+
349+
def _stream(
350+
self,
351+
messages: list[BaseMessage],
352+
stop: list[str] | None = None,
353+
run_manager: CallbackManagerForLLMRun | None = None,
354+
**kwargs: Any,
355+
) -> Iterator[ChatGenerationChunk]:
356+
for chunk in super()._stream(
357+
messages, stop=stop, run_manager=run_manager, **kwargs
358+
):
359+
self._header_capture.attach_to_chat_generation(chunk)
360+
yield chunk
361+
362+
self._header_capture.clear()
363+
364+
async def _astream(
365+
self,
366+
messages: list[BaseMessage],
367+
stop: list[str] | None = None,
368+
run_manager: AsyncCallbackManagerForLLMRun | None = None,
369+
**kwargs: Any,
370+
) -> AsyncIterator[ChatGenerationChunk]:
371+
async for chunk in super()._astream(
372+
messages, stop=stop, run_manager=run_manager, **kwargs
373+
):
374+
self._header_capture.attach_to_chat_generation(chunk)
375+
yield chunk
376+
377+
self._header_capture.clear()
312378

313379

314380
def _get_default_retryer() -> VertexRetryer:

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)