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.11.3] - 2026-05-21

### Added
- `uipath.llm_client.utils.sampling.strip_disabled_fields`: eagerly nulls instance attributes whose names appear in `disabled_params` and whose current values match `is_disabled_value`. Sibling of `strip_disabled_kwargs` for the case where vendor SDKs (langchain-anthropic, langchain-aws) read `self.<field>` rather than per-call `**kwargs` when building request bodies. Each strip logs a warning that includes the original value so callers can see exactly what was dropped.

## [1.11.2] - 2026-05-18

### Changed
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.11.3] - 2026-05-21

### Fixed
- `UiPathBaseLLMClient.setup_model_info` now calls `strip_disabled_fields` after merging `disabled_params`, so constructor-set sampling fields (e.g. `UiPathChatAnthropicBedrock(model="anthropic.claude-opus-4-7", temperature=0.7)`) are nulled on the instance once `disabled_params` is resolved. Plugs the init-time leak called out as a known follow-up in 1.10.0 — langchain-anthropic and langchain-aws's Bedrock Converse client read `self.temperature`/`self.top_p`/etc. when serializing the request body, so the existing kwargs-level strip alone wasn't enough. A warning is logged per stripped field with the original value so the caller can see what was dropped.

### Changed
- Bumped `uipath-llm-client` floor to `>=1.11.3` to match the core release exposing `strip_disabled_fields`.

## [1.11.2] - 2026-05-18

