Skip to content

Commit ff7262e

Browse files
cosminachoclaude
andauthored
fix: strip constructor-set sampling fields at construction time (#83)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 202136e commit ff7262e

10 files changed

Lines changed: 261 additions & 3 deletions

File tree

CHANGELOG.md

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

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

5+
## [1.11.3] - 2026-05-21
6+
7+
### Added
8+
- `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.
9+
510
## [1.11.2] - 2026-05-18
611

712
### Changed

packages/uipath_langchain_client/CHANGELOG.md

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

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

5+
## [1.11.3] - 2026-05-21
6+
7+
### Fixed
8+
- `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.
9+
10+
### Changed
11+
- Bumped `uipath-llm-client` floor to `>=1.11.3` to match the core release exposing `strip_disabled_fields`.
12+
513
## [1.11.2] - 2026-05-18
614

715
### Changed

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,<2.0.0",
9-
"uipath-llm-client>=1.11.2,<2.0.0",
9+
"uipath-llm-client>=1.11.3,<2.0.0",
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.11.2"
3+
__version__ = "1.11.3"

packages/uipath_langchain_client/src/uipath_langchain_client/base_client.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
)
5252
from uipath.llm_client.utils.sampling import (
5353
disabled_params_from_model_details,
54+
strip_disabled_fields,
5455
strip_disabled_kwargs,
5556
)
5657
from uipath_langchain_client.settings import (
@@ -172,6 +173,13 @@ def setup_model_info(self) -> Self:
172173
can derive from ``model_details`` (via
173174
``disabled_params_from_model_details``). User-provided keys win on
174175
conflicts, so callers can override a derived entry by name.
176+
177+
Once ``disabled_params`` is resolved, any matching instance field set at
178+
construction time is nulled via ``strip_disabled_fields``. Vendor SDKs
179+
that read ``self.<field>`` when serializing requests (langchain-
180+
anthropic, langchain-aws) would otherwise leak disabled values past the
181+
per-call ``strip_disabled_kwargs`` filter. The strip logs a warning per
182+
field so the caller knows what was dropped.
175183
"""
176184
if self.model_details is None:
177185
try:
@@ -188,6 +196,13 @@ def setup_model_info(self) -> Self:
188196
merged = {**derived, **user_provided}
189197
self.disabled_params = merged or None
190198

199+
strip_disabled_fields(
200+
self,
201+
disabled_params=self.disabled_params,
202+
model_name=self.model_name,
203+
logger=self.logger,
204+
)
205+
191206
return self
192207

193208
@cached_property
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.11.2"
3+
__version__ = "1.11.3"

src/uipath/llm_client/utils/sampling.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,42 @@ def strip_disabled_kwargs(
9494
)
9595
out.pop(key, None)
9696
return out
97+
98+
99+
def strip_disabled_fields(
100+
instance: Any,
101+
*,
102+
disabled_params: Mapping[str, Any] | None,
103+
model_name: str,
104+
logger: Logger | None,
105+
) -> None:
106+
"""Null instance attributes that match ``disabled_params``.
107+
108+
Sibling of :func:`strip_disabled_kwargs` for fields set at construction time.
109+
Vendor SDKs that build request bodies from ``self.<field>`` (e.g. langchain-
110+
anthropic's ``ChatAnthropic``, langchain-aws's ``ChatBedrockConverse``) bypass
111+
the kwargs-level strip; this helper neutralizes them once, eagerly, so they
112+
can't leak into any subsequent request.
113+
114+
Matching rule mirrors ``strip_disabled_kwargs``: a field is nulled out when
115+
its name is in ``disabled_params`` AND its current value is non-None AND
116+
``is_disabled_value`` matches the spec. Each strip logs a warning that
117+
includes the original value so the caller can see exactly what was dropped.
118+
"""
119+
if not disabled_params:
120+
return
121+
for key, spec in disabled_params.items():
122+
if not hasattr(instance, key):
123+
continue
124+
current = getattr(instance, key)
125+
if current is None:
126+
continue
127+
if is_disabled_value(current, spec):
128+
if logger is not None:
129+
logger.warning(
130+
"Disabling field %r (was %r) for model %r — parameter is in disabled_params",
131+
key,
132+
current,
133+
model_name,
134+
)
135+
setattr(instance, key, None)

tests/cassettes.db

0 Bytes
Binary file not shown.

tests/langchain/test_disabled_sampling_params.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,135 @@ def test_azure_autoinit_parallel_tool_calls_merges_with_our_derivation(
524524
assert set(llm.disabled_params) == set(DISABLED_SAMPLING_PARAMS) | {"parallel_tool_calls"}
525525

526526

527+
# --------------------------------------------------------------------------- #
528+
# constructor-level field stripping (the second leak)
529+
# --------------------------------------------------------------------------- #
530+
#
531+
# Per-call ``temperature`` lands in ``**kwargs`` and is removed by
532+
# ``strip_disabled_kwargs``. But ``UiPathChat(..., temperature=0.5)`` stores
533+
# the value on ``self.temperature``, and vendor SDKs that don't honor
534+
# langchain-openai's ``_filter_disabled_params`` (langchain-anthropic,
535+
# langchain-aws) read ``self.<field>`` when building the request body —
536+
# leaking the disabled value into the wire payload. ``strip_disabled_fields``
537+
# eagerly nulls matching fields once, inside ``setup_model_info``, so the
538+
# gateway never sees a value the caller already declared disabled. The strip
539+
# is permanent and logs a warning per field so the caller can see exactly
540+
# which value was dropped.
541+
542+
543+
def test_constructor_temperature_is_nulled_when_flag_set(
544+
client_settings: UiPathBaseSettings,
545+
) -> None:
546+
llm = UiPathChat(
547+
model="anthropic.claude-opus-4-7",
548+
settings=client_settings,
549+
model_details={"shouldSkipTemperature": True},
550+
temperature=0.7,
551+
top_p=0.9,
552+
)
553+
# Eager strip: caller-supplied disabled values are nulled before any call.
554+
assert llm.temperature is None
555+
assert llm.top_p is None
556+
557+
558+
def test_constructor_field_strip_skipped_when_flag_absent(
559+
client_settings: UiPathBaseSettings,
560+
) -> None:
561+
llm = UiPathChat(
562+
model="some-chatty-model",
563+
settings=client_settings,
564+
model_details={},
565+
temperature=0.7,
566+
)
567+
# No shouldSkipTemperature => no strip.
568+
assert llm.temperature == 0.7
569+
570+
571+
def test_constructor_field_strip_honors_value_list_spec(
572+
client_settings: UiPathBaseSettings,
573+
) -> None:
574+
# Spec list semantics: strip only when the current value is in the list.
575+
keep = UiPathChat(
576+
model="some-chatty-model",
577+
settings=client_settings,
578+
model_details={},
579+
disabled_params={"temperature": [0.0]},
580+
temperature=0.7, # not in [0.0] -> kept
581+
)
582+
assert keep.temperature == 0.7
583+
584+
drop = UiPathChat(
585+
model="some-chatty-model",
586+
settings=client_settings,
587+
model_details={},
588+
disabled_params={"temperature": [0.0]},
589+
temperature=0.0, # in [0.0] -> stripped
590+
)
591+
assert drop.temperature is None
592+
593+
594+
def test_constructor_field_strip_skips_fields_already_none(
595+
client_settings: UiPathBaseSettings,
596+
) -> None:
597+
# Field not set by caller (default None) => the strip is a no-op for it,
598+
# nothing weird happens to other fields. Just confirms the helper's
599+
# current=None guard.
600+
llm = UiPathChat(
601+
model="anthropic.claude-opus-4-7",
602+
settings=client_settings,
603+
model_details={"shouldSkipTemperature": True},
604+
)
605+
assert llm.temperature is None # default, not from strip
606+
assert llm.disabled_params is not None
607+
assert "temperature" in llm.disabled_params
608+
609+
610+
def test_constructor_field_strip_logs_warning_with_original_value(
611+
client_settings: UiPathBaseSettings,
612+
caplog: pytest.LogCaptureFixture,
613+
) -> None:
614+
logger = logging.getLogger("uipath.test.skip-sampling-field")
615+
with caplog.at_level(logging.WARNING, logger=logger.name):
616+
llm = UiPathChat(
617+
model="anthropic.claude-opus-4-7",
618+
settings=client_settings,
619+
model_details={"shouldSkipTemperature": True},
620+
temperature=0.7,
621+
logger=logger,
622+
)
623+
624+
# Sanity: the strip actually ran.
625+
assert llm.temperature is None
626+
627+
# Warning must include the field name AND the original value so the caller
628+
# knows exactly what was dropped.
629+
matching = [
630+
rec
631+
for rec in caplog.records
632+
if "'temperature'" in rec.getMessage() and "0.7" in rec.getMessage()
633+
]
634+
assert matching, (
635+
f"expected a warning mentioning 'temperature' and the original value 0.7; "
636+
f"got: {[r.getMessage() for r in caplog.records]}"
637+
)
638+
639+
640+
def test_constructor_field_strip_silent_when_logger_is_none(
641+
client_settings: UiPathBaseSettings,
642+
caplog: pytest.LogCaptureFixture,
643+
) -> None:
644+
with caplog.at_level(logging.DEBUG):
645+
llm = UiPathChat(
646+
model="anthropic.claude-opus-4-7",
647+
settings=client_settings,
648+
model_details={"shouldSkipTemperature": True},
649+
temperature=0.7,
650+
logger=None,
651+
)
652+
assert llm.temperature is None
653+
assert not any("Disabling field" in rec.getMessage() for rec in caplog.records)
654+
655+
527656
def test_openai_subclass_runtime_strip_honors_merged_disabled_params(
528657
monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings
529658
) -> None:
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""End-to-end check: constructor-level ``temperature`` survives ``shouldSkipTemperature``.
2+
3+
Recorded against the live LLM Gateway via the SQLite-backed VCR persister
4+
(see ``tests/conftest.py`` and ``tests/sqlite_persister.py``). The cassette
5+
captures a 200 response — which is itself proof that ``strip_disabled_fields``
6+
nulled the constructor-set field before the vendor SDK serialized the request
7+
body. Without the fix, the gateway returns 400 for any sampling-knob value on
8+
``anthropic.claude-opus-4-7`` (modelDetails advertises
9+
``shouldSkipTemperature: True``), and the ``before_record_response`` filter in
10+
``conftest.py`` would refuse to persist the failed exchange.
11+
12+
We exercise both vendor SDK families because they read ``self.temperature`` at
13+
different layers:
14+
- ``UiPathChatAnthropicBedrock`` -> langchain-anthropic's ``ChatAnthropic``
15+
- ``UiPathChatBedrockConverse`` -> langchain-aws's ``ChatBedrockConverse``
16+
"""
17+
18+
import pytest
19+
from langchain_core.messages import HumanMessage
20+
from uipath_langchain_client.clients.bedrock.chat_models import (
21+
UiPathChatAnthropicBedrock,
22+
UiPathChatBedrockConverse,
23+
)
24+
25+
from uipath.llm_client.settings import UiPathBaseSettings
26+
27+
OPUS_4_7 = "anthropic.claude-opus-4-7"
28+
29+
30+
@pytest.mark.vcr
31+
def test_opus_4_7_constructor_temperature_with_anthropic_bedrock(
32+
client_settings: UiPathBaseSettings,
33+
) -> None:
34+
chat = UiPathChatAnthropicBedrock(
35+
model=OPUS_4_7,
36+
settings=client_settings,
37+
# Skip discovery so the cassette only captures the chat completion.
38+
model_details={"shouldSkipTemperature": True},
39+
temperature=0.7,
40+
)
41+
# Eager strip: temperature was nulled at construction so the vendor SDK
42+
# serializes the request body without it.
43+
assert chat.temperature is None
44+
45+
response = chat.invoke([HumanMessage(content="Reply with the single word: pong")])
46+
assert response.content, "expected a non-empty response from the gateway"
47+
48+
49+
@pytest.mark.vcr
50+
def test_opus_4_7_constructor_temperature_with_bedrock_converse(
51+
client_settings: UiPathBaseSettings,
52+
) -> None:
53+
chat = UiPathChatBedrockConverse(
54+
model=OPUS_4_7,
55+
settings=client_settings,
56+
model_details={"shouldSkipTemperature": True},
57+
temperature=0.7,
58+
)
59+
assert chat.temperature is None
60+
61+
response = chat.invoke([HumanMessage(content="Reply with the single word: pong")])
62+
assert response.content

0 commit comments

Comments
 (0)