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

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

## [1.19.0] - 2026-09-09

### Added
- `UiPathHttpxClient` / `UiPathHttpxAsyncClient` now capture the per-request dollar cost the LLM Gateway reports for opted-in calls instead of dropping it. When a request carries `X-UiPath-LlmGateway-IncludeAssociatedDollarCost: true` (Passthrough API only), the value is exposed via `uipath.llm_client.utils.dollar_cost.get_captured_dollar_cost()` for the duration of the calling context. Non-streaming JSON responses are read from the top-level `associated_dollar_cost` field. Streaming responses are wrapped so the trailing frame the gateway appends after the vendor's terminal event — an SSE `data: {"associated_dollar_cost": ...}` event, or a `costMetadata` message on AWS event streams (Bedrock) — is picked up once the consumer finishes reading (including when the vendor SDK stops at `data: [DONE]` and closes the response without reading further). `None` means "not priced" and must never be read as $0; when a cost was requested but could not be read (compressed stream, lost event-stream framing, non-numeric value) a warning is logged so the gap is visible. Known residual: the gateway's SSE frame also reaches google-genai as a candidate-less chunk, so opted-in Gemini streams log langchain-google-genai's "Gemini produced an empty response" warning; the cost is still captured.

## [1.18.5] - 2026-09-07

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

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

## [1.19.0] - 2026-09-09

### Added
- `UiPathBaseChatModel` now surfaces the LLM Gateway's per-request dollar cost as `response_metadata["associated_dollar_cost"]` when the caller opted in by sending `X-UiPath-LlmGateway-IncludeAssociatedDollarCost: true` (e.g. via `default_headers`) on a passthrough model. `invoke()` / `ainvoke()` set it on the result message. `stream()` / `astream()` (and `invoke()` routed through streaming) yield one extra empty chunk carrying it after the vendor's last chunk, since the gateway only reports the cost after the terminal event; merging the chunks folds it into `response_metadata`. Covers all passthrough chat models, including the Bedrock ones: the gateway's trailing `costMetadata` event is decoded by the core client and hidden from langchain-aws. Opted-in Gemini streams additionally log langchain-google-genai's "Gemini produced an empty response" warning for the gateway's trailing frame; the cost is still surfaced. The key is absent when the gateway did not price the call — absence means "not priced", never $0.

### Changed
- Requires `uipath-llm-client>=1.19.0`.

## [1.18.5] - 2026-09-07

### Changed
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,<2.0.0",
"uipath-llm-client>=1.18.5,<2.0.0",
"uipath-llm-client>=1.19.0,<2.0.0",
]

