From d0ea322a9f974e0b1385dcf986bb0f9a26ddaac1 Mon Sep 17 00:00:00 2001 From: Cosmin Maria Date: Wed, 20 May 2026 16:25:22 +0300 Subject: [PATCH 1/3] fix: strip constructor-set sampling fields at invoke time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `UiPathChatAnthropicBedrock(model="anthropic.claude-opus-4-7", temperature=0.7)` previously leaked the disabled field into the request body because langchain- anthropic and langchain-aws build payloads from `self.`, bypassing the existing kwargs-level `strip_disabled_kwargs`. The gateway then rejected the call with 400 (modelDetails.shouldSkipTemperature: true for reasoning models). Adds `disabled_fields_stripped` context manager in core sampling utils — a sibling of `strip_disabled_kwargs` that temporarily nulls matching instance attributes for the duration of the underlying call and restores them on exit. Wired into the four `_generate`/`_agenerate`/`_stream`/`_astream` wrappers in `UiPathBaseChatModel`, so caller-visible state (`chat.temperature`) is unchanged but the vendor SDK reads `None` while building the request. Plugs the init-time leak called out as a known follow-up in 1.10.0. Tests: - 7 new unit tests pin down the during-call/after-call semantics, exception restoration, value-list spec handling, and warning logging. - 2 new VCR-cassetted integration tests against `anthropic.claude-opus-4-7` exercise both vendor SDK families (anthropic-bedrock + bedrock-converse). The recorded 200 is itself proof of the fix — `before_record_response` drops 4xx, so a pre-fix run would have refused to persist the cassette. Core 1.11.0 -> 1.11.2 (new helper exposed) Langchain 1.11.1 -> 1.11.2 (wiring + dep floor bump) Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 5 + packages/uipath_langchain_client/CHANGELOG.md | 8 + .../uipath_langchain_client/pyproject.toml | 2 +- .../uipath_langchain_client/__version__.py | 2 +- .../uipath_langchain_client/base_client.py | 63 ++++-- src/uipath/llm_client/__version__.py | 2 +- src/uipath/llm_client/utils/sampling.py | 53 ++++- tests/cassettes.db | Bin 46915584 -> 46915584 bytes .../test_disabled_sampling_params.py | 184 ++++++++++++++++++ ...st_disabled_sampling_params_integration.py | 65 +++++++ 10 files changed, 362 insertions(+), 22 deletions(-) create mode 100644 tests/langchain/test_disabled_sampling_params_integration.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d4ac5c46..c55630eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.disabled_fields_stripped`: a context manager that temporarily nulls instance attributes matching `disabled_params` for the duration of the block, then restores them on exit. Sibling of `strip_disabled_kwargs` for the case where vendor SDKs (langchain-anthropic, langchain-aws) read `self.` rather than per-call `**kwargs` when building request bodies. + ## [1.11.2] - 2026-05-18 ### Changed diff --git a/packages/uipath_langchain_client/CHANGELOG.md b/packages/uipath_langchain_client/CHANGELOG.md index 78da3d86..1380a136 100644 --- a/packages/uipath_langchain_client/CHANGELOG.md +++ b/packages/uipath_langchain_client/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to `uipath_langchain_client` will be documented in this file. +## [1.11.3] - 2026-05-21 + +### Fixed +- `UiPathBaseChatModel` now wraps `_uipath_generate`/`_uipath_agenerate`/`_uipath_stream`/`_uipath_astream` in `disabled_fields_stripped`, so constructor-set sampling fields (e.g. `UiPathChatAnthropicBedrock(model="anthropic.claude-opus-4-7", temperature=0.7)`) are nulled on the instance for the duration of the underlying call and restored on exit. 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. + +### Changed +- Bumped `uipath-llm-client` floor to `>=1.11.3` to match the core release exposing `disabled_fields_stripped`. + ## [1.11.2] - 2026-05-18 ### Changed diff --git a/packages/uipath_langchain_client/pyproject.toml b/packages/uipath_langchain_client/pyproject.toml index 87545621..e2ad9930 100644 --- a/packages/uipath_langchain_client/pyproject.toml +++ b/packages/uipath_langchain_client/pyproject.toml @@ -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] diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/__version__.py b/packages/uipath_langchain_client/src/uipath_langchain_client/__version__.py index 127ed99a..82ad7892 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/__version__.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/__version__.py @@ -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" diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/base_client.py b/packages/uipath_langchain_client/src/uipath_langchain_client/base_client.py index a3ee3664..e3fa536f 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/base_client.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/base_client.py @@ -50,6 +50,7 @@ set_captured_response_headers, ) from uipath.llm_client.utils.sampling import ( + disabled_fields_stripped, disabled_params_from_model_details, strip_disabled_kwargs, ) @@ -422,7 +423,15 @@ def _generate( ) set_captured_response_headers({}) try: - result = self._uipath_generate(messages, stop=stop, run_manager=run_manager, **kwargs) + with disabled_fields_stripped( + self, + disabled_params=self.disabled_params, + model_name=self.model_name, + logger=self.logger, + ): + result = self._uipath_generate( + messages, stop=stop, run_manager=run_manager, **kwargs + ) self._inject_gateway_headers(result.generations) return result finally: @@ -453,9 +462,15 @@ async def _agenerate( ) set_captured_response_headers({}) try: - result = await self._uipath_agenerate( - messages, stop=stop, run_manager=run_manager, **kwargs - ) + with disabled_fields_stripped( + self, + disabled_params=self.disabled_params, + model_name=self.model_name, + logger=self.logger, + ): + result = await self._uipath_agenerate( + messages, stop=stop, run_manager=run_manager, **kwargs + ) self._inject_gateway_headers(result.generations) return result finally: @@ -486,14 +501,20 @@ def _stream( ) set_captured_response_headers({}) try: - first = True - for chunk in self._uipath_stream( - messages, stop=stop, run_manager=run_manager, **kwargs + with disabled_fields_stripped( + self, + disabled_params=self.disabled_params, + model_name=self.model_name, + logger=self.logger, ): - if first: - self._inject_gateway_headers([chunk]) - first = False - yield chunk + first = True + for chunk in self._uipath_stream( + messages, stop=stop, run_manager=run_manager, **kwargs + ): + if first: + self._inject_gateway_headers([chunk]) + first = False + yield chunk finally: set_captured_response_headers({}) @@ -522,14 +543,20 @@ async def _astream( ) set_captured_response_headers({}) try: - first = True - async for chunk in self._uipath_astream( - messages, stop=stop, run_manager=run_manager, **kwargs + with disabled_fields_stripped( + self, + disabled_params=self.disabled_params, + model_name=self.model_name, + logger=self.logger, ): - if first: - self._inject_gateway_headers([chunk]) - first = False - yield chunk + first = True + async for chunk in self._uipath_astream( + messages, stop=stop, run_manager=run_manager, **kwargs + ): + if first: + self._inject_gateway_headers([chunk]) + first = False + yield chunk finally: set_captured_response_headers({}) diff --git a/src/uipath/llm_client/__version__.py b/src/uipath/llm_client/__version__.py index 548fbb89..7dda16a8 100644 --- a/src/uipath/llm_client/__version__.py +++ b/src/uipath/llm_client/__version__.py @@ -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" diff --git a/src/uipath/llm_client/utils/sampling.py b/src/uipath/llm_client/utils/sampling.py index 18d1d517..90397b5f 100644 --- a/src/uipath/llm_client/utils/sampling.py +++ b/src/uipath/llm_client/utils/sampling.py @@ -16,7 +16,8 @@ ``anthropic.claude-opus-4-7``), the entire sampling set gets disabled. """ -from collections.abc import Mapping +from collections.abc import Iterator, Mapping +from contextlib import contextmanager from logging import Logger from typing import Any @@ -94,3 +95,53 @@ def strip_disabled_kwargs( ) out.pop(key, None) return out + + +@contextmanager +def disabled_fields_stripped( + instance: Any, + *, + disabled_params: Mapping[str, Any] | None, + model_name: str, + logger: Logger | None, +) -> Iterator[None]: + """Temporarily wipe matching instance attributes for the duration of the block. + + Sibling of :func:`strip_disabled_kwargs` for fields set at construction time. + Vendor SDKs that build request bodies from ``self.`` (e.g. langchain- + anthropic's ``ChatAnthropic``, langchain-aws's ``ChatBedrockConverse``) bypass + the kwargs-level strip; wrapping the underlying ``_generate`` call in this + context manager forces them to read ``None`` for any disabled field, then + restores the original value on exit so caller-visible state is unchanged. + + 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. + """ + if not disabled_params: + yield + return + saved: dict[str, Any] = {} + try: + 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): + saved[key] = current + if logger is not None: + logger.warning( + "Stripping disabled field %r for model %r", + key, + model_name, + ) + # object.__setattr__ bypasses pydantic field validation so we can + # always restore the original — even if the field's declared type + # rejects None. + object.__setattr__(instance, key, None) + yield + finally: + for key, value in saved.items(): + object.__setattr__(instance, key, value) diff --git a/tests/cassettes.db b/tests/cassettes.db index 15c22051d94d527697bebab3228f4c589c512e98..3ab5143c3b451dd1824a0ef5fa7b92b264175292 100644 GIT binary patch delta 4739 zcmbu?c~leE0>|;pFhBs2B)H1CDL2XbdYKwTR25FHFg`;+;J?eluqE4tYia=dZ zSJVx4NB1B->VXU>67@t;s292yMWfy*2K7O)s4t2`{ZM}tkBn#l8i)p=!N`P$prI%M z4MW4x2y`DBi4xH$l!Qj3`%yA_0F6Oo(KwWX9z^5OLudk;h*D7+nuI2!bTkD`MGvEC zXgYcX%|J8JEHoRLQ3kT0Ok_niWJg&j8|9!}GzaCOd{lr6kpnqV5pp3n@}Oc=f=ba` zREFlE`RGx!04+p|P&ukVi_sFa6fHxKp-QwIJ&snODpZYDqE)B{tww9mT2zbHq4mC) zu)5}3ifmAISnu!a8{j_^m*ZxkO;jfyoa;NMNS#GdJ41W#xpjMFpRi2uMTlGHy|K0B zJ@ul$=WLFzCBPRMRH- z-5z&8tHtfMd$8S)`(}r;*liwYHkqxCLbu0NZ1p%?W{|1Ci)V6 zg}z4Lpl{JFbQ^t#zDGZxAJI?fXLJYsf__E6q2JLT=uh-lm7-%XXHO>$I>ga4WZ8SOwgp+opJ?TI?l1`*Ei6C7_SJI7iC-)FN z=|K!6Qfh3Y=_%dus{MnmVlbVxu}woyNiEAkSAE*lx#t2EU1qhp=KA5DQsec*QBtR@ zx?7fjfK93(N#5kcm9t7tueMfO{40_-X#d4`=kFJxrpxNHf*`qqcjQJ5`?ZZyYvn-)e(Awv1SrMvgEa!t~SPbTTAT3=O* z_zPE4$>S%XRckUJ#}|&&fipz_Uel}OM%HIL#!d!W=Jp;xU0%3xNz=l7uveWUQ6>> zIm7$qmA!I%q5oaKJR;WMw!2Dlt#-2~*Y3h-ZjYtVW^vgJ;`YMu=#bWRwhbCM#9|lm zXSbwyok!eqj7VK@Ez}RIEiC8u^%tv0Or9ey&(x%QC&WcdOK^^~e@(6NW|#*{7TGvJ ztjRhNFB;+wh1SUZdr9D45>0xO7}AHtlD;I4^dtRAJTa01WFQ$t1``t*LWYtAGK>r- zBglPZBuONrND>)M?kCCQ0WyY+CF4j6d60}J50MFEB1t7_WD=Q7(#aGul{`$Qk?G_S zGK0({v&d{>CK<#+GKrPgh@E7SY?4EA$sCeL@<{j2@;F&Rsz^0iNmh{>vYMMm{BwM$6Wh4X0Kr)C7CMGh33?&I<7#U7Rko(9;l1N68Br=-ZPm;+4WDFTg z#*q~AAQ?{{A`{3&l1kFZBr=(#lPP2>d6-Ni)5#-b2AN4_k=evdGKhs_5-YJ0JINy1 zB!}dZIV6wdlLAsm9K=bAh>N(1hZK_%QcC8MGBS_MCy$Z^WFc8Z%1H%TOqP(PWEpvk zRFdW7ak7F`k!rG%tRgjJHCaQ}l3KEktS5CukPT!b*+iZoo5>ckm3YZEvYqT8JIRye zDN;|KCcDTpWH)(MZp6=@tj>@^ydkUi$gzG`6`A)WBOA-GJ5N&lZmW81afVCX8r!Yi zBVUnpDlPt1{ohBh2n!S+;ch0VNTgjVNe~&DJZpfIj2HeC+v@87dn+6wt~7b0L~5`v zyhc~IM+SSzKC+)Qkmtw&@;rHg93+Q`kGx0@lb6WL delta 3467 zcmZA3XH*pT8i4VcVS%N&yMTy@x)d8AVpnVkHmqR9hJX#l0;t%fsVFL-2xDKcU_-@% zD;5?D)@U%smS#*$q9!pIQ%u3!=kLw^azFf@GqZE1?97}qZ(7wcw>0E6a=I@`0hN+u z@RlUWcVo-WhWk=rwyTrwt)%GuB%PnG+G2?CmRH!DjkSjEnoK`kmY+_B0xM`>4I9`( z3p?1u0giA&J&1kvFu)lua7BH%p#j{{5FTg*Pc%joG=&jf@P-e3(G1Pe0xi)Be(*;C zS|bo`5QMg9hxX`zj_8EW=z^{YMmKav5A;MYgrGM<5r#hKi+<>j0SL!H48mXx!B7lC z1R^mUBQO%9FdAbJg|Qfi@tA;#h{hzuAQo|$j47CkX_$_9%)m?}U>0U$4(1{eNl3;# z%*O(xU?CP^F_vH{mSH(oAQfpyM+P#n605KpSy+R$$i_OX#|Gpe7kSu-O~}V)6qx;V zg%4|;cn963XOh(mtHHMV0)UR-@8pd#E)hLcPsK*&LvlimhDa_dld7QcZrO z&--N`HR8OsovIHF^-!dnYHwNncb_tiIY!Z>kJDtT#n)rqOvcTn$|X(uNU^S%Jym^p zXq0M7E}3Y)s5OV#W@+>)wqPr^VLNtUCw9SvA{1jc_FymenKk;7x=D7raLHY%ljW%Wzrb2;ZNu%9q#Y{f4=G=y-qFZ4YLctB zn_7F<6p)|hYgCKI-s&O0QHu&o-d}*T8avd?(*HIG&A%OQerluo*zxDgID-nD#W|eE z1ysU1NphZ?>!^2@iHnG< zNPQ7Ekp?2}A`L}6L>h^BiZm8!BGOdEDB>mJE#f0v9QD5PTa#8}_nebC$5|Z9d}Xat zZg}>b zMj+ZC2yM|0?a=`p(FvW=1zi!0Zs?94=!sqkL2ra241LfS{m>r+5RQQuguxhsp%{h; zL}EBbU?fIiG{ztbV=)fnF#!`1jY)_>EaEU3Q!o|NFdgxjftg6aEX>9n%ta!Skc@el zj|E7fZMo(4^e}=_z3s#G4A659^w%m;}d*}&+s|E zz!Q9lr}zqA;~BoexA+dv@d7XLJ$}HCc!i%(i`V!WZ}1C##c%i>Z}A8I#9#Oub$Ew= zcb5U;EsmyKqGjfF`A$$jPQat zeBg^_XpR?a=`p(FvW=1zi!0Zs?94=!sqkL2ra241LfS z{m>r+5RQQuguxhsp%{h;L}EBbU?fIiG{ztbV=)fnF#!`1jY)_>EaEU3Q!o|NFdgxj zftg6aEX>9n%ta!Skc@elj|E7%dHwa8VGYa-W0 QZiw7eEY5|ulvm;Z0dr`` when building the request body — +# leaking the disabled value into the wire payload. The +# ``disabled_fields_stripped`` context manager nulls matching fields for the +# duration of the underlying call and restores them after, so +# ``chat.temperature`` still reads the caller's value but the gateway never +# sees it. + + +def test_constructor_temperature_is_stripped_during_call_and_restored( + monkeypatch: pytest.MonkeyPatch, 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, + ) + # Caller-visible state is untouched by construction. + assert llm.temperature == 0.7 + assert llm.top_p == 0.9 + + snapshot: dict[str, Any] = {} + + def _stub( + messages: Any, stop: Any = None, run_manager: Any = None, **kwargs: Any + ) -> ChatResult: + # Vendor SDKs read `self.` here — must see None for disabled fields. + snapshot["temperature_during_call"] = llm.temperature + snapshot["top_p_during_call"] = llm.top_p + return ChatResult(generations=[ChatGeneration(message=AIMessage(content="ok"))]) + + monkeypatch.setattr(llm, "_uipath_generate", _stub) + + llm.invoke("hi") + + assert snapshot["temperature_during_call"] is None + assert snapshot["top_p_during_call"] is None + # Original values are restored — the strip is invoke-time, not permanent. + assert llm.temperature == 0.7 + assert llm.top_p == 0.9 + + +@pytest.mark.asyncio +async def test_constructor_temperature_is_stripped_during_ainvoke_and_restored( + monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings +) -> None: + llm = UiPathChat( + model="anthropic.claude-opus-4-7", + settings=client_settings, + model_details={"shouldSkipTemperature": True}, + temperature=0.5, + ) + snapshot: dict[str, Any] = {} + + async def _stub( + messages: Any, stop: Any = None, run_manager: Any = None, **kwargs: Any + ) -> ChatResult: + snapshot["temperature_during_call"] = llm.temperature + return ChatResult(generations=[ChatGeneration(message=AIMessage(content="ok"))]) + + monkeypatch.setattr(llm, "_uipath_agenerate", _stub) + + await llm.ainvoke("hi") + + assert snapshot["temperature_during_call"] is None + assert llm.temperature == 0.5 + + +def test_constructor_field_strip_restores_on_underlying_exception( + monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings +) -> None: + # If the vendor SDK raises mid-call, the field must still be restored to + # the original value — otherwise a transient failure permanently nukes + # the caller's settings. + llm = UiPathChat( + model="anthropic.claude-opus-4-7", + settings=client_settings, + model_details={"shouldSkipTemperature": True}, + temperature=0.7, + ) + + def _stub( + messages: Any, stop: Any = None, run_manager: Any = None, **kwargs: Any + ) -> ChatResult: + raise RuntimeError("vendor boom") + + monkeypatch.setattr(llm, "_uipath_generate", _stub) + + with pytest.raises(RuntimeError, match="vendor boom"): + llm.invoke("hi") + + assert llm.temperature == 0.7 + + +def test_constructor_field_strip_skipped_when_flag_absent( + monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings +) -> None: + llm = UiPathChat( + model="some-chatty-model", + settings=client_settings, + model_details={}, + temperature=0.7, + ) + snapshot: dict[str, Any] = {} + + def _stub( + messages: Any, stop: Any = None, run_manager: Any = None, **kwargs: Any + ) -> ChatResult: + snapshot["temperature_during_call"] = llm.temperature + return ChatResult(generations=[ChatGeneration(message=AIMessage(content="ok"))]) + + monkeypatch.setattr(llm, "_uipath_generate", _stub) + + llm.invoke("hi") + + # No shouldSkipTemperature => no strip, value flows through to the SDK. + assert snapshot["temperature_during_call"] == 0.7 + assert llm.temperature == 0.7 + + +def test_constructor_field_strip_honors_value_list_spec( + monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings +) -> None: + # When disabled_params spec is a list, the field is only stripped when + # self. matches one of the listed values. + llm = UiPathChat( + model="some-chatty-model", + settings=client_settings, + model_details={}, + disabled_params={"temperature": [0.0]}, + temperature=0.7, # does not match -> NOT stripped + ) + snapshot: dict[str, Any] = {} + + def _stub( + messages: Any, stop: Any = None, run_manager: Any = None, **kwargs: Any + ) -> ChatResult: + snapshot["temperature_during_call"] = llm.temperature + return ChatResult(generations=[ChatGeneration(message=AIMessage(content="ok"))]) + + monkeypatch.setattr(llm, "_uipath_generate", _stub) + llm.invoke("hi") + assert snapshot["temperature_during_call"] == 0.7 + + +def test_constructor_field_strip_logs_warning_when_logger_set( + monkeypatch: pytest.MonkeyPatch, + client_settings: UiPathBaseSettings, + caplog: pytest.LogCaptureFixture, +) -> None: + logger = logging.getLogger("uipath.test.skip-sampling-field") + llm = UiPathChat( + model="anthropic.claude-opus-4-7", + settings=client_settings, + model_details={"shouldSkipTemperature": True}, + temperature=0.7, + logger=logger, + ) + + def _stub( + messages: Any, stop: Any = None, run_manager: Any = None, **kwargs: Any + ) -> ChatResult: + return ChatResult(generations=[ChatGeneration(message=AIMessage(content="ok"))]) + + monkeypatch.setattr(llm, "_uipath_generate", _stub) + + with caplog.at_level(logging.WARNING, logger=logger.name): + llm.invoke("hi") + + assert any( + "temperature" in rec.getMessage() and "disabled field" in rec.getMessage() + for rec in caplog.records + ), "expected a warning mentioning the disabled field strip" + + def test_openai_subclass_runtime_strip_honors_merged_disabled_params( monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings ) -> None: diff --git a/tests/langchain/test_disabled_sampling_params_integration.py b/tests/langchain/test_disabled_sampling_params_integration.py new file mode 100644 index 00000000..c36798a3 --- /dev/null +++ b/tests/langchain/test_disabled_sampling_params_integration.py @@ -0,0 +1,65 @@ +"""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 +``disabled_fields_stripped`` removed 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`` + +The cassette is body-insensitive (default VCR matcher matches host+method+path), +so the assertions also check ``chat.temperature`` is restored after the call — +verifying the strip is per-call, not permanent. +""" + +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, + ) + response = chat.invoke([HumanMessage(content="Reply with the single word: pong")]) + + assert response.content, "expected a non-empty response from the gateway" + # Per-call strip: caller-visible state survives the invoke. + assert chat.temperature == 0.7 + + +@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, + ) + response = chat.invoke([HumanMessage(content="Reply with the single word: pong")]) + + assert response.content + assert chat.temperature == 0.7 From afe4a3ef40a87caec0c8fbae8e7675608b74cf9f Mon Sep 17 00:00:00 2001 From: Cosmin Maria Date: Wed, 20 May 2026 16:41:54 +0300 Subject: [PATCH 2/3] Switch to eager construction-time strip instead of invoke-time context manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prior approach used a context manager that temporarily nulled matching fields for the duration of each call and restored them on exit, so `chat.temperature` read the caller's original value between invocations. That's sneaky: the gateway will never accept the value, so reporting 0.7 on the instance is a lie that hides the truth from the user. Replace `disabled_fields_stripped` (context manager) with `strip_disabled_fields` (eager, one-shot). Wire it into `UiPathBaseLLMClient.setup_model_info` so the strip happens once, immediately after `disabled_params` resolves. Each strip logs a warning that includes the original value, so the caller sees exactly what was dropped at construction time rather than being silently surprised later. The four `_generate`/`_agenerate`/`_stream`/`_astream` wrappers are now plain again — no context-manager wrapping, no save/restore, no exception edge cases, no generator subtlety. Net effect for callers: - `UiPathChatAnthropicBedrock(model="anthropic.claude-opus-4-7", temperature=0.7)` now logs a warning and leaves `chat.temperature is None`. - Per-call `chat.invoke(..., temperature=0.7)` still gets stripped by the existing `strip_disabled_kwargs` filter, unchanged. Tests updated to assert the field is None after construction (not "restored after call"). Restore-on-exception test removed (no longer applicable). VCR cassettes still replay — the gateway request body is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 2 +- packages/uipath_langchain_client/CHANGELOG.md | 4 +- .../uipath_langchain_client/base_client.py | 78 +++---- src/uipath/llm_client/utils/sampling.py | 60 +++--- .../test_disabled_sampling_params.py | 203 +++++++----------- ...st_disabled_sampling_params_integration.py | 27 ++- 6 files changed, 147 insertions(+), 227 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c55630eb..adacba20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to `uipath_llm_client` (core package) will be documented in ## [1.11.3] - 2026-05-21 ### Added -- `uipath.llm_client.utils.sampling.disabled_fields_stripped`: a context manager that temporarily nulls instance attributes matching `disabled_params` for the duration of the block, then restores them on exit. Sibling of `strip_disabled_kwargs` for the case where vendor SDKs (langchain-anthropic, langchain-aws) read `self.` rather than per-call `**kwargs` when building request bodies. +- `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.` 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 diff --git a/packages/uipath_langchain_client/CHANGELOG.md b/packages/uipath_langchain_client/CHANGELOG.md index 1380a136..6cd7dd6e 100644 --- a/packages/uipath_langchain_client/CHANGELOG.md +++ b/packages/uipath_langchain_client/CHANGELOG.md @@ -5,10 +5,10 @@ All notable changes to `uipath_langchain_client` will be documented in this file ## [1.11.3] - 2026-05-21 ### Fixed -- `UiPathBaseChatModel` now wraps `_uipath_generate`/`_uipath_agenerate`/`_uipath_stream`/`_uipath_astream` in `disabled_fields_stripped`, so constructor-set sampling fields (e.g. `UiPathChatAnthropicBedrock(model="anthropic.claude-opus-4-7", temperature=0.7)`) are nulled on the instance for the duration of the underlying call and restored on exit. 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. +- `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 `disabled_fields_stripped`. +- Bumped `uipath-llm-client` floor to `>=1.11.3` to match the core release exposing `strip_disabled_fields`. ## [1.11.2] - 2026-05-18 diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/base_client.py b/packages/uipath_langchain_client/src/uipath_langchain_client/base_client.py index e3fa536f..df1e0e11 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/base_client.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/base_client.py @@ -50,8 +50,8 @@ set_captured_response_headers, ) from uipath.llm_client.utils.sampling import ( - disabled_fields_stripped, disabled_params_from_model_details, + strip_disabled_fields, strip_disabled_kwargs, ) from uipath_langchain_client.settings import ( @@ -173,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.`` 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: @@ -189,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 @@ -423,15 +437,7 @@ def _generate( ) set_captured_response_headers({}) try: - with disabled_fields_stripped( - self, - disabled_params=self.disabled_params, - model_name=self.model_name, - logger=self.logger, - ): - result = self._uipath_generate( - messages, stop=stop, run_manager=run_manager, **kwargs - ) + result = self._uipath_generate(messages, stop=stop, run_manager=run_manager, **kwargs) self._inject_gateway_headers(result.generations) return result finally: @@ -462,15 +468,9 @@ async def _agenerate( ) set_captured_response_headers({}) try: - with disabled_fields_stripped( - self, - disabled_params=self.disabled_params, - model_name=self.model_name, - logger=self.logger, - ): - result = await self._uipath_agenerate( - messages, stop=stop, run_manager=run_manager, **kwargs - ) + result = await self._uipath_agenerate( + messages, stop=stop, run_manager=run_manager, **kwargs + ) self._inject_gateway_headers(result.generations) return result finally: @@ -501,20 +501,14 @@ def _stream( ) set_captured_response_headers({}) try: - with disabled_fields_stripped( - self, - disabled_params=self.disabled_params, - model_name=self.model_name, - logger=self.logger, + first = True + for chunk in self._uipath_stream( + messages, stop=stop, run_manager=run_manager, **kwargs ): - first = True - for chunk in self._uipath_stream( - messages, stop=stop, run_manager=run_manager, **kwargs - ): - if first: - self._inject_gateway_headers([chunk]) - first = False - yield chunk + if first: + self._inject_gateway_headers([chunk]) + first = False + yield chunk finally: set_captured_response_headers({}) @@ -543,20 +537,14 @@ async def _astream( ) set_captured_response_headers({}) try: - with disabled_fields_stripped( - self, - disabled_params=self.disabled_params, - model_name=self.model_name, - logger=self.logger, + first = True + async for chunk in self._uipath_astream( + messages, stop=stop, run_manager=run_manager, **kwargs ): - first = True - async for chunk in self._uipath_astream( - messages, stop=stop, run_manager=run_manager, **kwargs - ): - if first: - self._inject_gateway_headers([chunk]) - first = False - yield chunk + if first: + self._inject_gateway_headers([chunk]) + first = False + yield chunk finally: set_captured_response_headers({}) diff --git a/src/uipath/llm_client/utils/sampling.py b/src/uipath/llm_client/utils/sampling.py index 90397b5f..0bb643ab 100644 --- a/src/uipath/llm_client/utils/sampling.py +++ b/src/uipath/llm_client/utils/sampling.py @@ -16,8 +16,7 @@ ``anthropic.claude-opus-4-7``), the entire sampling set gets disabled. """ -from collections.abc import Iterator, Mapping -from contextlib import contextmanager +from collections.abc import Mapping from logging import Logger from typing import Any @@ -97,51 +96,42 @@ def strip_disabled_kwargs( return out -@contextmanager -def disabled_fields_stripped( +def strip_disabled_fields( instance: Any, *, disabled_params: Mapping[str, Any] | None, model_name: str, logger: Logger | None, -) -> Iterator[None]: - """Temporarily wipe matching instance attributes for the duration of the block. +) -> 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.`` (e.g. langchain- anthropic's ``ChatAnthropic``, langchain-aws's ``ChatBedrockConverse``) bypass - the kwargs-level strip; wrapping the underlying ``_generate`` call in this - context manager forces them to read ``None`` for any disabled field, then - restores the original value on exit so caller-visible state is unchanged. + 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. + ``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: - yield return - saved: dict[str, Any] = {} - try: - 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): - saved[key] = current - if logger is not None: - logger.warning( - "Stripping disabled field %r for model %r", - key, - model_name, - ) - # object.__setattr__ bypasses pydantic field validation so we can - # always restore the original — even if the field's declared type - # rejects None. - object.__setattr__(instance, key, None) - yield - finally: - for key, value in saved.items(): - object.__setattr__(instance, key, value) + 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, + ) + # object.__setattr__ bypasses pydantic field validation in case the + # field's declared type forbids None. + object.__setattr__(instance, key, None) diff --git a/tests/langchain/test_disabled_sampling_params.py b/tests/langchain/test_disabled_sampling_params.py index 6bb10cf7..67a0c85b 100644 --- a/tests/langchain/test_disabled_sampling_params.py +++ b/tests/langchain/test_disabled_sampling_params.py @@ -533,15 +533,15 @@ def test_azure_autoinit_parallel_tool_calls_merges_with_our_derivation( # the value on ``self.temperature``, and vendor SDKs that don't honor # langchain-openai's ``_filter_disabled_params`` (langchain-anthropic, # langchain-aws) read ``self.`` when building the request body — -# leaking the disabled value into the wire payload. The -# ``disabled_fields_stripped`` context manager nulls matching fields for the -# duration of the underlying call and restores them after, so -# ``chat.temperature`` still reads the caller's value but the gateway never -# sees it. +# 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_stripped_during_call_and_restored( - monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings +def test_constructor_temperature_is_nulled_when_flag_set( + client_settings: UiPathBaseSettings, ) -> None: llm = UiPathChat( model="anthropic.claude-opus-4-7", @@ -550,85 +550,13 @@ def test_constructor_temperature_is_stripped_during_call_and_restored( temperature=0.7, top_p=0.9, ) - # Caller-visible state is untouched by construction. - assert llm.temperature == 0.7 - assert llm.top_p == 0.9 - - snapshot: dict[str, Any] = {} - - def _stub( - messages: Any, stop: Any = None, run_manager: Any = None, **kwargs: Any - ) -> ChatResult: - # Vendor SDKs read `self.` here — must see None for disabled fields. - snapshot["temperature_during_call"] = llm.temperature - snapshot["top_p_during_call"] = llm.top_p - return ChatResult(generations=[ChatGeneration(message=AIMessage(content="ok"))]) - - monkeypatch.setattr(llm, "_uipath_generate", _stub) - - llm.invoke("hi") - - assert snapshot["temperature_during_call"] is None - assert snapshot["top_p_during_call"] is None - # Original values are restored — the strip is invoke-time, not permanent. - assert llm.temperature == 0.7 - assert llm.top_p == 0.9 - - -@pytest.mark.asyncio -async def test_constructor_temperature_is_stripped_during_ainvoke_and_restored( - monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings -) -> None: - llm = UiPathChat( - model="anthropic.claude-opus-4-7", - settings=client_settings, - model_details={"shouldSkipTemperature": True}, - temperature=0.5, - ) - snapshot: dict[str, Any] = {} - - async def _stub( - messages: Any, stop: Any = None, run_manager: Any = None, **kwargs: Any - ) -> ChatResult: - snapshot["temperature_during_call"] = llm.temperature - return ChatResult(generations=[ChatGeneration(message=AIMessage(content="ok"))]) - - monkeypatch.setattr(llm, "_uipath_agenerate", _stub) - - await llm.ainvoke("hi") - - assert snapshot["temperature_during_call"] is None - assert llm.temperature == 0.5 - - -def test_constructor_field_strip_restores_on_underlying_exception( - monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings -) -> None: - # If the vendor SDK raises mid-call, the field must still be restored to - # the original value — otherwise a transient failure permanently nukes - # the caller's settings. - llm = UiPathChat( - model="anthropic.claude-opus-4-7", - settings=client_settings, - model_details={"shouldSkipTemperature": True}, - temperature=0.7, - ) - - def _stub( - messages: Any, stop: Any = None, run_manager: Any = None, **kwargs: Any - ) -> ChatResult: - raise RuntimeError("vendor boom") - - monkeypatch.setattr(llm, "_uipath_generate", _stub) - - with pytest.raises(RuntimeError, match="vendor boom"): - llm.invoke("hi") - - assert llm.temperature == 0.7 + # 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( - monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings + client_settings: UiPathBaseSettings, ) -> None: llm = UiPathChat( model="some-chatty-model", @@ -636,76 +564,93 @@ def test_constructor_field_strip_skipped_when_flag_absent( model_details={}, temperature=0.7, ) - snapshot: dict[str, Any] = {} - - def _stub( - messages: Any, stop: Any = None, run_manager: Any = None, **kwargs: Any - ) -> ChatResult: - snapshot["temperature_during_call"] = llm.temperature - return ChatResult(generations=[ChatGeneration(message=AIMessage(content="ok"))]) - - monkeypatch.setattr(llm, "_uipath_generate", _stub) - - llm.invoke("hi") - - # No shouldSkipTemperature => no strip, value flows through to the SDK. - assert snapshot["temperature_during_call"] == 0.7 + # No shouldSkipTemperature => no strip. assert llm.temperature == 0.7 def test_constructor_field_strip_honors_value_list_spec( - monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings + client_settings: UiPathBaseSettings, ) -> None: - # When disabled_params spec is a list, the field is only stripped when - # self. matches one of the listed values. - llm = UiPathChat( + # 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, # does not match -> NOT stripped + temperature=0.7, # not in [0.0] -> kept ) - snapshot: dict[str, Any] = {} - - def _stub( - messages: Any, stop: Any = None, run_manager: Any = None, **kwargs: Any - ) -> ChatResult: - snapshot["temperature_during_call"] = llm.temperature - return ChatResult(generations=[ChatGeneration(message=AIMessage(content="ok"))]) + assert keep.temperature == 0.7 - monkeypatch.setattr(llm, "_uipath_generate", _stub) - llm.invoke("hi") - assert snapshot["temperature_during_call"] == 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_logs_warning_when_logger_set( - monkeypatch: pytest.MonkeyPatch, +def test_constructor_field_strip_skips_fields_already_none( client_settings: UiPathBaseSettings, - caplog: pytest.LogCaptureFixture, ) -> None: - logger = logging.getLogger("uipath.test.skip-sampling-field") + # 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}, - temperature=0.7, - logger=logger, ) + assert llm.temperature is None # default, not from strip + assert llm.disabled_params is not None + assert "temperature" in llm.disabled_params - def _stub( - messages: Any, stop: Any = None, run_manager: Any = None, **kwargs: Any - ) -> ChatResult: - return ChatResult(generations=[ChatGeneration(message=AIMessage(content="ok"))]) - - monkeypatch.setattr(llm, "_uipath_generate", _stub) +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.invoke("hi") - - assert any( - "temperature" in rec.getMessage() and "disabled field" in rec.getMessage() + 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 - ), "expected a warning mentioning the disabled field strip" + 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( diff --git a/tests/langchain/test_disabled_sampling_params_integration.py b/tests/langchain/test_disabled_sampling_params_integration.py index c36798a3..b94a9178 100644 --- a/tests/langchain/test_disabled_sampling_params_integration.py +++ b/tests/langchain/test_disabled_sampling_params_integration.py @@ -2,21 +2,17 @@ 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 -``disabled_fields_stripped`` removed 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. +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`` - -The cassette is body-insensitive (default VCR matcher matches host+method+path), -so the assertions also check ``chat.temperature`` is restored after the call — -verifying the strip is per-call, not permanent. """ import pytest @@ -42,11 +38,12 @@ def test_opus_4_7_constructor_temperature_with_anthropic_bedrock( model_details={"shouldSkipTemperature": True}, temperature=0.7, ) - response = chat.invoke([HumanMessage(content="Reply with the single word: pong")]) + # 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" - # Per-call strip: caller-visible state survives the invoke. - assert chat.temperature == 0.7 @pytest.mark.vcr @@ -59,7 +56,7 @@ def test_opus_4_7_constructor_temperature_with_bedrock_converse( model_details={"shouldSkipTemperature": True}, temperature=0.7, ) - response = chat.invoke([HumanMessage(content="Reply with the single word: pong")]) + assert chat.temperature is None + response = chat.invoke([HumanMessage(content="Reply with the single word: pong")]) assert response.content - assert chat.temperature == 0.7 From 8d267e993984b4e60ac2a277883a13ff4743fa2a Mon Sep 17 00:00:00 2001 From: Cosmin Maria Date: Wed, 20 May 2026 16:45:05 +0300 Subject: [PATCH 3/3] Nit: use plain setattr in strip_disabled_fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous object.__setattr__ was defensive bypass-validation, but every field in DISABLED_SAMPLING_PARAMS is declared as ` | None = None` on the underlying langchain subclasses, so None is always valid. `strip_disabled_fields` runs inside `@model_validator(mode="after")` and operates on the constructed pydantic model, so plain setattr already goes through `BaseModel.__setattr__` and respects field types. If a future caller tries to disable a non-nullable custom field, raising is the right behavior — better than silently bypassing. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/uipath/llm_client/utils/sampling.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/uipath/llm_client/utils/sampling.py b/src/uipath/llm_client/utils/sampling.py index 0bb643ab..54d1d92f 100644 --- a/src/uipath/llm_client/utils/sampling.py +++ b/src/uipath/llm_client/utils/sampling.py @@ -132,6 +132,4 @@ def strip_disabled_fields( current, model_name, ) - # object.__setattr__ bypasses pydantic field validation in case the - # field's declared type forbids None. - object.__setattr__(instance, key, None) + setattr(instance, key, None)