### 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.11.2,<2.0.0",
"uipath-llm-client>=1.11.3,<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.11.2"
__version__ = "1.11.3"
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
)
from uipath.llm_client.utils.sampling import (
disabled_params_from_model_details,
strip_disabled_fields,
strip_disabled_kwargs,
)
from uipath_langchain_client.settings import (
Expand Down Expand Up @@ -172,6 +173,13 @@ def setup_model_info(self) -> Self:
can derive from ``model_details`` (via
``disabled_params_from_model_details``). User-provided keys win on
conflicts, so callers can override a derived entry by name.

Once ``disabled_params`` is resolved, any matching instance field set at
construction time is nulled via ``strip_disabled_fields``. Vendor SDKs
that read ``self.<field>`` when serializing requests (langchain-
anthropic, langchain-aws) would otherwise leak disabled values past the
per-call ``strip_disabled_kwargs`` filter. The strip logs a warning per
field so the caller knows what was dropped.
"""
if self.model_details is None:
try:
Expand All @@ -188,6 +196,13 @@ def setup_model_info(self) -> Self:
merged = {**derived, **user_provided}
self.disabled_params = merged or None

strip_disabled_fields(
self,
disabled_params=self.disabled_params,
model_name=self.model_name,
logger=self.logger,
)

return self

@cached_property
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.11.2"
__version__ = "1.11.3"
39 changes: 39 additions & 0 deletions src/uipath/llm_client/utils/sampling.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,42 @@ def strip_disabled_kwargs(
)
out.pop(key, None)
return out


def strip_disabled_fields(
instance: Any,
*,
disabled_params: Mapping[str, Any] | None,
model_name: str,
logger: Logger | None,
) -> None:
"""Null instance attributes that match ``disabled_params``.

Sibling of :func:`strip_disabled_kwargs` for fields set at construction time.
Vendor SDKs that build request bodies from ``self.<field>`` (e.g. langchain-
anthropic's ``ChatAnthropic``, langchain-aws's ``ChatBedrockConverse``) bypass
the kwargs-level strip; this helper neutralizes them once, eagerly, so they
can't leak into any subsequent request.

Matching rule mirrors ``strip_disabled_kwargs``: a field is nulled out when
its name is in ``disabled_params`` AND its current value is non-None AND
``is_disabled_value`` matches the spec. Each strip logs a warning that
includes the original value so the caller can see exactly what was dropped.
"""
if not disabled_params:
return
for key, spec in disabled_params.items():
if not hasattr(instance, key):
continue
current = getattr(instance, key)
if current is None:
continue
if is_disabled_value(current, spec):
if logger is not None:
logger.warning(
"Disabling field %r (was %r) for model %r — parameter is in disabled_params",
key,
current,
model_name,
)
setattr(instance, key, None)
Binary file modified tests/cassettes.db
Binary file not shown.
129 changes: 129 additions & 0 deletions tests/langchain/test_disabled_sampling_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,135 @@ def test_azure_autoinit_parallel_tool_calls_merges_with_our_derivation(
assert set(llm.disabled_params) == set(DISABLED_SAMPLING_PARAMS) | {"parallel_tool_calls"}


# --------------------------------------------------------------------------- #
# constructor-level field stripping (the second leak)
# --------------------------------------------------------------------------- #
#
# Per-call ``temperature`` lands in ``**kwargs`` and is removed by
# ``strip_disabled_kwargs``. But ``UiPathChat(..., temperature=0.5)`` stores
# the value on ``self.temperature``, and vendor SDKs that don't honor
# langchain-openai's ``_filter_disabled_params`` (langchain-anthropic,
# langchain-aws) read ``self.<field>`` when building the request body —
# leaking the disabled value into the wire payload. ``strip_disabled_fields``
# eagerly nulls matching fields once, inside ``setup_model_info``, so the
# gateway never sees a value the caller already declared disabled. The strip
# is permanent and logs a warning per field so the caller can see exactly
# which value was dropped.


def test_constructor_temperature_is_nulled_when_flag_set(
client_settings: UiPathBaseSettings,
) -> None:
llm = UiPathChat(
model="anthropic.claude-opus-4-7",
settings=client_settings,
model_details={"shouldSkipTemperature": True},
temperature=0.7,
top_p=0.9,
)
# Eager strip: caller-supplied disabled values are nulled before any call.
assert llm.temperature is None
assert llm.top_p is None


def test_constructor_field_strip_skipped_when_flag_absent(
client_settings: UiPathBaseSettings,
) -> None:
llm = UiPathChat(
model="some-chatty-model",
settings=client_settings,
model_details={},
temperature=0.7,
)
# No shouldSkipTemperature => no strip.
assert llm.temperature == 0.7


def test_constructor_field_strip_honors_value_list_spec(
client_settings: UiPathBaseSettings,
) -> None:
# Spec list semantics: strip only when the current value is in the list.
keep = UiPathChat(
model="some-chatty-model",
settings=client_settings,
model_details={},
disabled_params={"temperature": [0.0]},
temperature=0.7, # not in [0.0] -> kept
)
assert keep.temperature == 0.7

drop = UiPathChat(
model="some-chatty-model",
settings=client_settings,
model_details={},
disabled_params={"temperature": [0.0]},
temperature=0.0, # in [0.0] -> stripped
)
assert drop.temperature is None


def test_constructor_field_strip_skips_fields_already_none(
client_settings: UiPathBaseSettings,
) -> None:
# Field not set by caller (default None) => the strip is a no-op for it,
# nothing weird happens to other fields. Just confirms the helper's
# current=None guard.
llm = UiPathChat(
model="anthropic.claude-opus-4-7",
settings=client_settings,
model_details={"shouldSkipTemperature": True},
)
assert llm.temperature is None # default, not from strip
assert llm.disabled_params is not None
assert "temperature" in llm.disabled_params


def test_constructor_field_strip_logs_warning_with_original_value(
client_settings: UiPathBaseSettings,
caplog: pytest.LogCaptureFixture,
) -> None:
logger = logging.getLogger("uipath.test.skip-sampling-field")
with caplog.at_level(logging.WARNING, logger=logger.name):
llm = UiPathChat(
model="anthropic.claude-opus-4-7",
settings=client_settings,
model_details={"shouldSkipTemperature": True},
temperature=0.7,
logger=logger,
)

# Sanity: the strip actually ran.
assert llm.temperature is None

# Warning must include the field name AND the original value so the caller
# knows exactly what was dropped.
matching = [
rec
for rec in caplog.records
if "'temperature'" in rec.getMessage() and "0.7" in rec.getMessage()
]
assert matching, (
f"expected a warning mentioning 'temperature' and the original value 0.7; "
f"got: {[r.getMessage() for r in caplog.records]}"
)


def test_constructor_field_strip_silent_when_logger_is_none(
client_settings: UiPathBaseSettings,
caplog: pytest.LogCaptureFixture,
) -> None:
with caplog.at_level(logging.DEBUG):
llm = UiPathChat(
model="anthropic.claude-opus-4-7",
settings=client_settings,
model_details={"shouldSkipTemperature": True},
temperature=0.7,
logger=None,
)
assert llm.temperature is None
assert not any("Disabling field" in rec.getMessage() for rec in caplog.records)


def test_openai_subclass_runtime_strip_honors_merged_disabled_params(
monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings
) -> None:
Expand Down
62 changes: 62 additions & 0 deletions tests/langchain/test_disabled_sampling_params_integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""End-to-end check: constructor-level ``temperature`` survives ``shouldSkipTemperature``.

Recorded against the live LLM Gateway via the SQLite-backed VCR persister
(see ``tests/conftest.py`` and ``tests/sqlite_persister.py``). The cassette
captures a 200 response — which is itself proof that ``strip_disabled_fields``
nulled the constructor-set field before the vendor SDK serialized the request
body. Without the fix, the gateway returns 400 for any sampling-knob value on
``anthropic.claude-opus-4-7`` (modelDetails advertises
``shouldSkipTemperature: True``), and the ``before_record_response`` filter in
``conftest.py`` would refuse to persist the failed exchange.

We exercise both vendor SDK families because they read ``self.temperature`` at
different layers:
- ``UiPathChatAnthropicBedrock`` -> langchain-anthropic's ``ChatAnthropic``
- ``UiPathChatBedrockConverse`` -> langchain-aws's ``ChatBedrockConverse``
"""

import pytest
from langchain_core.messages import HumanMessage
from uipath_langchain_client.clients.bedrock.chat_models import (
UiPathChatAnthropicBedrock,
UiPathChatBedrockConverse,
)

from uipath.llm_client.settings import UiPathBaseSettings

OPUS_4_7 = "anthropic.claude-opus-4-7"


@pytest.mark.vcr
def test_opus_4_7_constructor_temperature_with_anthropic_bedrock(
client_settings: UiPathBaseSettings,
) -> None:
chat = UiPathChatAnthropicBedrock(
model=OPUS_4_7,
settings=client_settings,
# Skip discovery so the cassette only captures the chat completion.
model_details={"shouldSkipTemperature": True},
temperature=0.7,
)
# Eager strip: temperature was nulled at construction so the vendor SDK
# serializes the request body without it.
assert chat.temperature is None

response = chat.invoke([HumanMessage(content="Reply with the single word: pong")])
assert response.content, "expected a non-empty response from the gateway"


@pytest.mark.vcr
def test_opus_4_7_constructor_temperature_with_bedrock_converse(
client_settings: UiPathBaseSettings,
) -> None:
chat = UiPathChatBedrockConverse(
model=OPUS_4_7,
settings=client_settings,
model_details={"shouldSkipTemperature": True},
temperature=0.7,
)
assert chat.temperature is None

response = chat.invoke([HumanMessage(content="Reply with the single word: pong")])
assert response.content
Loading