Skip to content

Commit 5c875c8

Browse files
authored
Custom callback for header injection (#47)
1 parent 62083f6 commit 5c875c8

15 files changed

Lines changed: 861 additions & 243 deletions

File tree

.github/workflows/cd-langchain.yml

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ jobs:
3434
exit 0
3535
fi
3636
37-
echo "Core package version also changed — waiting for 'cd' workflow to succeed..."
37+
CORE_VERSION=$(grep '^__version__' src/uipath/llm_client/__version__.py | sed -n 's/.*"\([^"]*\)".*/\1/p')
38+
echo "Core package version also changed ($CORE_VERSION) — waiting for 'cd' workflow to succeed..."
3839
3940
# Poll the cd workflow for up to 15 minutes
4041
for i in $(seq 1 30); do
@@ -43,9 +44,17 @@ jobs:
4344
4445
case "$STATUS" in
4546
completed:success)
46-
echo "cd workflow succeeded — waiting 120s for PyPI indexing..."
47-
sleep 120
48-
exit 0
47+
echo "cd workflow succeeded — polling PyPI until uipath-llm-client==$CORE_VERSION is available..."
48+
for j in $(seq 1 30); do
49+
if uv pip index versions uipath-llm-client 2>/dev/null | grep -q "$CORE_VERSION"; then
50+
echo "uipath-llm-client==$CORE_VERSION is available on PyPI."
51+
exit 0
52+
fi
53+
echo " Not yet available, retrying in 30s ($j/30)..."
54+
sleep 30
55+
done
56+
echo "::error::uipath-llm-client==$CORE_VERSION never appeared on PyPI (15 min)"
57+
exit 1
4958
;;
5059
completed:*)
5160
echo "::error::cd workflow finished with: $STATUS"

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_llm_client` (core package) will be documented in this file.
44

5+
## [1.5.6] - 2026-03-21
6+
7+
### Feature
8+
- Added `_DYNAMIC_REQUEST_HEADERS` ContextVar and helper functions (`get_dynamic_request_headers`, `set_dynamic_request_headers`) to `utils/headers.py`
9+
- Inject dynamic request headers in httpx `send()` for both sync and async clients
10+
511
## [1.5.5] - 2026-03-19
612

713
### Fix

packages/uipath_langchain_client/CHANGELOG.md

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

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

5+
## [1.5.6] - 2026-03-21
6+
7+
### Feature
8+
- Added `UiPathDynamicHeadersCallback`: extend and implement `get_headers()` to inject custom headers into each LLM gateway request
9+
- Uses `run_inline = True` so `on_chat_model_start`/`on_llm_start` run in the caller's coroutine, ensuring ContextVar mutations propagate to `httpx.send()`
10+
- Cleanup via `on_llm_end`/`on_llm_error`
11+
512
## [1.5.5] - 2026-03-19
613

714
### Fix headers

packages/uipath_langchain_client/pyproject.toml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ dynamic = ["version"]
55
readme = "README.md"
66
requires-python = ">=3.11"
77
dependencies = [
8-
"langchain>=1.2.12",
9-
"uipath-llm-client>=1.5.5",
8+
"langchain>=1.2.13",
9+
"uipath-llm-client>=1.5.6",
1010
]
1111

1212
[project.optional-dependencies]
@@ -17,17 +17,17 @@ google = [
1717
"langchain-google-genai>=4.2.1",
1818
]
1919
anthropic = [
20-
"langchain-anthropic>=1.3.5",
21-
"anthropic[bedrock,vertex]>=0.85.0",
20+
"langchain-anthropic>=1.4.0",
21+
"anthropic[bedrock,vertex]>=0.86.0",
2222
]
2323
aws = [
24-
"langchain-aws[anthropic]>=1.4.0",
24+
"langchain-aws[anthropic]>=1.4.1",
2525
]
2626
vertexai = [
2727
"langchain-google-vertexai>=3.2.2",
2828
]
2929
azure = [
30-
"langchain-azure-ai>=1.1.0",
30+
"langchain-azure-ai>=1.1.1",
3131
]
3232
fireworks = [
3333
"langchain-fireworks>=1.1.0",

packages/uipath_langchain_client/src/uipath_langchain_client/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"""
3434

3535
from uipath_langchain_client.__version__ import __version__
36+
from uipath_langchain_client.callbacks import UiPathDynamicHeadersCallback
3637
from uipath_langchain_client.clients import UiPathChat, UiPathEmbeddings
3738
from uipath_langchain_client.factory import get_chat_model, get_embedding_model
3839
from uipath_langchain_client.settings import (
@@ -47,6 +48,7 @@
4748
"get_embedding_model",
4849
"UiPathChat",
4950
"UiPathEmbeddings",
51+
"UiPathDynamicHeadersCallback",
5052
"get_default_client_settings",
5153
"LLMGatewaySettings",
5254
"PlatformSettings",
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.5.5"
3+
__version__ = "1.5.6"

packages/uipath_langchain_client/src/uipath_langchain_client/base_client.py

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@
3030
from typing import Any, Literal
3131

3232
from httpx import URL, Response
33+
from langchain_core.callbacks import (
34+
AsyncCallbackManagerForLLMRun,
35+
CallbackManagerForLLMRun,
36+
)
3337
from langchain_core.embeddings import Embeddings
3438
from langchain_core.language_models.chat_models import BaseChatModel
3539
from langchain_core.messages import BaseMessage
@@ -322,72 +326,128 @@ class UiPathBaseChatModel(UiPathBaseLLMClient, BaseChatModel):
322326
from the ContextVar (populated by the httpx client's send()) and inject them into
323327
the AIMessage's response_metadata under the 'uipath_llmgateway_headers' key.
324328
329+
Dynamic request headers are injected via UiPathDynamicHeadersCallback: set
330+
``run_inline = True`` (already the default) so LangChain calls
331+
``on_chat_model_start`` in the same coroutine as ``_agenerate``, ensuring the
332+
ContextVar is visible when ``httpx.send()`` fires.
333+
325334
Passthrough clients that delegate to vendor SDKs should inherit from this class
326335
so that headers are captured transparently.
327336
"""
328337

329338
def _generate(
330339
self,
331340
messages: list[BaseMessage],
332-
*args: Any,
341+
stop: list[str] | None = None,
342+
run_manager: CallbackManagerForLLMRun | None = None,
333343
**kwargs: Any,
334344
) -> ChatResult:
335345
set_captured_response_headers({})
336346
try:
337-
result = super()._generate(messages, *args, **kwargs)
347+
result = self._uipath_generate(messages, stop=stop, run_manager=run_manager, **kwargs)
338348
self._inject_gateway_headers(result.generations)
339349
return result
340350
finally:
341351
set_captured_response_headers({})
342352

353+
def _uipath_generate(
354+
self,
355+
messages: list[BaseMessage],
356+
stop: list[str] | None = None,
357+
run_manager: CallbackManagerForLLMRun | None = None,
358+
**kwargs: Any,
359+
) -> ChatResult:
360+
"""Override in subclasses to provide the core (non-wrapped) generate logic."""
361+
return super()._generate(messages, stop=stop, run_manager=run_manager, **kwargs)
362+
343363
async def _agenerate(
344364
self,
345365
messages: list[BaseMessage],
346-
*args: Any,
366+
stop: list[str] | None = None,
367+
run_manager: AsyncCallbackManagerForLLMRun | None = None,
347368
**kwargs: Any,
348369
) -> ChatResult:
349370
set_captured_response_headers({})
350371
try:
351-
result = await super()._agenerate(messages, *args, **kwargs)
372+
result = await self._uipath_agenerate(
373+
messages, stop=stop, run_manager=run_manager, **kwargs
374+
)
352375
self._inject_gateway_headers(result.generations)
353376
return result
354377
finally:
355378
set_captured_response_headers({})
356379

380+
async def _uipath_agenerate(
381+
self,
382+
messages: list[BaseMessage],
383+
stop: list[str] | None = None,
384+
run_manager: AsyncCallbackManagerForLLMRun | None = None,
385+
**kwargs: Any,
386+
) -> ChatResult:
387+
"""Override in subclasses to provide the core (non-wrapped) async generate logic."""
388+
return await super()._agenerate(messages, stop=stop, run_manager=run_manager, **kwargs)
389+
357390
def _stream(
358391
self,
359392
messages: list[BaseMessage],
360-
*args: Any,
393+
stop: list[str] | None = None,
394+
run_manager: CallbackManagerForLLMRun | None = None,
361395
**kwargs: Any,
362396
) -> Iterator[ChatGenerationChunk]:
363397
set_captured_response_headers({})
364398
try:
365399
first = True
366-
for chunk in super()._stream(messages, *args, **kwargs):
400+
for chunk in self._uipath_stream(
401+
messages, stop=stop, run_manager=run_manager, **kwargs
402+
):
367403
if first:
368404
self._inject_gateway_headers([chunk])
369405
first = False
370406
yield chunk
371407
finally:
372408
set_captured_response_headers({})
373409

410+
def _uipath_stream(
411+
self,
412+
messages: list[BaseMessage],
413+
stop: list[str] | None = None,
414+
run_manager: CallbackManagerForLLMRun | None = None,
415+
**kwargs: Any,
416+
) -> Iterator[ChatGenerationChunk]:
417+
"""Override in subclasses to provide the core (non-wrapped) stream logic."""
418+
yield from super()._stream(messages, stop=stop, run_manager=run_manager, **kwargs)
419+
374420
async def _astream(
375421
self,
376422
messages: list[BaseMessage],
377-
*args: Any,
423+
stop: list[str] | None = None,
424+
run_manager: AsyncCallbackManagerForLLMRun | None = None,
378425
**kwargs: Any,
379426
) -> AsyncIterator[ChatGenerationChunk]:
380427
set_captured_response_headers({})
381428
try:
382429
first = True
383-
async for chunk in super()._astream(messages, *args, **kwargs):
430+
async for chunk in self._uipath_astream(
431+
messages, stop=stop, run_manager=run_manager, **kwargs
432+
):
384433
if first:
385434
self._inject_gateway_headers([chunk])
386435
first = False
387436
yield chunk
388437
finally:
389438
set_captured_response_headers({})
390439

440+
async def _uipath_astream(
441+
self,
442+
messages: list[BaseMessage],
443+
stop: list[str] | None = None,
444+
run_manager: AsyncCallbackManagerForLLMRun | None = None,
445+
**kwargs: Any,
446+
) -> AsyncIterator[ChatGenerationChunk]:
447+
"""Override in subclasses to provide the core (non-wrapped) async stream logic."""
448+
async for chunk in super()._astream(messages, stop=stop, run_manager=run_manager, **kwargs):
449+
yield chunk
450+
391451
def _inject_gateway_headers(self, generations: Sequence[ChatGeneration]) -> None:
392452
"""Inject captured gateway headers into each generation's response_metadata."""
393453
if not self.captured_headers:
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""LangChain callbacks for dynamic per-request header injection."""
2+
3+
from abc import abstractmethod
4+
from typing import Any
5+
6+
from langchain_core.callbacks import BaseCallbackHandler
7+
8+
from uipath.llm_client.utils.headers import set_dynamic_request_headers
9+
10+
11+
class UiPathDynamicHeadersCallback(BaseCallbackHandler):
12+
"""Base callback for injecting dynamic headers into each LLM gateway request.
13+
14+
Extend this class and implement ``get_headers()`` to return the headers to
15+
inject. ``run_inline = True`` ensures ``on_chat_model_start`` is called
16+
directly in the caller's coroutine (not via ``asyncio.gather``), so the
17+
ContextVar mutation is visible when ``httpx.send()`` fires.
18+
19+
Example (OTEL trace propagation)::
20+
21+
from opentelemetry import trace, propagate
22+
23+
class OtelHeadersCallback(UiPathDynamicHeadersCallback):
24+
def get_headers(self) -> dict[str, str]:
25+
carrier: dict[str, str] = {}
26+
propagate.inject(carrier)
27+
return carrier
28+
29+
chat = get_chat_model(model_name="gpt-4o", client_settings=settings)
30+
response = chat.invoke("Hello!", config={"callbacks": [OtelHeadersCallback()]})
31+
"""
32+
33+
run_inline: bool = True # dispatch in the caller's coroutine, not via asyncio.gather
34+
35+
@abstractmethod
36+
def get_headers(self) -> dict[str, str]:
37+
"""Return headers to inject into the next LLM gateway request."""
38+
...
39+
40+
def on_chat_model_start(
41+
self,
42+
serialized: dict[str, Any],
43+
messages: list[list[Any]],
44+
**kwargs: Any,
45+
) -> None:
46+
set_dynamic_request_headers(self.get_headers())
47+
48+
def on_llm_start(
49+
self,
50+
serialized: dict[str, Any],
51+
prompts: list[str],
52+
**kwargs: Any,
53+
) -> None:
54+
set_dynamic_request_headers(self.get_headers())
55+
56+
def on_llm_end(self, response: Any, **kwargs: Any) -> None:
57+
set_dynamic_request_headers({})
58+
59+
def on_llm_error(self, error: BaseException, **kwargs: Any) -> None:
60+
set_dynamic_request_headers({})

0 commit comments

Comments
 (0)