diff --git a/CHANGELOG.md b/CHANGELOG.md index d4ac5c4..adacba2 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.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 ### Changed diff --git a/packages/uipath_langchain_client/CHANGELOG.md b/packages/uipath_langchain_client/CHANGELOG.md index 78da3d8..6cd7dd6 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 +- `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 diff --git a/packages/uipath_langchain_client/pyproject.toml b/packages/uipath_langchain_client/pyproject.toml index 8754562..e2ad993 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 127ed99..82ad789 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 a3ee366..df1e0e1 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 @@ -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 ( @@ -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.`` 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: @@ -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 diff --git a/src/uipath/llm_client/__version__.py b/src/uipath/llm_client/__version__.py index 548fbb8..7dda16a 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 18d1d51..54d1d92 100644 --- a/src/uipath/llm_client/utils/sampling.py +++ b/src/uipath/llm_client/utils/sampling.py @@ -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.`` (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) diff --git a/tests/cassettes.db b/tests/cassettes.db index 15c2205..3ab5143 100644 Binary files a/tests/cassettes.db and b/tests/cassettes.db differ diff --git a/tests/langchain/test_disabled_sampling_params.py b/tests/langchain/test_disabled_sampling_params.py index 712400d..67a0c85 100644 --- a/tests/langchain/test_disabled_sampling_params.py +++ b/tests/langchain/test_disabled_sampling_params.py @@ -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.`` 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: 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 0000000..b94a917 --- /dev/null +++ b/tests/langchain/test_disabled_sampling_params_integration.py @@ -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