[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.18.5"
__version__ = "1.19.0"
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
)
from langchain_core.embeddings import Embeddings
from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import BaseMessage
from langchain_core.messages import AIMessageChunk, BaseMessage
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
from pydantic import (
AliasChoices,
Expand All @@ -52,6 +52,10 @@
UiPathHttpxAsyncClient,
UiPathHttpxClient,
)
from uipath.llm_client.utils.dollar_cost import (
get_captured_dollar_cost,
set_captured_dollar_cost,
)
from uipath.llm_client.utils.exceptions import wrap_provider_errors
from uipath.llm_client.utils.headers import (
UIPATH_DEFAULT_REQUEST_HEADERS,
Expand Down Expand Up @@ -524,15 +528,20 @@ def _generate(
logger=self.logger,
)
set_captured_response_headers({})
# Models that bypass the UiPath httpx client (litellm) would otherwise
# inherit the value a previous request left in this context.
set_captured_dollar_cost(None)
try:
with wrap_provider_errors():
result = self._uipath_generate(
messages, stop=stop, run_manager=run_manager, **kwargs
)
self._inject_gateway_headers(result.generations)
self._inject_dollar_cost(result.generations)
return result
finally:
set_captured_response_headers({})
set_captured_dollar_cost(None)

def _uipath_generate(
self,
Expand All @@ -558,15 +567,18 @@ async def _agenerate(
logger=self.logger,
)
set_captured_response_headers({})
set_captured_dollar_cost(None)
try:
with wrap_provider_errors():
result = await self._uipath_agenerate(
messages, stop=stop, run_manager=run_manager, **kwargs
)
self._inject_gateway_headers(result.generations)
self._inject_dollar_cost(result.generations)
return result
finally:
set_captured_response_headers({})
set_captured_dollar_cost(None)

async def _uipath_agenerate(
self,
Expand All @@ -592,6 +604,7 @@ def _stream(
logger=self.logger,
)
set_captured_response_headers({})
set_captured_dollar_cost(None)
try:
first = True
with wrap_provider_errors():
Expand All @@ -602,8 +615,12 @@ def _stream(
self._inject_gateway_headers([chunk])
first = False
yield chunk
cost_chunk = self._dollar_cost_chunk()
if cost_chunk is not None:
yield cost_chunk
finally:
set_captured_response_headers({})
set_captured_dollar_cost(None)

def _uipath_stream(
self,
Expand All @@ -629,6 +646,7 @@ async def _astream(
logger=self.logger,
)
set_captured_response_headers({})
set_captured_dollar_cost(None)
try:
first = True
with wrap_provider_errors():
Expand All @@ -639,8 +657,12 @@ async def _astream(
self._inject_gateway_headers([chunk])
first = False
yield chunk
cost_chunk = self._dollar_cost_chunk()
if cost_chunk is not None:
yield cost_chunk
finally:
set_captured_response_headers({})
set_captured_dollar_cost(None)

async def _uipath_astream(
self,
Expand All @@ -663,6 +685,28 @@ def _inject_gateway_headers(self, generations: Sequence[ChatGeneration]) -> None
for generation in generations:
generation.message.response_metadata["headers"] = headers

def _inject_dollar_cost(self, generations: Sequence[ChatGeneration]) -> None:
"""Absent (None) means "not priced" and is never injected as $0."""
cost = get_captured_dollar_cost()
if cost is None:
return
for generation in generations:
generation.message.response_metadata["associated_dollar_cost"] = cost

def _dollar_cost_chunk(self) -> ChatGenerationChunk | None:
"""Trailing empty chunk carrying the cost, or None if not priced.

The cost is only known after the last content chunk was yielded. Holding
chunks back would delay every token, so it rides on an extra empty chunk
(like langchain-openai's usage chunk); merging folds it into response_metadata.
"""
cost = get_captured_dollar_cost()
if cost is None:
return None
return ChatGenerationChunk(
message=AIMessageChunk(content="", response_metadata={"associated_dollar_cost": cost})
)


class UiPathBaseEmbeddings(UiPathBaseLLMClient, Embeddings):
pass
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

from httpx import Client

from uipath.llm_client.utils.dollar_cost import COST_METADATA_EVENT_TYPE

try:
from botocore.eventstream import EventStreamBuffer
except ImportError as e:
Expand Down Expand Up @@ -70,6 +72,10 @@ def _stream_generator(
event_as_dict = event.to_response_dict()
dict_key = event_as_dict["headers"][":event-type"]
dict_value = json.loads(event_as_dict["body"].decode("utf-8"))
if dict_key == COST_METADATA_EVENT_TYPE:
# Gateway cost frame, already captured from the raw bytes by the
# httpx client; langchain-aws raises on unknown events.
continue
if "bytes" in dict_value:
dict_value["bytes"] = base64.b64decode(dict_value["bytes"])
yield {dict_key: dict_value}
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.18.5"
__version__ = "1.19.0"
26 changes: 26 additions & 0 deletions src/uipath/llm_client/httpx_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@
)

from uipath.llm_client.settings.base import UiPathAPIConfig, UiPathBaseSettings
from uipath.llm_client.utils.dollar_cost import (
attach_streaming_dollar_cost_capture,
extract_associated_dollar_cost,
requests_dollar_cost,
set_captured_dollar_cost,
)
from uipath.llm_client.utils.exceptions import patch_raise_for_status
from uipath.llm_client.utils.headers import (
UIPATH_DEFAULT_REQUEST_HEADERS,
Expand All @@ -74,6 +80,24 @@
_DEFAULT_MAX_RETRIES: typing.Final[int] = 5


def _capture_dollar_cost(request: Request, response: Response, stream: bool) -> None:
"""Record the gateway-reported dollar cost of this exchange in the current context.

Gated on the opt-in header: the field can only exist when asked for, and
re-parsing large (embeddings) bodies on every call is not free. Streaming
bodies are not received yet and carry the cost as a trailing frame, so the
stream is wrapped instead. Reset first so an unpriced response reads as None,
not as the previous request's cost.
"""
set_captured_dollar_cost(None)
if not requests_dollar_cost(request):
return
if stream:
attach_streaming_dollar_cost_capture(response)
else:
set_captured_dollar_cost(extract_associated_dollar_cost(response))


class UiPathHttpxClient(Client):
"""Synchronous HTTP client configured for UiPath LLM services.

Expand Down Expand Up @@ -271,6 +295,7 @@ def send(self, request: Request, *, stream: bool = False, **kwargs: Any) -> Resp
captured = extract_matching_headers(response.headers, self._captured_headers)
if captured:
set_captured_response_headers(captured)
_capture_dollar_cost(request, response, stream)
return patch_raise_for_status(response)


Expand Down Expand Up @@ -433,4 +458,5 @@ async def send(self, request: Request, *, stream: bool = False, **kwargs: Any) -
captured = extract_matching_headers(response.headers, self._captured_headers)
if captured:
set_captured_response_headers(captured)
_capture_dollar_cost(request, response, stream)
return patch_raise_for_status(response)
Loading
Loading