From 3ac80dba8a6a36c2f06df4c6baa2e17a796712b7 Mon Sep 17 00:00:00 2001 From: Cosmin Maria Date: Thu, 23 Apr 2026 17:23:29 +0300 Subject: [PATCH 01/14] Feat: strip sampling params at invoke time when modelDetails.shouldSkipTemperature is true MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reasoning-style models (anthropic.claude-opus-4-7) reject any sampling parameter and the gateway returns 400. The discovery endpoint already advertises this via modelDetails.shouldSkipTemperature. We now honor it: - Add model_details: dict | None on UiPathBaseLLMClient. - Lazy-resolve it via client_settings.get_model_info on first _skip_sampling call (backed by the class-cached discovery response). - Strip temperature/top_p/top_k/frequency_penalty/presence_penalty/seed/ logit_bias/logprobs/top_logprobs from invoke kwargs inside UiPathBaseChatModel's _generate/_agenerate/_stream/_astream wrappers. n is intentionally not treated as a sampling knob. - Factory forwards modelDetails to every chat-model constructor so the common path has zero extra network cost. - Each strip logs a warning via self.logger when one is configured. Init-time clearing of already-set instance fields (UiPathChat(temperature=0.5)) is intentionally out of scope for this PR — tracked separately. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/uipath_langchain_client/CHANGELOG.md | 6 + .../uipath_langchain_client/__version__.py | 2 +- .../uipath_langchain_client/base_client.py | 64 ++++ .../src/uipath_langchain_client/factory.py | 10 + .../test_disabled_sampling_params.py | 353 ++++++++++++++++++ 5 files changed, 434 insertions(+), 1 deletion(-) create mode 100644 tests/langchain/test_disabled_sampling_params.py diff --git a/packages/uipath_langchain_client/CHANGELOG.md b/packages/uipath_langchain_client/CHANGELOG.md index 5701366..b5bcce9 100644 --- a/packages/uipath_langchain_client/CHANGELOG.md +++ b/packages/uipath_langchain_client/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to `uipath_langchain_client` will be documented in this file. +## [1.10.0] - 2026-04-23 + +### Added +- `UiPathBaseChatModel` now strips sampling kwargs (`temperature`, `top_p`, `top_k`, `frequency_penalty`, `presence_penalty`, `seed`, `logit_bias`, `logprobs`, `top_logprobs`) at invocation time when the model's `modelDetails.shouldSkipTemperature` is true. Fixes `anthropic.claude-opus-4-7` rejecting any sampling parameter passed to `.invoke()` / `.ainvoke()` / streams. +- `model_details` field on `UiPathBaseLLMClient` (forwarded eagerly by `get_chat_model`; lazy-resolved from `client_settings.get_model_info` on direct instantiation). Each strip logs a warning via `self.logger` when one is configured. + ## [1.9.9] - 2026-04-23 ### Changed 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 b612be4..afec5ef 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.9.9" +__version__ = "1.10.0" 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 91274bd..962d621 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 @@ -108,6 +108,13 @@ class UiPathBaseLLMClient(BaseModel, ABC): description="Settings for the UiPath client (defaults based on UIPATH_LLM_SERVICE env var)", ) + model_details: dict[str, Any] | None = Field( + default=None, + description="Per-model capability flags sourced from the discovery endpoint " + "(e.g. {'shouldSkipTemperature': True}). The factory forwards it; direct " + "instantiation lazy-resolves it from client_settings on first construction.", + ) + default_headers: Mapping[str, str] | None = Field( default=None, description="Caller-supplied request headers. Merged on top of `class_default_headers`; " @@ -357,6 +364,59 @@ class UiPathBaseChatModel(UiPathBaseLLMClient, BaseChatModel): so that headers are captured transparently. """ + # When modelDetails["shouldSkipTemperature"] is truthy, the gateway rejects + # the entire sampling set for that model (reasoning-style models like + # anthropic.claude-opus-4-7), so we drop all of these — not just temperature. + # Note: `n` (number of candidates) is intentionally excluded; it is not a + # sampling knob. + _SAMPLING_PARAMS: ClassVar[tuple[str, ...]] = ( + "temperature", + "top_p", + "top_k", + "frequency_penalty", + "presence_penalty", + "seed", + "logit_bias", + "logprobs", + "top_logprobs", + ) + + def _skip_sampling(self) -> bool: + # Lazy-resolve model_details on first access rather than at construction + # time: the factory forwards it eagerly (zero extra cost), and + # get_available_models is class-cached inside the settings layer, so + # direct instantiation pays at most one discovery call per process. + # Running this lazily keeps existing unit tests that don't invoke the + # model from triggering an unrelated HTTP call at construction time. + details = self.model_details + if details is None: + try: + info = self.client_settings.get_model_info( + self.model_name, + byo_connection_id=self.byo_connection_id, + ) + details = info.get("modelDetails") or {} + except Exception: + details = {} + self.model_details = details + return bool(details.get("shouldSkipTemperature")) + + def _strip_disabled_sampling_kwargs(self, kwargs: dict[str, Any]) -> dict[str, Any]: + if not self._skip_sampling(): + return kwargs + out = dict(kwargs) + for param in self._SAMPLING_PARAMS: + if param in out: + if self.logger is not None: + self.logger.warning( + "Stripping unsupported invocation param %r for model %r " + "(shouldSkipTemperature=True)", + param, + self.model_name, + ) + out.pop(param, None) + return out + def _generate( self, messages: list[BaseMessage], @@ -364,6 +424,7 @@ def _generate( run_manager: CallbackManagerForLLMRun | None = None, **kwargs: Any, ) -> ChatResult: + kwargs = self._strip_disabled_sampling_kwargs(kwargs) set_captured_response_headers({}) try: result = self._uipath_generate(messages, stop=stop, run_manager=run_manager, **kwargs) @@ -389,6 +450,7 @@ async def _agenerate( run_manager: AsyncCallbackManagerForLLMRun | None = None, **kwargs: Any, ) -> ChatResult: + kwargs = self._strip_disabled_sampling_kwargs(kwargs) set_captured_response_headers({}) try: result = await self._uipath_agenerate( @@ -416,6 +478,7 @@ def _stream( run_manager: CallbackManagerForLLMRun | None = None, **kwargs: Any, ) -> Generator[ChatGenerationChunk, None, None]: + kwargs = self._strip_disabled_sampling_kwargs(kwargs) set_captured_response_headers({}) try: first = True @@ -446,6 +509,7 @@ async def _astream( run_manager: AsyncCallbackManagerForLLMRun | None = None, **kwargs: Any, ) -> AsyncGenerator[ChatGenerationChunk, None]: + kwargs = self._strip_disabled_sampling_kwargs(kwargs) set_captured_response_headers({}) try: first = True diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/factory.py b/packages/uipath_langchain_client/src/uipath_langchain_client/factory.py index 8ef3b99..6585f6f 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/factory.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/factory.py @@ -85,12 +85,14 @@ def get_chat_model( vendor_type=vendor_type, ) model_family = model_info.get("modelFamily", None) + model_details = model_info.get("modelDetails") or {} if custom_class is not None: return custom_class( model=model_name, settings=client_settings, byo_connection_id=byo_connection_id, + model_details=model_details, **model_kwargs, ) @@ -103,6 +105,7 @@ def get_chat_model( model=model_name, settings=client_settings, byo_connection_id=byo_connection_id, + model_details=model_details, **model_kwargs, ) @@ -138,6 +141,7 @@ def get_chat_model( settings=client_settings, api_flavor=api_flavor, byo_connection_id=byo_connection_id, + model_details=model_details, **model_kwargs, ) else: @@ -150,6 +154,7 @@ def get_chat_model( settings=client_settings, api_flavor=api_flavor, byo_connection_id=byo_connection_id, + model_details=model_details, **model_kwargs, ) case VendorType.VERTEXAI: @@ -163,6 +168,7 @@ def get_chat_model( settings=client_settings, vendor_type=discovered_vendor_type, byo_connection_id=byo_connection_id, + model_details=model_details, **model_kwargs, ) @@ -174,6 +180,7 @@ def get_chat_model( model=model_name, settings=client_settings, byo_connection_id=byo_connection_id, + model_details=model_details, **model_kwargs, ) case VendorType.AWSBEDROCK: @@ -188,6 +195,7 @@ def get_chat_model( model=model_name, settings=client_settings, byo_connection_id=byo_connection_id, + model_details=model_details, **model_kwargs, ) @@ -200,6 +208,7 @@ def get_chat_model( model=model_name, settings=client_settings, byo_connection_id=byo_connection_id, + model_details=model_details, **model_kwargs, ) @@ -211,6 +220,7 @@ def get_chat_model( model=model_name, settings=client_settings, byo_connection_id=byo_connection_id, + model_details=model_details, **model_kwargs, ) diff --git a/tests/langchain/test_disabled_sampling_params.py b/tests/langchain/test_disabled_sampling_params.py new file mode 100644 index 0000000..24b77fc --- /dev/null +++ b/tests/langchain/test_disabled_sampling_params.py @@ -0,0 +1,353 @@ +"""Unit tests for invocation-time stripping of sampling params based on +``modelDetails.shouldSkipTemperature``. + +These tests monkeypatch ``client_settings.get_model_info`` and the instance's +``_uipath_generate`` / ``_uipath_agenerate`` to capture kwargs, so no HTTP is +made by this file. +""" + +from __future__ import annotations + +import logging +from typing import Any + +import pytest +from langchain_core.messages import AIMessage +from langchain_core.outputs import ChatGeneration, ChatResult +from uipath_langchain_client.clients.normalized.chat_models import UiPathChat +from uipath_langchain_client.factory import get_chat_model + +from uipath.llm_client.settings import UiPathBaseSettings + +# --------------------------------------------------------------------------- # +# helpers +# --------------------------------------------------------------------------- # + + +def _stub_model_info( + monkeypatch: pytest.MonkeyPatch, + settings: UiPathBaseSettings, + *, + model_details: dict[str, Any] | None = None, + extra: dict[str, Any] | None = None, + raises: BaseException | None = None, +) -> None: + """Replace ``client_settings.get_model_info`` with a stub that returns + (or raises) a controlled value. ``monkeypatch`` reverts this at teardown. + """ + + def _stub(model_name: str, **kwargs: Any) -> dict[str, Any]: + if raises is not None: + raise raises + info: dict[str, Any] = { + "modelName": model_name, + "vendor": "AwsBedrock", + "modelSubscriptionType": "UiPathOwned", + "modelDetails": model_details, + } + if extra: + info.update(extra) + return info + + monkeypatch.setattr(settings, "get_model_info", _stub) + + +def _stub_generate( + monkeypatch: pytest.MonkeyPatch, instance: UiPathChat, captured: dict[str, Any] +) -> None: + """Replace ``_uipath_generate`` on the instance with a stub that records the + kwargs it receives and returns a minimal ChatResult. + """ + + def _stub( + messages: Any, stop: Any = None, run_manager: Any = None, **kwargs: Any + ) -> ChatResult: + captured.update(kwargs) + captured["__stop__"] = stop + return ChatResult(generations=[ChatGeneration(message=AIMessage(content="ok"))]) + + monkeypatch.setattr(instance, "_uipath_generate", _stub) + + +def _stub_agenerate( + monkeypatch: pytest.MonkeyPatch, instance: UiPathChat, captured: dict[str, Any] +) -> None: + async def _stub( + messages: Any, stop: Any = None, run_manager: Any = None, **kwargs: Any + ) -> ChatResult: + captured.update(kwargs) + captured["__stop__"] = stop + return ChatResult(generations=[ChatGeneration(message=AIMessage(content="ok"))]) + + monkeypatch.setattr(instance, "_uipath_agenerate", _stub) + + +# --------------------------------------------------------------------------- # +# invocation-time stripping — sync +# --------------------------------------------------------------------------- # + + +def test_invoke_strips_sampling_kwargs_when_flag_set( + monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings +) -> None: + llm = UiPathChat( + model="anthropic.claude-opus-4-7", + settings=client_settings, + model_details={"shouldSkipTemperature": True}, + ) + captured: dict[str, Any] = {} + _stub_generate(monkeypatch, llm, captured) + + llm.invoke("hi", temperature=0.3, top_p=0.9, top_k=5, seed=42, max_tokens=100) + + # All sampling kwargs stripped; non-sampling kwargs preserved. + for p in ("temperature", "top_p", "top_k", "seed"): + assert p not in captured, f"{p} should have been stripped" + assert captured.get("max_tokens") == 100 + + +def test_invoke_strips_all_listed_sampling_params( + monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings +) -> None: + llm = UiPathChat( + model="anthropic.claude-opus-4-7", + settings=client_settings, + model_details={"shouldSkipTemperature": True}, + ) + captured: dict[str, Any] = {} + _stub_generate(monkeypatch, llm, captured) + + # Pass every sampling param plus an unrelated kwarg; assert every + # sampling param is stripped and only max_tokens survives. + kwargs: dict[str, Any] = {p: 0.1 for p in llm._SAMPLING_PARAMS} + kwargs["max_tokens"] = 50 + llm.invoke("x", **kwargs) # type: ignore[arg-type] + + for p in llm._SAMPLING_PARAMS: + assert p not in captured + assert captured["max_tokens"] == 50 + + +def test_n_is_not_stripped( + monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings +) -> None: + # `n` (candidate count) is intentionally NOT part of _SAMPLING_PARAMS. + llm = UiPathChat( + model="anthropic.claude-opus-4-7", + settings=client_settings, + model_details={"shouldSkipTemperature": True}, + ) + captured: dict[str, Any] = {} + _stub_generate(monkeypatch, llm, captured) + + llm.invoke("x", n=3) + assert captured.get("n") == 3 + + +# --------------------------------------------------------------------------- # +# invocation-time stripping — async +# --------------------------------------------------------------------------- # + + +@pytest.mark.asyncio +async def test_ainvoke_strips_sampling_kwargs_when_flag_set( + monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings +) -> None: + llm = UiPathChat( + model="anthropic.claude-opus-4-7", + settings=client_settings, + model_details={"shouldSkipTemperature": True}, + ) + captured: dict[str, Any] = {} + _stub_agenerate(monkeypatch, llm, captured) + + await llm.ainvoke("hi", temperature=0.3, top_p=0.9) + + assert "temperature" not in captured + assert "top_p" not in captured + + +# --------------------------------------------------------------------------- # +# no-flag -> pass-through +# --------------------------------------------------------------------------- # + + +def test_invoke_preserves_kwargs_when_flag_absent( + monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings +) -> None: + llm = UiPathChat( + model="some-chatty-model", + settings=client_settings, + model_details={}, # empty — no flag + ) + captured: dict[str, Any] = {} + _stub_generate(monkeypatch, llm, captured) + + llm.invoke("hi", temperature=0.3, top_p=0.9) + + assert captured["temperature"] == 0.3 + assert captured["top_p"] == 0.9 + + +def test_invoke_preserves_kwargs_when_flag_false( + monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings +) -> None: + llm = UiPathChat( + model="some-chatty-model", + settings=client_settings, + model_details={"shouldSkipTemperature": False}, + ) + captured: dict[str, Any] = {} + _stub_generate(monkeypatch, llm, captured) + + llm.invoke("hi", temperature=0.3) + + assert captured["temperature"] == 0.3 + + +# --------------------------------------------------------------------------- # +# warning gating via self.logger +# --------------------------------------------------------------------------- # + + +def test_warning_logged_when_logger_set( + monkeypatch: pytest.MonkeyPatch, + client_settings: UiPathBaseSettings, + caplog: pytest.LogCaptureFixture, +) -> None: + logger = logging.getLogger("uipath.test.skip-sampling") + llm = UiPathChat( + model="anthropic.claude-opus-4-7", + settings=client_settings, + model_details={"shouldSkipTemperature": True}, + logger=logger, + ) + captured: dict[str, Any] = {} + _stub_generate(monkeypatch, llm, captured) + + with caplog.at_level(logging.WARNING, logger=logger.name): + llm.invoke("x", temperature=0.3) + + assert any( + "temperature" in rec.getMessage() and "shouldSkipTemperature" in rec.getMessage() + for rec in caplog.records + ), "expected a warning mentioning 'temperature' and 'shouldSkipTemperature'" + + +def test_no_warning_when_logger_is_none( + monkeypatch: pytest.MonkeyPatch, + client_settings: UiPathBaseSettings, + caplog: pytest.LogCaptureFixture, +) -> None: + llm = UiPathChat( + model="anthropic.claude-opus-4-7", + settings=client_settings, + model_details={"shouldSkipTemperature": True}, + logger=None, + ) + captured: dict[str, Any] = {} + _stub_generate(monkeypatch, llm, captured) + + with caplog.at_level(logging.DEBUG): + llm.invoke("x", temperature=0.3) + + # temperature was still stripped — we just don't log when logger is None. + assert "temperature" not in captured + assert not any("shouldSkipTemperature" in rec.getMessage() for rec in caplog.records) + + +def test_no_warning_when_nothing_to_strip( + monkeypatch: pytest.MonkeyPatch, + client_settings: UiPathBaseSettings, + caplog: pytest.LogCaptureFixture, +) -> None: + logger = logging.getLogger("uipath.test.skip-sampling-quiet") + llm = UiPathChat( + model="anthropic.claude-opus-4-7", + settings=client_settings, + model_details={"shouldSkipTemperature": True}, + logger=logger, + ) + captured: dict[str, Any] = {} + _stub_generate(monkeypatch, llm, captured) + + with caplog.at_level(logging.WARNING, logger=logger.name): + llm.invoke("x", max_tokens=50) # no sampling kwargs at all + + assert not any("shouldSkipTemperature" in rec.getMessage() for rec in caplog.records) + + +# --------------------------------------------------------------------------- # +# lazy resolution on direct instantiation +# --------------------------------------------------------------------------- # + + +def test_lazy_resolution_populates_model_details_on_first_invoke( + monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings +) -> None: + _stub_model_info(monkeypatch, client_settings, model_details={"shouldSkipTemperature": True}) + # No model_details passed — construction leaves it None. + llm = UiPathChat(model="anthropic.claude-opus-4-7", settings=client_settings) + assert llm.model_details is None + + captured: dict[str, Any] = {} + _stub_generate(monkeypatch, llm, captured) + llm.invoke("x", temperature=0.5) + + assert llm.model_details == {"shouldSkipTemperature": True} + assert "temperature" not in captured + + +def test_lazy_resolution_swallows_discovery_errors( + monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings +) -> None: + _stub_model_info(monkeypatch, client_settings, raises=RuntimeError("boom")) + llm = UiPathChat(model="anthropic.claude-opus-4-7", settings=client_settings) + + captured: dict[str, Any] = {} + _stub_generate(monkeypatch, llm, captured) + llm.invoke("x", temperature=0.5) + + # Discovery failure => we fall back to empty model_details and don't strip. + assert llm.model_details == {} + assert captured["temperature"] == 0.5 + + +# --------------------------------------------------------------------------- # +# factory forwarding +# --------------------------------------------------------------------------- # + + +def test_factory_forwards_model_details_to_normalized_chat( + monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings +) -> None: + from uipath_langchain_client.settings import RoutingMode + + _stub_model_info(monkeypatch, client_settings, model_details={"shouldSkipTemperature": True}) + + llm = get_chat_model( + "anthropic.claude-opus-4-7", + client_settings=client_settings, + routing_mode=RoutingMode.NORMALIZED, + ) + + assert isinstance(llm, UiPathChat) + assert llm.model_details == {"shouldSkipTemperature": True} + + +def test_factory_forwards_empty_dict_when_no_model_details( + monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings +) -> None: + from uipath_langchain_client.settings import RoutingMode + + _stub_model_info(monkeypatch, client_settings, model_details=None) + + llm = get_chat_model( + "gpt-4o", + client_settings=client_settings, + routing_mode=RoutingMode.NORMALIZED, + ) + + assert isinstance(llm, UiPathChat) + # None -> {} via `or {}` in the factory + assert llm.model_details == {} From 22227faf0a2d8da5edea4a39a05129245c4caaa6 Mon Sep 17 00:00:00 2001 From: Cosmin Maria Date: Thu, 23 Apr 2026 17:30:16 +0300 Subject: [PATCH 02/14] Refactor: populate model_details in model_post_init instead of lazy on first invoke Direct instantiation (UiPathChat(model="opus47")) now resolves model_details eagerly during pydantic's post-init hook. get_available_models is class-cached inside the settings layer, so at most one discovery HTTP call fires per process regardless of how many chat models are built. Also guards against overwriting an explicitly-provided model_details (the factory's fast path), and adds a test for that case. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/uipath_langchain_client/CHANGELOG.md | 2 +- .../uipath_langchain_client/base_client.py | 26 ++++++------ .../test_disabled_sampling_params.py | 40 ++++++++++++++----- 3 files changed, 44 insertions(+), 24 deletions(-) diff --git a/packages/uipath_langchain_client/CHANGELOG.md b/packages/uipath_langchain_client/CHANGELOG.md index b5bcce9..822e91e 100644 --- a/packages/uipath_langchain_client/CHANGELOG.md +++ b/packages/uipath_langchain_client/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to `uipath_langchain_client` will be documented in this file ### Added - `UiPathBaseChatModel` now strips sampling kwargs (`temperature`, `top_p`, `top_k`, `frequency_penalty`, `presence_penalty`, `seed`, `logit_bias`, `logprobs`, `top_logprobs`) at invocation time when the model's `modelDetails.shouldSkipTemperature` is true. Fixes `anthropic.claude-opus-4-7` rejecting any sampling parameter passed to `.invoke()` / `.ainvoke()` / streams. -- `model_details` field on `UiPathBaseLLMClient` (forwarded eagerly by `get_chat_model`; lazy-resolved from `client_settings.get_model_info` on direct instantiation). Each strip logs a warning via `self.logger` when one is configured. +- `model_details` field on `UiPathBaseLLMClient`, populated eagerly: `get_chat_model` forwards it from the discovery response it already fetches; direct instantiation resolves it in `model_post_init` via `client_settings.get_model_info` (backed by the class-cached discovery response, so at most one network call per process). Each strip logs a warning via `self.logger` when one is configured. ## [1.9.9] - 2026-04-23 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 962d621..77685da 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 @@ -381,25 +381,25 @@ class UiPathBaseChatModel(UiPathBaseLLMClient, BaseChatModel): "top_logprobs", ) - def _skip_sampling(self) -> bool: - # Lazy-resolve model_details on first access rather than at construction - # time: the factory forwards it eagerly (zero extra cost), and - # get_available_models is class-cached inside the settings layer, so - # direct instantiation pays at most one discovery call per process. - # Running this lazily keeps existing unit tests that don't invoke the - # model from triggering an unrelated HTTP call at construction time. - details = self.model_details - if details is None: + def model_post_init(self, __context: Any) -> None: + # Populate model_details eagerly so that direct instantiation + # (e.g. ``UiPathChat(model="opus47")``) behaves the same as the factory + # path. ``get_available_models`` is class-cached inside the settings + # layer, so at most one discovery HTTP call fires per process even if + # many chat models get constructed. + super().model_post_init(__context) + if self.model_details is None: try: info = self.client_settings.get_model_info( self.model_name, byo_connection_id=self.byo_connection_id, ) - details = info.get("modelDetails") or {} + self.model_details = info.get("modelDetails") or {} except Exception: - details = {} - self.model_details = details - return bool(details.get("shouldSkipTemperature")) + self.model_details = {} + + def _skip_sampling(self) -> bool: + return bool(self.model_details and self.model_details.get("shouldSkipTemperature")) def _strip_disabled_sampling_kwargs(self, kwargs: dict[str, Any]) -> dict[str, Any]: if not self._skip_sampling(): diff --git a/tests/langchain/test_disabled_sampling_params.py b/tests/langchain/test_disabled_sampling_params.py index 24b77fc..4e105b0 100644 --- a/tests/langchain/test_disabled_sampling_params.py +++ b/tests/langchain/test_disabled_sampling_params.py @@ -278,41 +278,61 @@ def test_no_warning_when_nothing_to_strip( # --------------------------------------------------------------------------- # -# lazy resolution on direct instantiation +# eager resolution via model_post_init on direct instantiation # --------------------------------------------------------------------------- # -def test_lazy_resolution_populates_model_details_on_first_invoke( +def test_post_init_populates_model_details_on_direct_instantiation( monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings ) -> None: _stub_model_info(monkeypatch, client_settings, model_details={"shouldSkipTemperature": True}) - # No model_details passed — construction leaves it None. + # No model_details passed — model_post_init should fetch and populate it. llm = UiPathChat(model="anthropic.claude-opus-4-7", settings=client_settings) - assert llm.model_details is None + assert llm.model_details == {"shouldSkipTemperature": True} captured: dict[str, Any] = {} _stub_generate(monkeypatch, llm, captured) llm.invoke("x", temperature=0.5) - - assert llm.model_details == {"shouldSkipTemperature": True} assert "temperature" not in captured -def test_lazy_resolution_swallows_discovery_errors( +def test_post_init_swallows_discovery_errors( monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings ) -> None: _stub_model_info(monkeypatch, client_settings, raises=RuntimeError("boom")) llm = UiPathChat(model="anthropic.claude-opus-4-7", settings=client_settings) + # Discovery failure => we fall back to empty model_details and don't strip. + assert llm.model_details == {} + captured: dict[str, Any] = {} _stub_generate(monkeypatch, llm, captured) llm.invoke("x", temperature=0.5) - - # Discovery failure => we fall back to empty model_details and don't strip. - assert llm.model_details == {} assert captured["temperature"] == 0.5 +def test_post_init_does_not_overwrite_explicitly_provided_model_details( + monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings +) -> None: + # If a caller (or the factory) already passed model_details, post_init + # must not call get_model_info and must not overwrite the forwarded value. + called: dict[str, bool] = {"called": False} + + def _stub(*args: Any, **kwargs: Any) -> dict[str, Any]: + called["called"] = True + return {"modelDetails": {"shouldSkipTemperature": False}} + + monkeypatch.setattr(client_settings, "get_model_info", _stub) + + llm = UiPathChat( + model="anthropic.claude-opus-4-7", + settings=client_settings, + model_details={"shouldSkipTemperature": True}, + ) + assert llm.model_details == {"shouldSkipTemperature": True} + assert called["called"] is False + + # --------------------------------------------------------------------------- # # factory forwarding # --------------------------------------------------------------------------- # From b60296a3ecbe98864e42591dd8a2eefbe3a8ab6a Mon Sep 17 00:00:00 2001 From: Cosmin Maria Date: Thu, 23 Apr 2026 17:40:26 +0300 Subject: [PATCH 03/14] Refactor: extract sampling-strip helpers + move model_details resolution to UiPathBaseLLMClient - New _sampling.py module holds DISABLED_SAMPLING_PARAMS, should_skip_sampling, and strip_disabled_sampling_kwargs. Keeps the knowledge of which flags mean what in one place instead of spread across wrappers. - UiPathBaseChatModel's four generate/stream wrappers now delegate to a thin _strip_sampling method that calls the module helper. - model_details resolution moves from UiPathBaseChatModel.model_post_init to a @model_validator(mode="after") on UiPathBaseLLMClient, so embedding wrappers get the same eager-resolve behavior. get_available_models stays class-cached, so this remains at most one network call per process. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/uipath_langchain_client/_sampling.py | 62 +++++++++++++ .../uipath_langchain_client/base_client.py | 92 +++++++------------ .../test_disabled_sampling_params.py | 15 +-- 3 files changed, 104 insertions(+), 65 deletions(-) create mode 100644 packages/uipath_langchain_client/src/uipath_langchain_client/_sampling.py diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/_sampling.py b/packages/uipath_langchain_client/src/uipath_langchain_client/_sampling.py new file mode 100644 index 0000000..aff1db1 --- /dev/null +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/_sampling.py @@ -0,0 +1,62 @@ +"""Shared helpers for stripping sampling parameters that a model rejects. + +Reasoning-style models (e.g. ``anthropic.claude-opus-4-7``) advertise +``modelDetails.shouldSkipTemperature: true`` on the discovery endpoint. When +that flag is set, the gateway rejects the entire sampling set, not just +``temperature``. The helpers here centralize that knowledge so every chat +wrapper can reuse them via ``UiPathBaseChatModel``. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from logging import Logger +from typing import Any + +# Parameters the gateway rejects when ``shouldSkipTemperature`` is true. +# ``n`` (candidate count) is intentionally NOT here — it is not a sampling knob. +DISABLED_SAMPLING_PARAMS: tuple[str, ...] = ( + "temperature", + "top_p", + "top_k", + "frequency_penalty", + "presence_penalty", + "seed", + "logit_bias", + "logprobs", + "top_logprobs", +) + + +def should_skip_sampling(model_details: Mapping[str, Any] | None) -> bool: + """True iff the provided ``modelDetails`` carries ``shouldSkipTemperature``.""" + return bool(model_details and model_details.get("shouldSkipTemperature")) + + +def strip_disabled_sampling_kwargs( + kwargs: Mapping[str, Any], + *, + model_details: Mapping[str, Any] | None, + model_name: str, + logger: Logger | None, +) -> dict[str, Any]: + """Return a copy of ``kwargs`` with disabled sampling params removed. + + When ``model_details`` does not flag the model as sampling-less, the + input is returned unchanged (as a new dict so callers can mutate safely). + A warning is logged per stripped parameter when a logger is provided. + """ + out = dict(kwargs) + if not should_skip_sampling(model_details): + return out + for param in DISABLED_SAMPLING_PARAMS: + if param in out: + if logger is not None: + logger.warning( + "Stripping unsupported invocation param %r for model %r " + "(shouldSkipTemperature=True)", + param, + model_name, + ) + out.pop(param, None) + return out 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 77685da..a3b8b8d 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 @@ -27,7 +27,7 @@ from abc import ABC from collections.abc import AsyncGenerator, Generator, Mapping, Sequence from functools import cached_property -from typing import Any, ClassVar, Literal +from typing import Any, ClassVar, Literal, Self from httpx import URL, Response from langchain_core.callbacks import ( @@ -38,7 +38,7 @@ from langchain_core.language_models.chat_models import BaseChatModel from langchain_core.messages import BaseMessage from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult -from pydantic import AliasChoices, BaseModel, ConfigDict, Field +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator from uipath.llm_client.httpx_client import ( UiPathHttpxAsyncClient, @@ -49,6 +49,7 @@ get_captured_response_headers, set_captured_response_headers, ) +from uipath_langchain_client._sampling import strip_disabled_sampling_kwargs from uipath_langchain_client.settings import ( UiPathAPIConfig, UiPathBaseSettings, @@ -147,6 +148,25 @@ class UiPathBaseLLMClient(BaseModel, ABC): description="Logger for request/response logging", ) + @model_validator(mode="after") + def _resolve_model_details(self) -> Self: + # Populate model_details eagerly so direct instantiation behaves the + # same as the factory path. get_available_models is class-cached inside + # the settings layer, so at most one discovery HTTP call fires per + # process regardless of how many chat/embedding models are built. + # Placed on UiPathBaseLLMClient (not just the chat subclass) because + # model_details is meaningful for embedding wrappers too. + if self.model_details is None: + try: + info = self.client_settings.get_model_info( + self.model_name, + byo_connection_id=self.byo_connection_id, + ) + self.model_details = info.get("modelDetails") or {} + except Exception: + self.model_details = {} + return self + @cached_property def uipath_sync_client(self) -> UiPathHttpxClient: """Here we instantiate a synchronous HTTP client with the proper authentication pipeline, retry logic, logging etc.""" @@ -364,58 +384,14 @@ class UiPathBaseChatModel(UiPathBaseLLMClient, BaseChatModel): so that headers are captured transparently. """ - # When modelDetails["shouldSkipTemperature"] is truthy, the gateway rejects - # the entire sampling set for that model (reasoning-style models like - # anthropic.claude-opus-4-7), so we drop all of these — not just temperature. - # Note: `n` (number of candidates) is intentionally excluded; it is not a - # sampling knob. - _SAMPLING_PARAMS: ClassVar[tuple[str, ...]] = ( - "temperature", - "top_p", - "top_k", - "frequency_penalty", - "presence_penalty", - "seed", - "logit_bias", - "logprobs", - "top_logprobs", - ) - - def model_post_init(self, __context: Any) -> None: - # Populate model_details eagerly so that direct instantiation - # (e.g. ``UiPathChat(model="opus47")``) behaves the same as the factory - # path. ``get_available_models`` is class-cached inside the settings - # layer, so at most one discovery HTTP call fires per process even if - # many chat models get constructed. - super().model_post_init(__context) - if self.model_details is None: - try: - info = self.client_settings.get_model_info( - self.model_name, - byo_connection_id=self.byo_connection_id, - ) - self.model_details = info.get("modelDetails") or {} - except Exception: - self.model_details = {} - - def _skip_sampling(self) -> bool: - return bool(self.model_details and self.model_details.get("shouldSkipTemperature")) - - def _strip_disabled_sampling_kwargs(self, kwargs: dict[str, Any]) -> dict[str, Any]: - if not self._skip_sampling(): - return kwargs - out = dict(kwargs) - for param in self._SAMPLING_PARAMS: - if param in out: - if self.logger is not None: - self.logger.warning( - "Stripping unsupported invocation param %r for model %r " - "(shouldSkipTemperature=True)", - param, - self.model_name, - ) - out.pop(param, None) - return out + def _strip_sampling(self, kwargs: dict[str, Any]) -> dict[str, Any]: + """Drop sampling kwargs the model's ``modelDetails`` flags as unsupported.""" + return strip_disabled_sampling_kwargs( + kwargs, + model_details=self.model_details, + model_name=self.model_name, + logger=self.logger, + ) def _generate( self, @@ -424,7 +400,7 @@ def _generate( run_manager: CallbackManagerForLLMRun | None = None, **kwargs: Any, ) -> ChatResult: - kwargs = self._strip_disabled_sampling_kwargs(kwargs) + kwargs = self._strip_sampling(kwargs) set_captured_response_headers({}) try: result = self._uipath_generate(messages, stop=stop, run_manager=run_manager, **kwargs) @@ -450,7 +426,7 @@ async def _agenerate( run_manager: AsyncCallbackManagerForLLMRun | None = None, **kwargs: Any, ) -> ChatResult: - kwargs = self._strip_disabled_sampling_kwargs(kwargs) + kwargs = self._strip_sampling(kwargs) set_captured_response_headers({}) try: result = await self._uipath_agenerate( @@ -478,7 +454,7 @@ def _stream( run_manager: CallbackManagerForLLMRun | None = None, **kwargs: Any, ) -> Generator[ChatGenerationChunk, None, None]: - kwargs = self._strip_disabled_sampling_kwargs(kwargs) + kwargs = self._strip_sampling(kwargs) set_captured_response_headers({}) try: first = True @@ -509,7 +485,7 @@ async def _astream( run_manager: AsyncCallbackManagerForLLMRun | None = None, **kwargs: Any, ) -> AsyncGenerator[ChatGenerationChunk, None]: - kwargs = self._strip_disabled_sampling_kwargs(kwargs) + kwargs = self._strip_sampling(kwargs) set_captured_response_headers({}) try: first = True diff --git a/tests/langchain/test_disabled_sampling_params.py b/tests/langchain/test_disabled_sampling_params.py index 4e105b0..fe4f535 100644 --- a/tests/langchain/test_disabled_sampling_params.py +++ b/tests/langchain/test_disabled_sampling_params.py @@ -14,6 +14,7 @@ import pytest from langchain_core.messages import AIMessage from langchain_core.outputs import ChatGeneration, ChatResult +from uipath_langchain_client._sampling import DISABLED_SAMPLING_PARAMS from uipath_langchain_client.clients.normalized.chat_models import UiPathChat from uipath_langchain_client.factory import get_chat_model @@ -119,11 +120,11 @@ def test_invoke_strips_all_listed_sampling_params( # Pass every sampling param plus an unrelated kwarg; assert every # sampling param is stripped and only max_tokens survives. - kwargs: dict[str, Any] = {p: 0.1 for p in llm._SAMPLING_PARAMS} + kwargs: dict[str, Any] = {p: 0.1 for p in DISABLED_SAMPLING_PARAMS} kwargs["max_tokens"] = 50 llm.invoke("x", **kwargs) # type: ignore[arg-type] - for p in llm._SAMPLING_PARAMS: + for p in DISABLED_SAMPLING_PARAMS: assert p not in captured assert captured["max_tokens"] == 50 @@ -278,15 +279,15 @@ def test_no_warning_when_nothing_to_strip( # --------------------------------------------------------------------------- # -# eager resolution via model_post_init on direct instantiation +# eager model_details resolution via the UiPathBaseLLMClient validator # --------------------------------------------------------------------------- # -def test_post_init_populates_model_details_on_direct_instantiation( +def test_validator_populates_model_details_on_direct_instantiation( monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings ) -> None: _stub_model_info(monkeypatch, client_settings, model_details={"shouldSkipTemperature": True}) - # No model_details passed — model_post_init should fetch and populate it. + # No model_details passed — the validator should fetch and populate it. llm = UiPathChat(model="anthropic.claude-opus-4-7", settings=client_settings) assert llm.model_details == {"shouldSkipTemperature": True} @@ -296,7 +297,7 @@ def test_post_init_populates_model_details_on_direct_instantiation( assert "temperature" not in captured -def test_post_init_swallows_discovery_errors( +def test_validator_swallows_discovery_errors( monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings ) -> None: _stub_model_info(monkeypatch, client_settings, raises=RuntimeError("boom")) @@ -311,7 +312,7 @@ def test_post_init_swallows_discovery_errors( assert captured["temperature"] == 0.5 -def test_post_init_does_not_overwrite_explicitly_provided_model_details( +def test_validator_does_not_overwrite_explicitly_provided_model_details( monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings ) -> None: # If a caller (or the factory) already passed model_details, post_init From a2c7c6e612abaad8459238004b522ca8b6dc3528 Mon Sep 17 00:00:00 2001 From: Cosmin Maria Date: Thu, 23 Apr 2026 17:49:17 +0300 Subject: [PATCH 04/14] Refactor: move sampling helpers to core utils + resolve model_details via field_validator - New src/uipath/llm_client/utils/sampling.py in the core package exports DISABLED_SAMPLING_PARAMS, should_skip_sampling, and strip_disabled_sampling_kwargs. These are framework-agnostic and fit the existing core utils pattern (one file per concern). - Langchain's utils.py re-exports them, so the public import path uipath_langchain_client.utils.strip_disabled_sampling_kwargs is preserved. - model_details resolution is now a @field_validator("model_details", mode="after") collocated with the field declaration. It reads already-validated client_settings and model_name off info.data and calls get_model_info, instead of living in a separate @model_validator method. - Core version 1.9.9 -> 1.10.0 with changelog entry; langchain's core-dep floor bumped to >=1.10.0. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 5 ++ packages/uipath_langchain_client/CHANGELOG.md | 7 ++- .../uipath_langchain_client/pyproject.toml | 2 +- .../uipath_langchain_client/base_client.py | 63 +++++++++++-------- .../src/uipath_langchain_client/utils.py | 8 +++ src/uipath/llm_client/__version__.py | 2 +- .../uipath/llm_client/utils/sampling.py | 5 +- .../test_disabled_sampling_params.py | 2 +- 8 files changed, 62 insertions(+), 32 deletions(-) rename packages/uipath_langchain_client/src/uipath_langchain_client/_sampling.py => src/uipath/llm_client/utils/sampling.py (93%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 935890f..0599203 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.10.0] - 2026-04-23 + +### Added +- `uipath.llm_client.utils.sampling` module exposing `DISABLED_SAMPLING_PARAMS`, `should_skip_sampling(model_details)`, and `strip_disabled_sampling_kwargs(...)`. Centralizes the gateway's rule that `modelDetails.shouldSkipTemperature=True` implies the full sampling set is rejected (temperature, top_p, top_k, frequency/presence penalty, seed, logit_bias, logprobs, top_logprobs). Framework-agnostic helpers intended for reuse by any wrapper layer. + ## [1.9.9] - 2026-04-23 ### Changed diff --git a/packages/uipath_langchain_client/CHANGELOG.md b/packages/uipath_langchain_client/CHANGELOG.md index 822e91e..5607019 100644 --- a/packages/uipath_langchain_client/CHANGELOG.md +++ b/packages/uipath_langchain_client/CHANGELOG.md @@ -5,8 +5,11 @@ All notable changes to `uipath_langchain_client` will be documented in this file ## [1.10.0] - 2026-04-23 ### Added -- `UiPathBaseChatModel` now strips sampling kwargs (`temperature`, `top_p`, `top_k`, `frequency_penalty`, `presence_penalty`, `seed`, `logit_bias`, `logprobs`, `top_logprobs`) at invocation time when the model's `modelDetails.shouldSkipTemperature` is true. Fixes `anthropic.claude-opus-4-7` rejecting any sampling parameter passed to `.invoke()` / `.ainvoke()` / streams. -- `model_details` field on `UiPathBaseLLMClient`, populated eagerly: `get_chat_model` forwards it from the discovery response it already fetches; direct instantiation resolves it in `model_post_init` via `client_settings.get_model_info` (backed by the class-cached discovery response, so at most one network call per process). Each strip logs a warning via `self.logger` when one is configured. +- `UiPathBaseChatModel` now strips sampling kwargs (`temperature`, `top_p`, `top_k`, `frequency_penalty`, `presence_penalty`, `seed`, `logit_bias`, `logprobs`, `top_logprobs`) at invocation time when the model's `modelDetails.shouldSkipTemperature` is true. Fixes `anthropic.claude-opus-4-7` rejecting any sampling parameter passed to `.invoke()` / `.ainvoke()` / streams. The shared helpers live in the core package at `uipath.llm_client.utils.sampling` and are re-exported from `uipath_langchain_client.utils`. +- `model_details` field on `UiPathBaseLLMClient`, populated eagerly via a `@field_validator("model_details", mode="after")`: `get_chat_model` forwards it from the discovery response it already fetches; direct instantiation resolves it via `client_settings.get_model_info` (backed by the class-cached discovery response, so at most one network call per process). Each strip logs a warning via `self.logger` when one is configured. + +### Changed +- Bumped `uipath-llm-client` floor to `>=1.10.0` to match the release that adds `uipath.llm_client.utils.sampling`. ## [1.9.9] - 2026-04-23 diff --git a/packages/uipath_langchain_client/pyproject.toml b/packages/uipath_langchain_client/pyproject.toml index bca0acc..056c4ef 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.9.9,<2.0.0", + "uipath-llm-client>=1.10.0,<2.0.0", ] [project.optional-dependencies] 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 a3b8b8d..f03816f 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 @@ -27,7 +27,7 @@ from abc import ABC from collections.abc import AsyncGenerator, Generator, Mapping, Sequence from functools import cached_property -from typing import Any, ClassVar, Literal, Self +from typing import Any, ClassVar, Literal from httpx import URL, Response from langchain_core.callbacks import ( @@ -38,7 +38,14 @@ from langchain_core.language_models.chat_models import BaseChatModel from langchain_core.messages import BaseMessage from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult -from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator +from pydantic import ( + AliasChoices, + BaseModel, + ConfigDict, + Field, + ValidationInfo, + field_validator, +) from uipath.llm_client.httpx_client import ( UiPathHttpxAsyncClient, @@ -49,13 +56,12 @@ get_captured_response_headers, set_captured_response_headers, ) -from uipath_langchain_client._sampling import strip_disabled_sampling_kwargs from uipath_langchain_client.settings import ( UiPathAPIConfig, UiPathBaseSettings, get_default_client_settings, ) -from uipath_langchain_client.utils import RetryConfig +from uipath_langchain_client.utils import RetryConfig, strip_disabled_sampling_kwargs class UiPathBaseLLMClient(BaseModel, ABC): @@ -112,10 +118,36 @@ class UiPathBaseLLMClient(BaseModel, ABC): model_details: dict[str, Any] | None = Field( default=None, description="Per-model capability flags sourced from the discovery endpoint " - "(e.g. {'shouldSkipTemperature': True}). The factory forwards it; direct " - "instantiation lazy-resolves it from client_settings on first construction.", + "(e.g. {'shouldSkipTemperature': True}). The factory forwards it; when absent, " + "the field validator below eagerly resolves it from client_settings.", ) + @field_validator("model_details", mode="after") + @classmethod + def _resolve_model_details( + cls, value: dict[str, Any] | None, info: ValidationInfo + ) -> dict[str, Any]: + # Fields validate in declaration order, so by the time this runs both + # ``client_settings`` and ``model_name`` are already in ``info.data``. + # Eager resolution here keeps direct instantiation and the factory + # path consistent. ``get_available_models`` is class-cached inside + # the settings layer, so at most one discovery HTTP call fires per + # process regardless of how many chat/embedding models are built. + if value is not None: + return value + settings = info.data.get("client_settings") + model_name = info.data.get("model_name") + if settings is None or not model_name: + return {} + try: + model_info = settings.get_model_info( + model_name, + byo_connection_id=info.data.get("byo_connection_id"), + ) + return model_info.get("modelDetails") or {} + except Exception: + return {} + default_headers: Mapping[str, str] | None = Field( default=None, description="Caller-supplied request headers. Merged on top of `class_default_headers`; " @@ -148,25 +180,6 @@ class UiPathBaseLLMClient(BaseModel, ABC): description="Logger for request/response logging", ) - @model_validator(mode="after") - def _resolve_model_details(self) -> Self: - # Populate model_details eagerly so direct instantiation behaves the - # same as the factory path. get_available_models is class-cached inside - # the settings layer, so at most one discovery HTTP call fires per - # process regardless of how many chat/embedding models are built. - # Placed on UiPathBaseLLMClient (not just the chat subclass) because - # model_details is meaningful for embedding wrappers too. - if self.model_details is None: - try: - info = self.client_settings.get_model_info( - self.model_name, - byo_connection_id=self.byo_connection_id, - ) - self.model_details = info.get("modelDetails") or {} - except Exception: - self.model_details = {} - return self - @cached_property def uipath_sync_client(self) -> UiPathHttpxClient: """Here we instantiate a synchronous HTTP client with the proper authentication pipeline, retry logic, logging etc.""" diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/utils.py b/packages/uipath_langchain_client/src/uipath_langchain_client/utils.py index 0607b05..4957fdd 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/utils.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/utils.py @@ -18,6 +18,11 @@ is_anthropic_model_name, ) from uipath.llm_client.utils.retry import RetryConfig +from uipath.llm_client.utils.sampling import ( + DISABLED_SAMPLING_PARAMS, + should_skip_sampling, + strip_disabled_sampling_kwargs, +) __all__ = [ "RetryConfig", @@ -36,4 +41,7 @@ "UiPathTooManyRequestsError", "ANTHROPIC_MODEL_NAME_KEYWORDS", "is_anthropic_model_name", + "DISABLED_SAMPLING_PARAMS", + "should_skip_sampling", + "strip_disabled_sampling_kwargs", ] diff --git a/src/uipath/llm_client/__version__.py b/src/uipath/llm_client/__version__.py index bd9af64..e4a0dca 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.9.9" +__version__ = "1.10.0" diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/_sampling.py b/src/uipath/llm_client/utils/sampling.py similarity index 93% rename from packages/uipath_langchain_client/src/uipath_langchain_client/_sampling.py rename to src/uipath/llm_client/utils/sampling.py index aff1db1..3ad390a 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/_sampling.py +++ b/src/uipath/llm_client/utils/sampling.py @@ -3,8 +3,9 @@ Reasoning-style models (e.g. ``anthropic.claude-opus-4-7``) advertise ``modelDetails.shouldSkipTemperature: true`` on the discovery endpoint. When that flag is set, the gateway rejects the entire sampling set, not just -``temperature``. The helpers here centralize that knowledge so every chat -wrapper can reuse them via ``UiPathBaseChatModel``. +``temperature``. The helpers here centralize that knowledge so every framework +wrapper (LangChain chat models, future LlamaIndex wrappers, the core +normalized client, etc.) can reuse the same rule. """ from __future__ import annotations diff --git a/tests/langchain/test_disabled_sampling_params.py b/tests/langchain/test_disabled_sampling_params.py index fe4f535..525fa54 100644 --- a/tests/langchain/test_disabled_sampling_params.py +++ b/tests/langchain/test_disabled_sampling_params.py @@ -14,9 +14,9 @@ import pytest from langchain_core.messages import AIMessage from langchain_core.outputs import ChatGeneration, ChatResult -from uipath_langchain_client._sampling import DISABLED_SAMPLING_PARAMS from uipath_langchain_client.clients.normalized.chat_models import UiPathChat from uipath_langchain_client.factory import get_chat_model +from uipath_langchain_client.utils import DISABLED_SAMPLING_PARAMS from uipath.llm_client.settings import UiPathBaseSettings From b9443904a3439b950537d67d3959eadcfd95f3c8 Mon Sep 17 00:00:00 2001 From: Cosmin Maria Date: Thu, 23 Apr 2026 19:54:56 +0300 Subject: [PATCH 05/14] Nit: tighten the model_details field_validator Keep field_validator (default_factory with data arg breaks LangChain's internal zero-arg call at langchain_core/utils/pydantic.py). Rename the method to _resolve and inline the fetch so the validator body is one short try/except block. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../uipath_langchain_client/base_client.py | 35 ++++++++----------- 1 file changed, 15 insertions(+), 20 deletions(-) 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 f03816f..2c8e6af 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 @@ -119,32 +119,27 @@ class UiPathBaseLLMClient(BaseModel, ABC): default=None, description="Per-model capability flags sourced from the discovery endpoint " "(e.g. {'shouldSkipTemperature': True}). The factory forwards it; when absent, " - "the field validator below eagerly resolves it from client_settings.", + "the field validator below resolves it eagerly from client_settings.", ) @field_validator("model_details", mode="after") @classmethod - def _resolve_model_details( - cls, value: dict[str, Any] | None, info: ValidationInfo - ) -> dict[str, Any]: - # Fields validate in declaration order, so by the time this runs both - # ``client_settings`` and ``model_name`` are already in ``info.data``. - # Eager resolution here keeps direct instantiation and the factory - # path consistent. ``get_available_models`` is class-cached inside - # the settings layer, so at most one discovery HTTP call fires per - # process regardless of how many chat/embedding models are built. - if value is not None: - return value - settings = info.data.get("client_settings") - model_name = info.data.get("model_name") - if settings is None or not model_name: - return {} + def _resolve(cls, v: dict[str, Any] | None, info: ValidationInfo) -> dict[str, Any]: + # Fields validate in declaration order: client_settings and model_name + # are already in info.data. get_available_models is class-cached inside + # the settings layer, so at most one discovery HTTP call per process. + if v is not None: + return v try: - model_info = settings.get_model_info( - model_name, - byo_connection_id=info.data.get("byo_connection_id"), + return ( + info.data["client_settings"] + .get_model_info( + info.data["model_name"], + byo_connection_id=info.data.get("byo_connection_id"), + ) + .get("modelDetails") + or {} ) - return model_info.get("modelDetails") or {} except Exception: return {} From b074e1cf0261ca1158042b308b65bf835a1932aa Mon Sep 17 00:00:00 2001 From: Cosmin Maria Date: Thu, 23 Apr 2026 20:00:21 +0300 Subject: [PATCH 06/14] Revert to @model_validator for model_details + inline strip_disabled_sampling_kwargs calls - Switch back from @field_validator to @model_validator(mode="after") so the resolver uses typed self.* attributes instead of untyped info.data[...] lookups. - Drop the _strip_sampling helper method on UiPathBaseChatModel; call strip_disabled_sampling_kwargs directly in _generate/_agenerate/_stream/ _astream since there's no shared computation to hoist. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../uipath_langchain_client/base_client.py | 88 +++++++++---------- 1 file changed, 44 insertions(+), 44 deletions(-) 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 2c8e6af..d9b4535 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 @@ -27,7 +27,7 @@ from abc import ABC from collections.abc import AsyncGenerator, Generator, Mapping, Sequence from functools import cached_property -from typing import Any, ClassVar, Literal +from typing import Any, ClassVar, Literal, Self from httpx import URL, Response from langchain_core.callbacks import ( @@ -38,14 +38,7 @@ from langchain_core.language_models.chat_models import BaseChatModel from langchain_core.messages import BaseMessage from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult -from pydantic import ( - AliasChoices, - BaseModel, - ConfigDict, - Field, - ValidationInfo, - field_validator, -) +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator from uipath.llm_client.httpx_client import ( UiPathHttpxAsyncClient, @@ -119,30 +112,9 @@ class UiPathBaseLLMClient(BaseModel, ABC): default=None, description="Per-model capability flags sourced from the discovery endpoint " "(e.g. {'shouldSkipTemperature': True}). The factory forwards it; when absent, " - "the field validator below resolves it eagerly from client_settings.", + "``_resolve_model_details`` below fetches it from client_settings.", ) - @field_validator("model_details", mode="after") - @classmethod - def _resolve(cls, v: dict[str, Any] | None, info: ValidationInfo) -> dict[str, Any]: - # Fields validate in declaration order: client_settings and model_name - # are already in info.data. get_available_models is class-cached inside - # the settings layer, so at most one discovery HTTP call per process. - if v is not None: - return v - try: - return ( - info.data["client_settings"] - .get_model_info( - info.data["model_name"], - byo_connection_id=info.data.get("byo_connection_id"), - ) - .get("modelDetails") - or {} - ) - except Exception: - return {} - default_headers: Mapping[str, str] | None = Field( default=None, description="Caller-supplied request headers. Merged on top of `class_default_headers`; " @@ -175,6 +147,23 @@ def _resolve(cls, v: dict[str, Any] | None, info: ValidationInfo) -> dict[str, A description="Logger for request/response logging", ) + @model_validator(mode="after") + def _resolve_model_details(self) -> Self: + # Fetch modelDetails from the discovery endpoint when the factory + # didn't forward it. get_available_models is class-cached inside the + # settings layer, so at most one discovery HTTP call fires per process + # regardless of how many chat/embedding models are built. + if self.model_details is None: + try: + info = self.client_settings.get_model_info( + self.model_name, + byo_connection_id=self.byo_connection_id, + ) + self.model_details = info.get("modelDetails") or {} + except Exception: + self.model_details = {} + return self + @cached_property def uipath_sync_client(self) -> UiPathHttpxClient: """Here we instantiate a synchronous HTTP client with the proper authentication pipeline, retry logic, logging etc.""" @@ -392,15 +381,6 @@ class UiPathBaseChatModel(UiPathBaseLLMClient, BaseChatModel): so that headers are captured transparently. """ - def _strip_sampling(self, kwargs: dict[str, Any]) -> dict[str, Any]: - """Drop sampling kwargs the model's ``modelDetails`` flags as unsupported.""" - return strip_disabled_sampling_kwargs( - kwargs, - model_details=self.model_details, - model_name=self.model_name, - logger=self.logger, - ) - def _generate( self, messages: list[BaseMessage], @@ -408,7 +388,12 @@ def _generate( run_manager: CallbackManagerForLLMRun | None = None, **kwargs: Any, ) -> ChatResult: - kwargs = self._strip_sampling(kwargs) + kwargs = strip_disabled_sampling_kwargs( + kwargs, + model_details=self.model_details, + model_name=self.model_name, + logger=self.logger, + ) set_captured_response_headers({}) try: result = self._uipath_generate(messages, stop=stop, run_manager=run_manager, **kwargs) @@ -434,7 +419,12 @@ async def _agenerate( run_manager: AsyncCallbackManagerForLLMRun | None = None, **kwargs: Any, ) -> ChatResult: - kwargs = self._strip_sampling(kwargs) + kwargs = strip_disabled_sampling_kwargs( + kwargs, + model_details=self.model_details, + model_name=self.model_name, + logger=self.logger, + ) set_captured_response_headers({}) try: result = await self._uipath_agenerate( @@ -462,7 +452,12 @@ def _stream( run_manager: CallbackManagerForLLMRun | None = None, **kwargs: Any, ) -> Generator[ChatGenerationChunk, None, None]: - kwargs = self._strip_sampling(kwargs) + kwargs = strip_disabled_sampling_kwargs( + kwargs, + model_details=self.model_details, + model_name=self.model_name, + logger=self.logger, + ) set_captured_response_headers({}) try: first = True @@ -493,7 +488,12 @@ async def _astream( run_manager: AsyncCallbackManagerForLLMRun | None = None, **kwargs: Any, ) -> AsyncGenerator[ChatGenerationChunk, None]: - kwargs = self._strip_sampling(kwargs) + kwargs = strip_disabled_sampling_kwargs( + kwargs, + model_details=self.model_details, + model_name=self.model_name, + logger=self.logger, + ) set_captured_response_headers({}) try: first = True From df1c6e2b0f93c39f83894367790f46c57e37fe98 Mon Sep 17 00:00:00 2001 From: Cosmin Maria Date: Thu, 23 Apr 2026 20:05:00 +0300 Subject: [PATCH 07/14] Drop sampling + model_family re-exports from langchain utils.py; import from core directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Callers now import from the core modules where the code actually lives (uipath.llm_client.utils.sampling, uipath.llm_client.utils.model_family) rather than going through the langchain utils.py re-export layer. The re-export for these two was noise — they weren't langchain-specific and the indirection hid the real source. Exceptions and RetryConfig still re-export through langchain utils.py because they're widely re-used across many langchain client files. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/uipath_langchain_client/base_client.py | 3 ++- .../clients/normalized/chat_models.py | 2 +- .../src/uipath_langchain_client/factory.py | 2 +- .../src/uipath_langchain_client/utils.py | 14 -------------- src/uipath/llm_client/utils/sampling.py | 2 -- tests/langchain/test_disabled_sampling_params.py | 4 +--- 6 files changed, 5 insertions(+), 22 deletions(-) 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 d9b4535..6cb4fbb 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 @@ -49,12 +49,13 @@ get_captured_response_headers, set_captured_response_headers, ) +from uipath.llm_client.utils.sampling import strip_disabled_sampling_kwargs from uipath_langchain_client.settings import ( UiPathAPIConfig, UiPathBaseSettings, get_default_client_settings, ) -from uipath_langchain_client.utils import RetryConfig, strip_disabled_sampling_kwargs +from uipath_langchain_client.utils import RetryConfig class UiPathBaseLLMClient(BaseModel, ABC): diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/clients/normalized/chat_models.py b/packages/uipath_langchain_client/src/uipath_langchain_client/clients/normalized/chat_models.py index 88a7040..c59d3e9 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/clients/normalized/chat_models.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/clients/normalized/chat_models.py @@ -64,9 +64,9 @@ from langchain_core.utils.pydantic import is_basemodel_subclass from pydantic import AliasChoices, BaseModel, Field +from uipath.llm_client.utils.model_family import is_anthropic_model_name from uipath_langchain_client.base_client import UiPathBaseChatModel from uipath_langchain_client.settings import ApiType, RoutingMode, UiPathAPIConfig -from uipath_langchain_client.utils import is_anthropic_model_name _DictOrPydanticClass = Union[dict[str, Any], type[BaseModel], type] _DictOrPydantic = Union[dict[str, Any], BaseModel] diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/factory.py b/packages/uipath_langchain_client/src/uipath_langchain_client/factory.py index 6585f6f..f0e0576 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/factory.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/factory.py @@ -22,6 +22,7 @@ from typing import Any +from uipath.llm_client.utils.model_family import is_anthropic_model_name from uipath_langchain_client.base_client import ( UiPathBaseChatModel, UiPathBaseEmbeddings, @@ -36,7 +37,6 @@ VendorType, get_default_client_settings, ) -from uipath_langchain_client.utils import is_anthropic_model_name def get_chat_model( diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/utils.py b/packages/uipath_langchain_client/src/uipath_langchain_client/utils.py index 4957fdd..b49e05a 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/utils.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/utils.py @@ -13,16 +13,7 @@ UiPathTooManyRequestsError, UiPathUnprocessableEntityError, ) -from uipath.llm_client.utils.model_family import ( - ANTHROPIC_MODEL_NAME_KEYWORDS, - is_anthropic_model_name, -) from uipath.llm_client.utils.retry import RetryConfig -from uipath.llm_client.utils.sampling import ( - DISABLED_SAMPLING_PARAMS, - should_skip_sampling, - strip_disabled_sampling_kwargs, -) __all__ = [ "RetryConfig", @@ -39,9 +30,4 @@ "UiPathServiceUnavailableError", "UiPathGatewayTimeoutError", "UiPathTooManyRequestsError", - "ANTHROPIC_MODEL_NAME_KEYWORDS", - "is_anthropic_model_name", - "DISABLED_SAMPLING_PARAMS", - "should_skip_sampling", - "strip_disabled_sampling_kwargs", ] diff --git a/src/uipath/llm_client/utils/sampling.py b/src/uipath/llm_client/utils/sampling.py index 3ad390a..43b34eb 100644 --- a/src/uipath/llm_client/utils/sampling.py +++ b/src/uipath/llm_client/utils/sampling.py @@ -8,8 +8,6 @@ normalized client, etc.) can reuse the same rule. """ -from __future__ import annotations - from collections.abc import Mapping from logging import Logger from typing import Any diff --git a/tests/langchain/test_disabled_sampling_params.py b/tests/langchain/test_disabled_sampling_params.py index 525fa54..31be3eb 100644 --- a/tests/langchain/test_disabled_sampling_params.py +++ b/tests/langchain/test_disabled_sampling_params.py @@ -6,8 +6,6 @@ made by this file. """ -from __future__ import annotations - import logging from typing import Any @@ -16,9 +14,9 @@ from langchain_core.outputs import ChatGeneration, ChatResult from uipath_langchain_client.clients.normalized.chat_models import UiPathChat from uipath_langchain_client.factory import get_chat_model -from uipath_langchain_client.utils import DISABLED_SAMPLING_PARAMS from uipath.llm_client.settings import UiPathBaseSettings +from uipath.llm_client.utils.sampling import DISABLED_SAMPLING_PARAMS # --------------------------------------------------------------------------- # # helpers From f0ff54bf07d9cf2281b2b492e65cc0cd716e7083 Mon Sep 17 00:00:00 2001 From: Cosmin Maria Date: Thu, 23 Apr 2026 20:08:48 +0300 Subject: [PATCH 08/14] Chore: drop from __future__ import annotations across the repo Python 3.11+ is the minimum, so the postponed-evaluation import is noise. Three files had it; none used TYPE_CHECKING-only imports that depended on lazy annotations, so removing it is safe. pyright, ruff, and pytest all remain green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/uipath_langchain_client/clients/litellm/embeddings.py | 2 -- src/uipath/llm_client/clients/normalized/completions.py | 2 -- src/uipath/llm_client/clients/normalized/embeddings.py | 2 -- 3 files changed, 6 deletions(-) diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/clients/litellm/embeddings.py b/packages/uipath_langchain_client/src/uipath_langchain_client/clients/litellm/embeddings.py index 9648e22..263823b 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/clients/litellm/embeddings.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/clients/litellm/embeddings.py @@ -10,8 +10,6 @@ >>> vectors = embeddings.embed_documents(["Hello world"]) """ -from __future__ import annotations - from pydantic import Field, model_validator from typing_extensions import Self diff --git a/src/uipath/llm_client/clients/normalized/completions.py b/src/uipath/llm_client/clients/normalized/completions.py index 35b91a3..4bd63b5 100644 --- a/src/uipath/llm_client/clients/normalized/completions.py +++ b/src/uipath/llm_client/clients/normalized/completions.py @@ -1,7 +1,5 @@ """Completions endpoint for the UiPath Normalized API.""" -from __future__ import annotations - import json from collections.abc import AsyncGenerator, Callable, Generator, Sequence from typing import Any, Union, get_args, get_origin, get_type_hints diff --git a/src/uipath/llm_client/clients/normalized/embeddings.py b/src/uipath/llm_client/clients/normalized/embeddings.py index 9caf92c..44f6596 100644 --- a/src/uipath/llm_client/clients/normalized/embeddings.py +++ b/src/uipath/llm_client/clients/normalized/embeddings.py @@ -3,8 +3,6 @@ Provides synchronous and asynchronous methods for generating text embeddings. """ -from __future__ import annotations - from typing import Any from uipath.llm_client.clients.normalized.types import ( From 21ea27b2705585530a41228fa9921f776a2f15cd Mon Sep 17 00:00:00 2001 From: Cosmin Maria Date: Thu, 23 Apr 2026 20:33:57 +0300 Subject: [PATCH 09/14] Generalize via disabled_params + mode="before" validator (init-time clearing, langchain-openai compatible) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the sampling-specific interface with the langchain-openai disabled_params convention: - Core utils: rename strip_disabled_sampling_kwargs -> strip_disabled_kwargs (operates on {name: None | [values]} spec, same as langchain-openai). Add disabled_params_from_model_details to derive the spec from a modelDetails dict (today: shouldSkipTemperature = the full sampling set). Add is_disabled_value helper. - UiPathBaseLLMClient: add disabled_params field; swap the @model_validator(mode="after") for a single @model_validator(mode="before") that (1) resolves model_details (caller-provided wins, else client_settings.get_model_info), (2) derives disabled_params when the caller didn't pass one, (3) pops matching keys from values BEFORE pydantic field validation. This is the same pattern langchain-openai uses for o1/GPT-5 temperature at base.py:1093-1123, so init-time clearing happens without any __pydantic_fields_set__ hackery — the field simply never enters model_fields_set. - Runtime stripping in UiPathBaseChatModel's four wrappers delegates to strip_disabled_kwargs(disabled_params=self.disabled_params, ...). - Drop the unused disabled_params field declaration on UiPathChat (now inherited from UiPathBaseLLMClient). - Tests cover: init-time clearing (fields never enter model_fields_set, popped from model_kwargs too), user-provided disabled_params wins over derivation, disabled_params value-list semantics, factory forwarding, discovery fallback, swallowed errors, and logger-gated warnings. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 2 +- packages/uipath_langchain_client/CHANGELOG.md | 8 +- .../uipath_langchain_client/base_client.py | 106 ++++++--- .../clients/normalized/chat_models.py | 1 - src/uipath/llm_client/utils/sampling.py | 83 +++++-- .../test_disabled_sampling_params.py | 216 +++++++++++------- 6 files changed, 283 insertions(+), 133 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0599203..6854f5d 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.10.0] - 2026-04-23 ### Added -- `uipath.llm_client.utils.sampling` module exposing `DISABLED_SAMPLING_PARAMS`, `should_skip_sampling(model_details)`, and `strip_disabled_sampling_kwargs(...)`. Centralizes the gateway's rule that `modelDetails.shouldSkipTemperature=True` implies the full sampling set is rejected (temperature, top_p, top_k, frequency/presence penalty, seed, logit_bias, logprobs, top_logprobs). Framework-agnostic helpers intended for reuse by any wrapper layer. +- `uipath.llm_client.utils.sampling` module exposing `DISABLED_SAMPLING_PARAMS`, `disabled_params_from_model_details`, `is_disabled_value`, and `strip_disabled_kwargs`. The helpers use the langchain-openai-style `disabled_params` format (`{name: None | [values]}`) so they compose with the existing `langchain_openai._filter_disabled_params` path. `disabled_params_from_model_details` derives the disabled-param map from a discovery-endpoint `modelDetails` dict (today: `shouldSkipTemperature=True` disables the full sampling set — temperature, top_p, top_k, frequency/presence penalty, seed, logit_bias, logprobs, top_logprobs). ## [1.9.9] - 2026-04-23 diff --git a/packages/uipath_langchain_client/CHANGELOG.md b/packages/uipath_langchain_client/CHANGELOG.md index 5607019..944a309 100644 --- a/packages/uipath_langchain_client/CHANGELOG.md +++ b/packages/uipath_langchain_client/CHANGELOG.md @@ -5,8 +5,12 @@ All notable changes to `uipath_langchain_client` will be documented in this file ## [1.10.0] - 2026-04-23 ### Added -- `UiPathBaseChatModel` now strips sampling kwargs (`temperature`, `top_p`, `top_k`, `frequency_penalty`, `presence_penalty`, `seed`, `logit_bias`, `logprobs`, `top_logprobs`) at invocation time when the model's `modelDetails.shouldSkipTemperature` is true. Fixes `anthropic.claude-opus-4-7` rejecting any sampling parameter passed to `.invoke()` / `.ainvoke()` / streams. The shared helpers live in the core package at `uipath.llm_client.utils.sampling` and are re-exported from `uipath_langchain_client.utils`. -- `model_details` field on `UiPathBaseLLMClient`, populated eagerly via a `@field_validator("model_details", mode="after")`: `get_chat_model` forwards it from the discovery response it already fetches; direct instantiation resolves it via `client_settings.get_model_info` (backed by the class-cached discovery response, so at most one network call per process). Each strip logs a warning via `self.logger` when one is configured. +- `model_details` and `disabled_params` fields on `UiPathBaseLLMClient`, plus a single `@model_validator(mode="before")` that (1) forwards the factory-supplied `model_details` or fetches it from `client_settings.get_model_info`, (2) derives `disabled_params` from it when the caller didn't provide one, and (3) pops matching keys from the input `values` dict *before* pydantic field validation — the langchain-openai pattern used for o1/GPT-5 temperature. Result: `UiPathChat(model="anthropic.claude-opus-4-7", temperature=0.5)` no longer sets `temperature` on the instance (it never enters `model_fields_set`), and `llm.invoke("...", temperature=0.2)` strips it from kwargs. +- `disabled_params` uses the langchain-openai shape (`{name: None | [values]}`), so subclasses inheriting from `ChatOpenAI` / `AzureChatOpenAI` also benefit from the native `_filter_disabled_params` path inside `bind_tools`. Users can override `disabled_params` explicitly — the validator respects any value the caller passes in and only falls back to deriving from `model_details` when `None`. +- Runtime stripping in the four `_generate`/`_agenerate`/`_stream`/`_astream` wrappers on `UiPathBaseChatModel` now delegates to `uipath.llm_client.utils.sampling.strip_disabled_kwargs`, which is generic over `disabled_params`. A warning is logged via `self.logger` for each stripped key when a logger is configured. + +### Removed +- The unused `disabled_params` field declaration on `UiPathChat` (now inherited from `UiPathBaseLLMClient`). ### Changed - Bumped `uipath-llm-client` floor to `>=1.10.0` to match the release that adds `uipath.llm_client.utils.sampling`. 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 6cb4fbb..833039a 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 @@ -27,7 +27,7 @@ from abc import ABC from collections.abc import AsyncGenerator, Generator, Mapping, Sequence from functools import cached_property -from typing import Any, ClassVar, Literal, Self +from typing import Any, ClassVar, Literal from httpx import URL, Response from langchain_core.callbacks import ( @@ -49,7 +49,11 @@ get_captured_response_headers, set_captured_response_headers, ) -from uipath.llm_client.utils.sampling import strip_disabled_sampling_kwargs +from uipath.llm_client.utils.sampling import ( + disabled_params_from_model_details, + is_disabled_value, + strip_disabled_kwargs, +) from uipath_langchain_client.settings import ( UiPathAPIConfig, UiPathBaseSettings, @@ -112,8 +116,14 @@ class UiPathBaseLLMClient(BaseModel, ABC): model_details: dict[str, Any] | None = Field( default=None, description="Per-model capability flags sourced from the discovery endpoint " - "(e.g. {'shouldSkipTemperature': True}). The factory forwards it; when absent, " - "``_resolve_model_details`` below fetches it from client_settings.", + "(e.g. {'shouldSkipTemperature': True}). Passed through by the factory; " + "resolved from client_settings.get_model_info otherwise.", + ) + disabled_params: dict[str, Any] | None = Field( + default=None, + description="langchain-openai-style map of parameters that must not be sent to " + "this model. Keys are param names; values are None (always disabled) or a list " + "of disallowed values. Derived from ``model_details`` when not provided.", ) default_headers: Mapping[str, str] | None = Field( @@ -148,22 +158,62 @@ class UiPathBaseLLMClient(BaseModel, ABC): description="Logger for request/response logging", ) - @model_validator(mode="after") - def _resolve_model_details(self) -> Self: - # Fetch modelDetails from the discovery endpoint when the factory - # didn't forward it. get_available_models is class-cached inside the - # settings layer, so at most one discovery HTTP call fires per process - # regardless of how many chat/embedding models are built. - if self.model_details is None: - try: - info = self.client_settings.get_model_info( - self.model_name, - byo_connection_id=self.byo_connection_id, - ) - self.model_details = info.get("modelDetails") or {} - except Exception: - self.model_details = {} - return self + @model_validator(mode="before") + @classmethod + def _resolve_model_details_and_strip(cls, values: Any) -> Any: + """Resolve ``model_details`` and apply ``disabled_params`` before validation. + + Mirrors the ``mode="before"`` + ``values.pop(...)`` pattern + ``langchain-openai`` uses for o1/GPT-5 temperature. Popping at this + stage means the field never enters ``model_fields_set``, so downstream + consumers of ``model_fields_set`` (e.g. ``UiPathChat._default_params``) + naturally skip it — no private-attribute hackery. + """ + if not isinstance(values, dict): + return values + + # 1. Resolve model_details — caller-provided wins, else fetch from settings. + if values.get("model_details") is None: + settings = values.get("client_settings") or values.get("settings") + if settings is None: + try: + settings = get_default_client_settings() + values["client_settings"] = settings + except Exception: + settings = None + model_name = values.get("model_name") or values.get("model") + if settings is not None and model_name: + try: + info = settings.get_model_info( + model_name, + byo_connection_id=values.get("byo_connection_id"), + ) + values["model_details"] = info.get("modelDetails") or {} + except Exception: + values["model_details"] = {} + else: + values["model_details"] = {} + + # 2. Derive disabled_params when caller didn't provide one. + if values.get("disabled_params") is None: + derived = disabled_params_from_model_details(values.get("model_details")) + if derived: + values["disabled_params"] = derived + + # 3. Pop any top-level field and any model_kwargs entry that matches + # the disabled_params spec, so those values never become set fields. + disabled = values.get("disabled_params") or {} + if disabled: + for key in list(values.keys()): + if key in disabled and is_disabled_value(values[key], disabled[key]): + values.pop(key, None) + model_kwargs = values.get("model_kwargs") + if isinstance(model_kwargs, dict): + for key in list(model_kwargs.keys()): + if key in disabled and is_disabled_value(model_kwargs[key], disabled[key]): + model_kwargs.pop(key, None) + + return values @cached_property def uipath_sync_client(self) -> UiPathHttpxClient: @@ -389,9 +439,9 @@ def _generate( run_manager: CallbackManagerForLLMRun | None = None, **kwargs: Any, ) -> ChatResult: - kwargs = strip_disabled_sampling_kwargs( + kwargs = strip_disabled_kwargs( kwargs, - model_details=self.model_details, + disabled_params=self.disabled_params, model_name=self.model_name, logger=self.logger, ) @@ -420,9 +470,9 @@ async def _agenerate( run_manager: AsyncCallbackManagerForLLMRun | None = None, **kwargs: Any, ) -> ChatResult: - kwargs = strip_disabled_sampling_kwargs( + kwargs = strip_disabled_kwargs( kwargs, - model_details=self.model_details, + disabled_params=self.disabled_params, model_name=self.model_name, logger=self.logger, ) @@ -453,9 +503,9 @@ def _stream( run_manager: CallbackManagerForLLMRun | None = None, **kwargs: Any, ) -> Generator[ChatGenerationChunk, None, None]: - kwargs = strip_disabled_sampling_kwargs( + kwargs = strip_disabled_kwargs( kwargs, - model_details=self.model_details, + disabled_params=self.disabled_params, model_name=self.model_name, logger=self.logger, ) @@ -489,9 +539,9 @@ async def _astream( run_manager: AsyncCallbackManagerForLLMRun | None = None, **kwargs: Any, ) -> AsyncGenerator[ChatGenerationChunk, None]: - kwargs = strip_disabled_sampling_kwargs( + kwargs = strip_disabled_kwargs( kwargs, - model_details=self.model_details, + disabled_params=self.disabled_params, model_name=self.model_name, logger=self.logger, ) diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/clients/normalized/chat_models.py b/packages/uipath_langchain_client/src/uipath_langchain_client/clients/normalized/chat_models.py index c59d3e9..bfaeb15 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/clients/normalized/chat_models.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/clients/normalized/chat_models.py @@ -179,7 +179,6 @@ class UiPathChat(UiPathBaseChatModel): seed: int | None = None model_kwargs: dict[str, Any] = Field(default_factory=dict) - disabled_params: dict[str, Any] | None = None # OpenAI logit_bias: dict[str, int] | None = None diff --git a/src/uipath/llm_client/utils/sampling.py b/src/uipath/llm_client/utils/sampling.py index 43b34eb..18d1d51 100644 --- a/src/uipath/llm_client/utils/sampling.py +++ b/src/uipath/llm_client/utils/sampling.py @@ -1,11 +1,19 @@ -"""Shared helpers for stripping sampling parameters that a model rejects. - -Reasoning-style models (e.g. ``anthropic.claude-opus-4-7``) advertise -``modelDetails.shouldSkipTemperature: true`` on the discovery endpoint. When -that flag is set, the gateway rejects the entire sampling set, not just -``temperature``. The helpers here centralize that knowledge so every framework -wrapper (LangChain chat models, future LlamaIndex wrappers, the core -normalized client, etc.) can reuse the same rule. +"""Helpers for the ``disabled_params`` convention. + +``disabled_params`` is the langchain-openai-style declaration that certain +parameters must not be sent to a model. It maps param names to either: + +- ``None``: the parameter is always disabled, regardless of its value. +- ``list[Any]``: the parameter is disabled only when its value is in the list. + +We reuse this shape so that classes inheriting from +``langchain_openai.BaseChatOpenAI`` also benefit from its native +``_filter_disabled_params`` path inside ``bind_tools``. + +The sampling-specific knowledge lives in ``disabled_params_from_model_details``: +when the gateway's discovery endpoint advertises +``modelDetails.shouldSkipTemperature: true`` on a reasoning-style model (e.g. +``anthropic.claude-opus-4-7``), the entire sampling set gets disabled. """ from collections.abc import Mapping @@ -27,35 +35,62 @@ ) -def should_skip_sampling(model_details: Mapping[str, Any] | None) -> bool: - """True iff the provided ``modelDetails`` carries ``shouldSkipTemperature``.""" - return bool(model_details and model_details.get("shouldSkipTemperature")) +def disabled_params_from_model_details( + model_details: Mapping[str, Any] | None, +) -> dict[str, Any] | None: + """Derive ``disabled_params`` from a discovery-endpoint ``modelDetails`` dict. + + Returns None when no capability flags warrant disabling anything, so callers + can distinguish "nothing to disable" from "disabled empty mapping". + """ + if not model_details: + return None + disabled: dict[str, Any] = {} + if model_details.get("shouldSkipTemperature"): + for param in DISABLED_SAMPLING_PARAMS: + disabled[param] = None + # Future gateway flags (e.g. per-param ``shouldSkipTopP``) can extend this. + return disabled or None + + +def is_disabled_value(value: Any, disabled_spec: Any) -> bool: + """Match the langchain-openai ``_filter_disabled_params`` semantics. + + ``disabled_spec`` is either None (always disabled) or an iterable of values + (disabled only when ``value`` is in the iterable). + """ + if disabled_spec is None: + return True + try: + return value in disabled_spec + except TypeError: + return False -def strip_disabled_sampling_kwargs( +def strip_disabled_kwargs( kwargs: Mapping[str, Any], *, - model_details: Mapping[str, Any] | None, + disabled_params: Mapping[str, Any] | None, model_name: str, logger: Logger | None, ) -> dict[str, Any]: - """Return a copy of ``kwargs`` with disabled sampling params removed. + """Return a copy of ``kwargs`` with entries matching ``disabled_params`` removed. - When ``model_details`` does not flag the model as sampling-less, the - input is returned unchanged (as a new dict so callers can mutate safely). - A warning is logged per stripped parameter when a logger is provided. + Uses the same matching rule as langchain-openai: a key is stripped when it + is in ``disabled_params`` AND either the spec is None or the kwarg value + matches one of the listed disabled values. Logs a warning per strip if a + logger is supplied; silent otherwise. """ out = dict(kwargs) - if not should_skip_sampling(model_details): + if not disabled_params: return out - for param in DISABLED_SAMPLING_PARAMS: - if param in out: + for key in list(out.keys()): + if key in disabled_params and is_disabled_value(out[key], disabled_params[key]): if logger is not None: logger.warning( - "Stripping unsupported invocation param %r for model %r " - "(shouldSkipTemperature=True)", - param, + "Stripping disabled invocation param %r for model %r", + key, model_name, ) - out.pop(param, None) + out.pop(key, None) return out diff --git a/tests/langchain/test_disabled_sampling_params.py b/tests/langchain/test_disabled_sampling_params.py index 31be3eb..9e28987 100644 --- a/tests/langchain/test_disabled_sampling_params.py +++ b/tests/langchain/test_disabled_sampling_params.py @@ -1,9 +1,19 @@ -"""Unit tests for invocation-time stripping of sampling params based on -``modelDetails.shouldSkipTemperature``. +"""Unit tests for the ``disabled_params`` + ``model_details`` wiring. -These tests monkeypatch ``client_settings.get_model_info`` and the instance's -``_uipath_generate`` / ``_uipath_agenerate`` to capture kwargs, so no HTTP is -made by this file. +Covers two layers: + +1. **Init-time clearing** via the ``@model_validator(mode="before")`` on + ``UiPathBaseLLMClient``: fields that match ``disabled_params`` (either + provided or derived from ``model_details.shouldSkipTemperature``) are + popped before pydantic field validation runs, so they never enter + ``model_fields_set``. + +2. **Invocation-time stripping** via ``strip_disabled_kwargs`` wired into + ``_generate``/``_agenerate``/``_stream``/``_astream`` on + ``UiPathBaseChatModel``. + +Tests monkeypatch ``client_settings.get_model_info`` and stub the +``_uipath_generate``/``_uipath_agenerate`` methods so no HTTP is ever made. """ import logging @@ -31,9 +41,7 @@ def _stub_model_info( extra: dict[str, Any] | None = None, raises: BaseException | None = None, ) -> None: - """Replace ``client_settings.get_model_info`` with a stub that returns - (or raises) a controlled value. ``monkeypatch`` reverts this at teardown. - """ + """Replace ``client_settings.get_model_info`` with a stub.""" def _stub(model_name: str, **kwargs: Any) -> dict[str, Any]: if raises is not None: @@ -54,10 +62,6 @@ def _stub(model_name: str, **kwargs: Any) -> dict[str, Any]: def _stub_generate( monkeypatch: pytest.MonkeyPatch, instance: UiPathChat, captured: dict[str, Any] ) -> None: - """Replace ``_uipath_generate`` on the instance with a stub that records the - kwargs it receives and returns a minimal ChatResult. - """ - def _stub( messages: Any, stop: Any = None, run_manager: Any = None, **kwargs: Any ) -> ChatResult: @@ -82,7 +86,74 @@ async def _stub( # --------------------------------------------------------------------------- # -# invocation-time stripping — sync +# init-time clearing (the new layer) +# --------------------------------------------------------------------------- # + + +def test_init_clears_sampling_fields_when_model_details_flag_set( + client_settings: UiPathBaseSettings, +) -> None: + llm = UiPathChat( + model="anthropic.claude-opus-4-7", + settings=client_settings, + model_details={"shouldSkipTemperature": True}, + temperature=0.5, + top_p=0.9, + frequency_penalty=0.1, + ) + # The popped fields must not be in model_fields_set, and _default_params + # (which filters by that set) must not emit them. + for param in ("temperature", "top_p", "frequency_penalty"): + assert param not in llm.model_fields_set + assert "temperature" not in llm._default_params + assert "top_p" not in llm._default_params + # disabled_params is derived from modelDetails when not explicitly passed. + assert llm.disabled_params is not None + assert set(llm.disabled_params) >= set(DISABLED_SAMPLING_PARAMS) + + +def test_user_provided_disabled_params_wins_over_model_details( + client_settings: UiPathBaseSettings, +) -> None: + llm = UiPathChat( + model="anthropic.claude-opus-4-7", + settings=client_settings, + model_details={"shouldSkipTemperature": True}, + disabled_params={"logit_bias": None}, # overrides the derivation + temperature=0.5, # should survive because logit_bias is the only disabled key + ) + assert llm.disabled_params == {"logit_bias": None} + # temperature is NOT disabled here, so the user's value survives. + assert llm.temperature == 0.5 + + +def test_init_clears_from_model_kwargs_too( + client_settings: UiPathBaseSettings, +) -> None: + llm = UiPathChat( + model="anthropic.claude-opus-4-7", + settings=client_settings, + model_details={"shouldSkipTemperature": True}, + model_kwargs={"temperature": 0.5, "max_tokens": 100}, + ) + # temperature popped from model_kwargs; max_tokens preserved. + assert "temperature" not in llm.model_kwargs + assert llm.model_kwargs == {"max_tokens": 100} + + +def test_init_no_op_when_flag_absent(client_settings: UiPathBaseSettings) -> None: + llm = UiPathChat( + model="some-chatty-model", + settings=client_settings, + model_details={}, + temperature=0.5, + ) + assert llm.temperature == 0.5 + assert "temperature" in llm.model_fields_set + + +# --------------------------------------------------------------------------- # +# invocation-time stripping # --------------------------------------------------------------------------- # @@ -99,13 +170,12 @@ def test_invoke_strips_sampling_kwargs_when_flag_set( llm.invoke("hi", temperature=0.3, top_p=0.9, top_k=5, seed=42, max_tokens=100) - # All sampling kwargs stripped; non-sampling kwargs preserved. for p in ("temperature", "top_p", "top_k", "seed"): assert p not in captured, f"{p} should have been stripped" assert captured.get("max_tokens") == 100 -def test_invoke_strips_all_listed_sampling_params( +def test_invoke_strips_every_listed_sampling_param( monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings ) -> None: llm = UiPathChat( @@ -116,8 +186,6 @@ def test_invoke_strips_all_listed_sampling_params( captured: dict[str, Any] = {} _stub_generate(monkeypatch, llm, captured) - # Pass every sampling param plus an unrelated kwarg; assert every - # sampling param is stripped and only max_tokens survives. kwargs: dict[str, Any] = {p: 0.1 for p in DISABLED_SAMPLING_PARAMS} kwargs["max_tokens"] = 50 llm.invoke("x", **kwargs) # type: ignore[arg-type] @@ -130,7 +198,7 @@ def test_invoke_strips_all_listed_sampling_params( def test_n_is_not_stripped( monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings ) -> None: - # `n` (candidate count) is intentionally NOT part of _SAMPLING_PARAMS. + # `n` (candidate count) is intentionally NOT part of DISABLED_SAMPLING_PARAMS. llm = UiPathChat( model="anthropic.claude-opus-4-7", settings=client_settings, @@ -143,11 +211,6 @@ def test_n_is_not_stripped( assert captured.get("n") == 3 -# --------------------------------------------------------------------------- # -# invocation-time stripping — async -# --------------------------------------------------------------------------- # - - @pytest.mark.asyncio async def test_ainvoke_strips_sampling_kwargs_when_flag_set( monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings @@ -166,18 +229,13 @@ async def test_ainvoke_strips_sampling_kwargs_when_flag_set( assert "top_p" not in captured -# --------------------------------------------------------------------------- # -# no-flag -> pass-through -# --------------------------------------------------------------------------- # - - def test_invoke_preserves_kwargs_when_flag_absent( monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings ) -> None: llm = UiPathChat( model="some-chatty-model", settings=client_settings, - model_details={}, # empty — no flag + model_details={}, ) captured: dict[str, Any] = {} _stub_generate(monkeypatch, llm, captured) @@ -188,20 +246,43 @@ def test_invoke_preserves_kwargs_when_flag_absent( assert captured["top_p"] == 0.9 -def test_invoke_preserves_kwargs_when_flag_false( +def test_invoke_honors_user_supplied_disabled_params( monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings ) -> None: llm = UiPathChat( model="some-chatty-model", settings=client_settings, - model_details={"shouldSkipTemperature": False}, + model_details={}, + disabled_params={"frequency_penalty": None}, ) captured: dict[str, Any] = {} _stub_generate(monkeypatch, llm, captured) - llm.invoke("hi", temperature=0.3) + llm.invoke("x", temperature=0.3, frequency_penalty=1.0) - assert captured["temperature"] == 0.3 + assert captured.get("temperature") == 0.3 + assert "frequency_penalty" not in captured + + +def test_invoke_honors_disabled_params_value_list( + monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings +) -> None: + # langchain-openai semantics: list spec means "disabled when value is in list". + llm = UiPathChat( + model="some-chatty-model", + settings=client_settings, + model_details={}, + disabled_params={"temperature": [0.0, 1.5]}, + ) + captured: dict[str, Any] = {} + _stub_generate(monkeypatch, llm, captured) + + llm.invoke("x", temperature=1.5) # matches -> stripped + assert "temperature" not in captured + + captured.clear() + llm.invoke("x", temperature=0.7) # does not match -> preserved + assert captured.get("temperature") == 0.7 # --------------------------------------------------------------------------- # @@ -228,9 +309,9 @@ def test_warning_logged_when_logger_set( llm.invoke("x", temperature=0.3) assert any( - "temperature" in rec.getMessage() and "shouldSkipTemperature" in rec.getMessage() + "temperature" in rec.getMessage() and "disabled" in rec.getMessage() for rec in caplog.records - ), "expected a warning mentioning 'temperature' and 'shouldSkipTemperature'" + ), "expected a warning mentioning 'temperature' and 'disabled'" def test_no_warning_when_logger_is_none( @@ -250,9 +331,8 @@ def test_no_warning_when_logger_is_none( with caplog.at_level(logging.DEBUG): llm.invoke("x", temperature=0.3) - # temperature was still stripped — we just don't log when logger is None. assert "temperature" not in captured - assert not any("shouldSkipTemperature" in rec.getMessage() for rec in caplog.records) + assert not any("disabled invocation param" in rec.getMessage() for rec in caplog.records) def test_no_warning_when_nothing_to_strip( @@ -271,65 +351,45 @@ def test_no_warning_when_nothing_to_strip( _stub_generate(monkeypatch, llm, captured) with caplog.at_level(logging.WARNING, logger=logger.name): - llm.invoke("x", max_tokens=50) # no sampling kwargs at all + llm.invoke("x", max_tokens=50) - assert not any("shouldSkipTemperature" in rec.getMessage() for rec in caplog.records) + assert not any("disabled invocation param" in rec.getMessage() for rec in caplog.records) # --------------------------------------------------------------------------- # -# eager model_details resolution via the UiPathBaseLLMClient validator +# discovery fallback # --------------------------------------------------------------------------- # -def test_validator_populates_model_details_on_direct_instantiation( +def test_validator_fetches_model_details_when_not_provided( monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings ) -> None: _stub_model_info(monkeypatch, client_settings, model_details={"shouldSkipTemperature": True}) - # No model_details passed — the validator should fetch and populate it. - llm = UiPathChat(model="anthropic.claude-opus-4-7", settings=client_settings) + llm = UiPathChat( + model="anthropic.claude-opus-4-7", + settings=client_settings, + temperature=0.5, + ) + # model_details resolved from discovery; disabled_params derived; temperature stripped. assert llm.model_details == {"shouldSkipTemperature": True} - - captured: dict[str, Any] = {} - _stub_generate(monkeypatch, llm, captured) - llm.invoke("x", temperature=0.5) - assert "temperature" not in captured + assert llm.disabled_params is not None + assert "temperature" in llm.disabled_params + assert "temperature" not in llm.model_fields_set def test_validator_swallows_discovery_errors( monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings ) -> None: _stub_model_info(monkeypatch, client_settings, raises=RuntimeError("boom")) - llm = UiPathChat(model="anthropic.claude-opus-4-7", settings=client_settings) - - # Discovery failure => we fall back to empty model_details and don't strip. - assert llm.model_details == {} - - captured: dict[str, Any] = {} - _stub_generate(monkeypatch, llm, captured) - llm.invoke("x", temperature=0.5) - assert captured["temperature"] == 0.5 - - -def test_validator_does_not_overwrite_explicitly_provided_model_details( - monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings -) -> None: - # If a caller (or the factory) already passed model_details, post_init - # must not call get_model_info and must not overwrite the forwarded value. - called: dict[str, bool] = {"called": False} - - def _stub(*args: Any, **kwargs: Any) -> dict[str, Any]: - called["called"] = True - return {"modelDetails": {"shouldSkipTemperature": False}} - - monkeypatch.setattr(client_settings, "get_model_info", _stub) - llm = UiPathChat( model="anthropic.claude-opus-4-7", settings=client_settings, - model_details={"shouldSkipTemperature": True}, + temperature=0.5, ) - assert llm.model_details == {"shouldSkipTemperature": True} - assert called["called"] is False + # Discovery failure => model_details is {} and nothing is stripped. + assert llm.model_details == {} + assert llm.disabled_params is None + assert llm.temperature == 0.5 # --------------------------------------------------------------------------- # @@ -337,7 +397,7 @@ def _stub(*args: Any, **kwargs: Any) -> dict[str, Any]: # --------------------------------------------------------------------------- # -def test_factory_forwards_model_details_to_normalized_chat( +def test_factory_forwards_model_details( monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings ) -> None: from uipath_langchain_client.settings import RoutingMode @@ -352,6 +412,8 @@ def test_factory_forwards_model_details_to_normalized_chat( assert isinstance(llm, UiPathChat) assert llm.model_details == {"shouldSkipTemperature": True} + assert llm.disabled_params is not None + assert "temperature" in llm.disabled_params def test_factory_forwards_empty_dict_when_no_model_details( @@ -368,5 +430,5 @@ def test_factory_forwards_empty_dict_when_no_model_details( ) assert isinstance(llm, UiPathChat) - # None -> {} via `or {}` in the factory assert llm.model_details == {} + assert llm.disabled_params is None From a73fb7d8084722321ab921ba46e99e8e18ee977f Mon Sep 17 00:00:00 2001 From: Cosmin Maria Date: Thu, 23 Apr 2026 20:41:59 +0300 Subject: [PATCH 10/14] Switch disabled_params validator from mode=before to mode=after MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same observable behavior; the after version is ~9 LOC shorter and uses typed self.* access (self.client_settings.get_model_info(self.model_name, byo_connection_id=self.byo_connection_id)) instead of the mode=before raw-dict alias juggling. The clear step uses object.__setattr__ + self.__pydantic_fields_set__.discard — one line per cleared field — which keeps UiPathChat._default_params's model_fields_set filter happy. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/uipath_langchain_client/CHANGELOG.md | 2 +- .../uipath_langchain_client/base_client.py | 95 +++++++++---------- 2 files changed, 44 insertions(+), 53 deletions(-) diff --git a/packages/uipath_langchain_client/CHANGELOG.md b/packages/uipath_langchain_client/CHANGELOG.md index 944a309..176628a 100644 --- a/packages/uipath_langchain_client/CHANGELOG.md +++ b/packages/uipath_langchain_client/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to `uipath_langchain_client` will be documented in this file ## [1.10.0] - 2026-04-23 ### Added -- `model_details` and `disabled_params` fields on `UiPathBaseLLMClient`, plus a single `@model_validator(mode="before")` that (1) forwards the factory-supplied `model_details` or fetches it from `client_settings.get_model_info`, (2) derives `disabled_params` from it when the caller didn't provide one, and (3) pops matching keys from the input `values` dict *before* pydantic field validation — the langchain-openai pattern used for o1/GPT-5 temperature. Result: `UiPathChat(model="anthropic.claude-opus-4-7", temperature=0.5)` no longer sets `temperature` on the instance (it never enters `model_fields_set`), and `llm.invoke("...", temperature=0.2)` strips it from kwargs. +- `model_details` and `disabled_params` fields on `UiPathBaseLLMClient`, plus a single `@model_validator(mode="after")` that (1) forwards the factory-supplied `model_details` or fetches it from `client_settings.get_model_info`, (2) derives `disabled_params` from it when the caller didn't provide one, and (3) clears any set field whose name matches the disabled spec — resetting the attribute to `None` and removing it from `__pydantic_fields_set__`. Result: `UiPathChat(model="anthropic.claude-opus-4-7", temperature=0.5)` no longer has `temperature` in `model_fields_set`, and `llm.invoke("...", temperature=0.2)` strips it from kwargs. - `disabled_params` uses the langchain-openai shape (`{name: None | [values]}`), so subclasses inheriting from `ChatOpenAI` / `AzureChatOpenAI` also benefit from the native `_filter_disabled_params` path inside `bind_tools`. Users can override `disabled_params` explicitly — the validator respects any value the caller passes in and only falls back to deriving from `model_details` when `None`. - Runtime stripping in the four `_generate`/`_agenerate`/`_stream`/`_astream` wrappers on `UiPathBaseChatModel` now delegates to `uipath.llm_client.utils.sampling.strip_disabled_kwargs`, which is generic over `disabled_params`. A warning is logged via `self.logger` for each stripped key when a logger is configured. 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 833039a..4a57c39 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 @@ -27,7 +27,7 @@ from abc import ABC from collections.abc import AsyncGenerator, Generator, Mapping, Sequence from functools import cached_property -from typing import Any, ClassVar, Literal +from typing import Any, ClassVar, Literal, Self from httpx import URL, Response from langchain_core.callbacks import ( @@ -158,62 +158,53 @@ class UiPathBaseLLMClient(BaseModel, ABC): description="Logger for request/response logging", ) - @model_validator(mode="before") - @classmethod - def _resolve_model_details_and_strip(cls, values: Any) -> Any: - """Resolve ``model_details`` and apply ``disabled_params`` before validation. + @model_validator(mode="after") + def _resolve_disabled_params(self) -> Self: + """Resolve ``model_details`` / ``disabled_params`` and clear any matching fields. - Mirrors the ``mode="before"`` + ``values.pop(...)`` pattern - ``langchain-openai`` uses for o1/GPT-5 temperature. Popping at this - stage means the field never enters ``model_fields_set``, so downstream - consumers of ``model_fields_set`` (e.g. ``UiPathChat._default_params``) - naturally skip it — no private-attribute hackery. + Runs after pydantic has assigned fields, so everything is accessed + via typed attributes (``self.client_settings``, ``self.model_name``) + instead of juggling raw-dict aliases. Fields that end up disabled are + both reset to ``None`` *and* removed from ``__pydantic_fields_set__`` + so downstream consumers (e.g. ``UiPathChat._default_params``, which + filters by ``model_fields_set``) naturally exclude them. """ - if not isinstance(values, dict): - return values - # 1. Resolve model_details — caller-provided wins, else fetch from settings. - if values.get("model_details") is None: - settings = values.get("client_settings") or values.get("settings") - if settings is None: - try: - settings = get_default_client_settings() - values["client_settings"] = settings - except Exception: - settings = None - model_name = values.get("model_name") or values.get("model") - if settings is not None and model_name: - try: - info = settings.get_model_info( - model_name, - byo_connection_id=values.get("byo_connection_id"), - ) - values["model_details"] = info.get("modelDetails") or {} - except Exception: - values["model_details"] = {} - else: - values["model_details"] = {} + if self.model_details is None: + try: + info = self.client_settings.get_model_info( + self.model_name, + byo_connection_id=self.byo_connection_id, + ) + self.model_details = info.get("modelDetails") or {} + except Exception: + self.model_details = {} # 2. Derive disabled_params when caller didn't provide one. - if values.get("disabled_params") is None: - derived = disabled_params_from_model_details(values.get("model_details")) - if derived: - values["disabled_params"] = derived - - # 3. Pop any top-level field and any model_kwargs entry that matches - # the disabled_params spec, so those values never become set fields. - disabled = values.get("disabled_params") or {} - if disabled: - for key in list(values.keys()): - if key in disabled and is_disabled_value(values[key], disabled[key]): - values.pop(key, None) - model_kwargs = values.get("model_kwargs") - if isinstance(model_kwargs, dict): - for key in list(model_kwargs.keys()): - if key in disabled and is_disabled_value(model_kwargs[key], disabled[key]): - model_kwargs.pop(key, None) - - return values + if self.disabled_params is None: + self.disabled_params = disabled_params_from_model_details(self.model_details) + + if not self.disabled_params: + return self + + # 3. Clear any set field whose value matches the disabled spec. + for name in list(self.model_fields_set): + if name in self.disabled_params and is_disabled_value( + getattr(self, name, None), self.disabled_params[name] + ): + object.__setattr__(self, name, None) + self.__pydantic_fields_set__.discard(name) + + # 4. Also strip matching entries from model_kwargs if the subclass has one. + model_kwargs = getattr(self, "model_kwargs", None) + if isinstance(model_kwargs, dict): + for name in list(model_kwargs.keys()): + if name in self.disabled_params and is_disabled_value( + model_kwargs[name], self.disabled_params[name] + ): + model_kwargs.pop(name, None) + + return self @cached_property def uipath_sync_client(self) -> UiPathHttpxClient: From 1ca32c9d911b7284f5089822cf37e6d2ed995774 Mon Sep 17 00:00:00 2001 From: Cosmin Maria Date: Thu, 23 Apr 2026 20:53:03 +0300 Subject: [PATCH 11/14] Trim validator to metadata resolution only; merge user-provided disabled_params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename the hook from _resolve_disabled_params to _finalize_model_metadata to reflect that it owns both model_details and disabled_params resolution (not just the disabled_params side). - Drop the field-clearing (self.temperature = None + __pydantic_fields_set__.discard) and model_kwargs-clearing steps. The validator is now purely data: resolve model_details, derive disabled_params, merge with caller-provided (user keys win). - Runtime invoke-time stripping still handles .invoke(temperature=...). Init-time values set on the instance (UiPathChat(..., temperature=0.5)) remain in model_fields_set and flow into _default_params — tracked as a follow-up in the langchain CHANGELOG. - Tests swap init-time-clearing assertions for merge-semantics cases: derived ∪ user, narrowing-override, no-derivation fallback. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/uipath_langchain_client/CHANGELOG.md | 9 ++- .../uipath_langchain_client/base_client.py | 52 ++++++--------- .../test_disabled_sampling_params.py | 64 ++++++++----------- 3 files changed, 52 insertions(+), 73 deletions(-) diff --git a/packages/uipath_langchain_client/CHANGELOG.md b/packages/uipath_langchain_client/CHANGELOG.md index 176628a..922852c 100644 --- a/packages/uipath_langchain_client/CHANGELOG.md +++ b/packages/uipath_langchain_client/CHANGELOG.md @@ -5,9 +5,9 @@ All notable changes to `uipath_langchain_client` will be documented in this file ## [1.10.0] - 2026-04-23 ### Added -- `model_details` and `disabled_params` fields on `UiPathBaseLLMClient`, plus a single `@model_validator(mode="after")` that (1) forwards the factory-supplied `model_details` or fetches it from `client_settings.get_model_info`, (2) derives `disabled_params` from it when the caller didn't provide one, and (3) clears any set field whose name matches the disabled spec — resetting the attribute to `None` and removing it from `__pydantic_fields_set__`. Result: `UiPathChat(model="anthropic.claude-opus-4-7", temperature=0.5)` no longer has `temperature` in `model_fields_set`, and `llm.invoke("...", temperature=0.2)` strips it from kwargs. -- `disabled_params` uses the langchain-openai shape (`{name: None | [values]}`), so subclasses inheriting from `ChatOpenAI` / `AzureChatOpenAI` also benefit from the native `_filter_disabled_params` path inside `bind_tools`. Users can override `disabled_params` explicitly — the validator respects any value the caller passes in and only falls back to deriving from `model_details` when `None`. -- Runtime stripping in the four `_generate`/`_agenerate`/`_stream`/`_astream` wrappers on `UiPathBaseChatModel` now delegates to `uipath.llm_client.utils.sampling.strip_disabled_kwargs`, which is generic over `disabled_params`. A warning is logged via `self.logger` for each stripped key when a logger is configured. +- `model_details` and `disabled_params` fields on `UiPathBaseLLMClient`, plus a single `@model_validator(mode="after") _finalize_model_metadata` that (1) forwards the factory-supplied `model_details` or fetches it from `client_settings.get_model_info`, and (2) sets `disabled_params` to the merge of what the caller passed and what `disabled_params_from_model_details` derives — user keys win on conflicts, so callers can override any derived entry by name. +- `disabled_params` uses the langchain-openai shape (`{name: None | [values]}`), so subclasses inheriting from `ChatOpenAI` / `AzureChatOpenAI` also benefit from the native `_filter_disabled_params` path inside `bind_tools`. +- Runtime stripping in the four `_generate`/`_agenerate`/`_stream`/`_astream` wrappers on `UiPathBaseChatModel` delegates to `uipath.llm_client.utils.sampling.strip_disabled_kwargs`, generic over `disabled_params`. A warning is logged via `self.logger` for each stripped key when a logger is configured. Fixes `anthropic.claude-opus-4-7` rejecting any sampling parameter passed via `.invoke()` / `.ainvoke()` / streams. ### Removed - The unused `disabled_params` field declaration on `UiPathChat` (now inherited from `UiPathBaseLLMClient`). @@ -15,6 +15,9 @@ All notable changes to `uipath_langchain_client` will be documented in this file ### Changed - Bumped `uipath-llm-client` floor to `>=1.10.0` to match the release that adds `uipath.llm_client.utils.sampling`. +### Known follow-up +- Init-time values set on the instance (`UiPathChat(model="anthropic.claude-opus-4-7", temperature=0.5)`) still flow into the outgoing request body via `_default_params` / the vendor SDK. The runtime invoke-time strip handles `.invoke(..., temperature=...)`; a follow-up will plug the init-time leak using the already-populated `disabled_params`. + ## [1.9.9] - 2026-04-23 ### Changed 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 4a57c39..cac03db 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,7 +51,6 @@ ) from uipath.llm_client.utils.sampling import ( disabled_params_from_model_details, - is_disabled_value, strip_disabled_kwargs, ) from uipath_langchain_client.settings import ( @@ -159,17 +158,21 @@ class UiPathBaseLLMClient(BaseModel, ABC): ) @model_validator(mode="after") - def _resolve_disabled_params(self) -> Self: - """Resolve ``model_details`` / ``disabled_params`` and clear any matching fields. - - Runs after pydantic has assigned fields, so everything is accessed - via typed attributes (``self.client_settings``, ``self.model_name``) - instead of juggling raw-dict aliases. Fields that end up disabled are - both reset to ``None`` *and* removed from ``__pydantic_fields_set__`` - so downstream consumers (e.g. ``UiPathChat._default_params``, which - filters by ``model_fields_set``) naturally exclude them. + def _finalize_model_metadata(self) -> Self: + """Resolve ``model_details`` from discovery and merge ``disabled_params``. + + Runs after pydantic has validated the fields, so ``self.client_settings`` + (with its ``default_factory``) and ``self.model_name`` are already live. + + ``model_details`` is resolved once: caller-forwarded value wins, then a + lookup against ``client_settings.get_model_info`` (backed by the + class-cached discovery response), else an empty mapping on failure. + + ``disabled_params`` is the merge of what the caller passed and what we + 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. """ - # 1. Resolve model_details — caller-provided wins, else fetch from settings. if self.model_details is None: try: info = self.client_settings.get_model_info( @@ -180,29 +183,10 @@ def _resolve_disabled_params(self) -> Self: except Exception: self.model_details = {} - # 2. Derive disabled_params when caller didn't provide one. - if self.disabled_params is None: - self.disabled_params = disabled_params_from_model_details(self.model_details) - - if not self.disabled_params: - return self - - # 3. Clear any set field whose value matches the disabled spec. - for name in list(self.model_fields_set): - if name in self.disabled_params and is_disabled_value( - getattr(self, name, None), self.disabled_params[name] - ): - object.__setattr__(self, name, None) - self.__pydantic_fields_set__.discard(name) - - # 4. Also strip matching entries from model_kwargs if the subclass has one. - model_kwargs = getattr(self, "model_kwargs", None) - if isinstance(model_kwargs, dict): - for name in list(model_kwargs.keys()): - if name in self.disabled_params and is_disabled_value( - model_kwargs[name], self.disabled_params[name] - ): - model_kwargs.pop(name, None) + derived = disabled_params_from_model_details(self.model_details) or {} + user_provided = self.disabled_params or {} + merged = {**derived, **user_provided} + self.disabled_params = merged or None return self diff --git a/tests/langchain/test_disabled_sampling_params.py b/tests/langchain/test_disabled_sampling_params.py index 9e28987..b80000f 100644 --- a/tests/langchain/test_disabled_sampling_params.py +++ b/tests/langchain/test_disabled_sampling_params.py @@ -2,11 +2,11 @@ Covers two layers: -1. **Init-time clearing** via the ``@model_validator(mode="before")`` on - ``UiPathBaseLLMClient``: fields that match ``disabled_params`` (either - provided or derived from ``model_details.shouldSkipTemperature``) are - popped before pydantic field validation runs, so they never enter - ``model_fields_set``. +1. **Metadata resolution** via ``@model_validator(mode="after")`` on + ``UiPathBaseLLMClient``: ``model_details`` is forwarded by the factory or + fetched from ``client_settings.get_model_info``; ``disabled_params`` is the + merge of anything the caller passed and what we can derive from + ``model_details``. 2. **Invocation-time stripping** via ``strip_disabled_kwargs`` wired into ``_generate``/``_agenerate``/``_stream``/``_astream`` on @@ -86,70 +86,64 @@ async def _stub( # --------------------------------------------------------------------------- # -# init-time clearing (the new layer) +# metadata resolution (model_details + disabled_params) # --------------------------------------------------------------------------- # -def test_init_clears_sampling_fields_when_model_details_flag_set( +def test_disabled_params_derived_from_model_details_flag( client_settings: UiPathBaseSettings, ) -> None: llm = UiPathChat( model="anthropic.claude-opus-4-7", settings=client_settings, model_details={"shouldSkipTemperature": True}, - temperature=0.5, - top_p=0.9, - frequency_penalty=0.1, ) - # The popped fields must not be in model_fields_set, and _default_params - # (which filters by that set) must not emit them. - for param in ("temperature", "top_p", "frequency_penalty"): - assert param not in llm.model_fields_set - assert "temperature" not in llm._default_params - assert "top_p" not in llm._default_params - # disabled_params is derived from modelDetails when not explicitly passed. assert llm.disabled_params is not None - assert set(llm.disabled_params) >= set(DISABLED_SAMPLING_PARAMS) + assert set(llm.disabled_params) == set(DISABLED_SAMPLING_PARAMS) -def test_user_provided_disabled_params_wins_over_model_details( +def test_user_provided_disabled_params_merges_with_derived( client_settings: UiPathBaseSettings, ) -> None: + # modelDetails derives the sampling set; caller adds an extra key + # (logit_bias is already in the sampling set, so we use stream_usage + # to demonstrate a truly additive merge). llm = UiPathChat( model="anthropic.claude-opus-4-7", settings=client_settings, model_details={"shouldSkipTemperature": True}, - disabled_params={"logit_bias": None}, # overrides the derivation - temperature=0.5, # should survive because logit_bias is the only disabled key + disabled_params={"stream_usage": None}, ) - assert llm.disabled_params == {"logit_bias": None} - # temperature is NOT disabled here, so the user's value survives. - assert llm.temperature == 0.5 + assert llm.disabled_params is not None + assert set(llm.disabled_params) == set(DISABLED_SAMPLING_PARAMS) | {"stream_usage"} -def test_init_clears_from_model_kwargs_too( +def test_user_provided_disabled_params_overrides_derived_entry( client_settings: UiPathBaseSettings, ) -> None: + # If the caller supplies a narrower spec for an already-derived key + # (e.g. only disable temperature when value is 0.0), their spec wins. llm = UiPathChat( model="anthropic.claude-opus-4-7", settings=client_settings, model_details={"shouldSkipTemperature": True}, - model_kwargs={"temperature": 0.5, "max_tokens": 100}, + disabled_params={"temperature": [0.0]}, ) - # temperature popped from model_kwargs; max_tokens preserved. - assert "temperature" not in llm.model_kwargs - assert llm.model_kwargs == {"max_tokens": 100} + assert llm.disabled_params is not None + assert llm.disabled_params["temperature"] == [0.0] + # Other derived keys remain disabled unconditionally. + assert llm.disabled_params["top_p"] is None -def test_init_no_op_when_flag_absent(client_settings: UiPathBaseSettings) -> None: +def test_no_disabled_params_when_flag_absent( + client_settings: UiPathBaseSettings, +) -> None: llm = UiPathChat( model="some-chatty-model", settings=client_settings, model_details={}, - temperature=0.5, ) - assert llm.temperature == 0.5 - assert "temperature" in llm.model_fields_set + assert llm.disabled_params is None # --------------------------------------------------------------------------- # @@ -368,13 +362,11 @@ def test_validator_fetches_model_details_when_not_provided( llm = UiPathChat( model="anthropic.claude-opus-4-7", settings=client_settings, - temperature=0.5, ) - # model_details resolved from discovery; disabled_params derived; temperature stripped. + # model_details resolved from discovery; disabled_params derived. assert llm.model_details == {"shouldSkipTemperature": True} assert llm.disabled_params is not None assert "temperature" in llm.disabled_params - assert "temperature" not in llm.model_fields_set def test_validator_swallows_discovery_errors( From 283819e157e6e0a07d5da8cb6a11c436666a56a3 Mon Sep 17 00:00:00 2001 From: Cosmin Maria Date: Thu, 23 Apr 2026 20:57:49 +0300 Subject: [PATCH 12/14] Rename metadata hook to setup_model_info Matches the existing setup_* convention on the other validators (setup_uipath_client, setup_api_flavor_and_version) and ties the name to the get_model_info call it consumes. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/uipath_langchain_client/CHANGELOG.md | 2 +- .../src/uipath_langchain_client/base_client.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/uipath_langchain_client/CHANGELOG.md b/packages/uipath_langchain_client/CHANGELOG.md index 922852c..372b957 100644 --- a/packages/uipath_langchain_client/CHANGELOG.md +++ b/packages/uipath_langchain_client/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to `uipath_langchain_client` will be documented in this file ## [1.10.0] - 2026-04-23 ### Added -- `model_details` and `disabled_params` fields on `UiPathBaseLLMClient`, plus a single `@model_validator(mode="after") _finalize_model_metadata` that (1) forwards the factory-supplied `model_details` or fetches it from `client_settings.get_model_info`, and (2) sets `disabled_params` to the merge of what the caller passed and what `disabled_params_from_model_details` derives — user keys win on conflicts, so callers can override any derived entry by name. +- `model_details` and `disabled_params` fields on `UiPathBaseLLMClient`, plus a single `@model_validator(mode="after") setup_model_info` that (1) forwards the factory-supplied `model_details` or fetches it from `client_settings.get_model_info`, and (2) sets `disabled_params` to the merge of what the caller passed and what `disabled_params_from_model_details` derives — user keys win on conflicts, so callers can override any derived entry by name. - `disabled_params` uses the langchain-openai shape (`{name: None | [values]}`), so subclasses inheriting from `ChatOpenAI` / `AzureChatOpenAI` also benefit from the native `_filter_disabled_params` path inside `bind_tools`. - Runtime stripping in the four `_generate`/`_agenerate`/`_stream`/`_astream` wrappers on `UiPathBaseChatModel` delegates to `uipath.llm_client.utils.sampling.strip_disabled_kwargs`, generic over `disabled_params`. A warning is logged via `self.logger` for each stripped key when a logger is configured. Fixes `anthropic.claude-opus-4-7` rejecting any sampling parameter passed via `.invoke()` / `.ainvoke()` / streams. 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 cac03db..a3ee366 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 @@ -158,7 +158,7 @@ class UiPathBaseLLMClient(BaseModel, ABC): ) @model_validator(mode="after") - def _finalize_model_metadata(self) -> Self: + def setup_model_info(self) -> Self: """Resolve ``model_details`` from discovery and merge ``disabled_params``. Runs after pydantic has validated the fields, so ``self.client_settings`` From 62a6a0f162f51783d3996f3c74d5e54b1db327ce Mon Sep 17 00:00:00 2001 From: Cosmin Maria Date: Thu, 23 Apr 2026 21:06:22 +0300 Subject: [PATCH 13/14] Add OpenAI / Azure interop tests for disabled_params merging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins down how our setup_model_info validator composes with langchain-openai's native disabled_params conventions: - UiPathChatOpenAI derives disabled_params from model_details (gateway shouldSkipTemperature) like our normalized UiPathChat does. - User-supplied disabled_params merge with the derived set; user keys win on conflict (e.g. narrowing temperature to a value-list spec). - UiPathAzureChatOpenAI's native auto-init of {"parallel_tool_calls": None} (for non-gpt-4o models) runs BEFORE our validator in MRO order. Our merge then treats that as a caller-provided value and preserves it alongside the derived sampling set. Neither convention is lost. - End-to-end: runtime .invoke() strip on UiPathChatOpenAI honors the merged set — drops both derived sampling kwargs AND user-supplied parallel_tool_calls. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../test_disabled_sampling_params.py | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/tests/langchain/test_disabled_sampling_params.py b/tests/langchain/test_disabled_sampling_params.py index b80000f..42ed116 100644 --- a/tests/langchain/test_disabled_sampling_params.py +++ b/tests/langchain/test_disabled_sampling_params.py @@ -23,6 +23,10 @@ from langchain_core.messages import AIMessage from langchain_core.outputs import ChatGeneration, ChatResult from uipath_langchain_client.clients.normalized.chat_models import UiPathChat +from uipath_langchain_client.clients.openai.chat_models import ( + UiPathAzureChatOpenAI, + UiPathChatOpenAI, +) from uipath_langchain_client.factory import get_chat_model from uipath.llm_client.settings import UiPathBaseSettings @@ -424,3 +428,131 @@ def test_factory_forwards_empty_dict_when_no_model_details( assert isinstance(llm, UiPathChat) assert llm.model_details == {} assert llm.disabled_params is None + + +# --------------------------------------------------------------------------- # +# interop with langchain-openai's native ``disabled_params`` +# --------------------------------------------------------------------------- # +# +# ``BaseChatOpenAI`` (and thus ``UiPathChatOpenAI``) already declares +# ``disabled_params: dict[str, Any] | None`` with the same shape we use. These +# tests pin down: +# - Our ``setup_model_info`` derives + merges correctly on an OpenAI subclass. +# - Caller-supplied keys (e.g. langchain-openai's classic ``parallel_tool_calls``) +# survive the merge alongside the gateway-derived sampling set. +# - ``UiPathAzureChatOpenAI``'s native auto-init of +# ``{"parallel_tool_calls": None}`` (fires only when ``disabled_params is None``) +# still works when we have nothing to contribute (flag absent). + + +def test_openai_subclass_derives_disabled_params_from_model_details( + client_settings: UiPathBaseSettings, +) -> None: + llm = UiPathChatOpenAI( + model="some-reasoning-openai-model", + client_settings=client_settings, + model_details={"shouldSkipTemperature": True}, + ) + assert llm.disabled_params is not None + assert set(llm.disabled_params) == set(DISABLED_SAMPLING_PARAMS) + + +def test_openai_subclass_merges_user_disabled_params_with_derived( + client_settings: UiPathBaseSettings, +) -> None: + # langchain-openai's classic disabled_params usage: disable parallel_tool_calls. + # Our setup_model_info should merge it with the gateway-derived sampling set. + llm = UiPathChatOpenAI( + model="some-reasoning-openai-model", + client_settings=client_settings, + model_details={"shouldSkipTemperature": True}, + disabled_params={"parallel_tool_calls": None}, + ) + assert llm.disabled_params is not None + assert set(llm.disabled_params) == set(DISABLED_SAMPLING_PARAMS) | {"parallel_tool_calls"} + + +def test_openai_subclass_user_override_wins_on_conflict( + client_settings: UiPathBaseSettings, +) -> None: + # If the caller narrows a derived key (e.g. "disable temperature only at 0.0"), + # their more specific spec must win over the unconditional None from the + # derivation. + llm = UiPathChatOpenAI( + model="some-reasoning-openai-model", + client_settings=client_settings, + model_details={"shouldSkipTemperature": True}, + disabled_params={"temperature": [0.0]}, + ) + assert llm.disabled_params is not None + assert llm.disabled_params["temperature"] == [0.0] + # Other sampling-set keys remain unconditionally disabled. + assert llm.disabled_params["top_p"] is None + + +def test_azure_autoinit_parallel_tool_calls_still_fires_without_flag( + client_settings: UiPathBaseSettings, +) -> None: + # AzureChatOpenAI auto-sets {"parallel_tool_calls": None} in its own + # model_validator when disabled_params is None and the model is not gpt-4o. + # With no shouldSkipTemperature, setup_model_info leaves disabled_params as + # None, so Azure's native logic must still fire. + llm = UiPathAzureChatOpenAI( + model="gpt-5.1", # not gpt-4o -> Azure auto-init applies + client_settings=client_settings, + model_details={}, + ) + assert llm.disabled_params == {"parallel_tool_calls": None} + + +def test_azure_autoinit_parallel_tool_calls_merges_with_our_derivation( + client_settings: UiPathBaseSettings, +) -> None: + # AzureChatOpenAI's own model_validator runs before ours in MRO order and + # sets ``disabled_params = {"parallel_tool_calls": None}`` (for non-gpt-4o + # models). Our setup_model_info then treats that as a caller-provided + # value and merges the derived sampling set on top. Result: BOTH Azure's + # classic parallel_tool_calls restriction AND the gateway's + # shouldSkipTemperature-derived sampling set end up in disabled_params — + # neither convention is lost. + llm = UiPathAzureChatOpenAI( + model="gpt-5.1", # not gpt-4o -> Azure auto-init applies + client_settings=client_settings, + model_details={"shouldSkipTemperature": True}, + ) + assert llm.disabled_params is not None + assert set(llm.disabled_params) == set(DISABLED_SAMPLING_PARAMS) | {"parallel_tool_calls"} + + +def test_openai_subclass_runtime_strip_honors_merged_disabled_params( + monkeypatch: pytest.MonkeyPatch, client_settings: UiPathBaseSettings +) -> None: + # End-to-end: runtime invoke-time strip on an OpenAI subclass sees the + # merged disabled_params and drops both the derived sampling key AND the + # user-supplied parallel_tool_calls. + llm = UiPathChatOpenAI( + model="some-reasoning-openai-model", + client_settings=client_settings, + model_details={"shouldSkipTemperature": True}, + disabled_params={"parallel_tool_calls": None}, + ) + captured: dict[str, Any] = {} + + def _stub_uipath_generate( + messages: Any, stop: Any = None, run_manager: Any = None, **kwargs: Any + ) -> ChatResult: + captured.update(kwargs) + return ChatResult(generations=[ChatGeneration(message=AIMessage(content="ok"))]) + + monkeypatch.setattr(llm, "_uipath_generate", _stub_uipath_generate) + + llm.invoke( + "hi", + temperature=0.3, # derived disable + parallel_tool_calls=True, # user-supplied disable + max_tokens=50, # unrelated, survives + ) + + assert "temperature" not in captured + assert "parallel_tool_calls" not in captured + assert captured.get("max_tokens") == 50 From 5d026ef3f686f990a1f21d6d3e700749819c7011 Mon Sep 17 00:00:00 2001 From: Cosmin Maria Date: Thu, 23 Apr 2026 21:09:09 +0300 Subject: [PATCH 14/14] Nit: use settings= (pydantic alias) instead of client_settings= in new tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pyright can't see the field-name alias on UiPath*ChatOpenAI constructor calls — it only sees the declared alias "settings". Switch the six new OpenAI interop tests to settings= to match what pyright expects. Runtime behavior is identical (validate_by_name + validate_by_alias both on). Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/langchain/test_disabled_sampling_params.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/langchain/test_disabled_sampling_params.py b/tests/langchain/test_disabled_sampling_params.py index 42ed116..712400d 100644 --- a/tests/langchain/test_disabled_sampling_params.py +++ b/tests/langchain/test_disabled_sampling_params.py @@ -450,7 +450,7 @@ def test_openai_subclass_derives_disabled_params_from_model_details( ) -> None: llm = UiPathChatOpenAI( model="some-reasoning-openai-model", - client_settings=client_settings, + settings=client_settings, model_details={"shouldSkipTemperature": True}, ) assert llm.disabled_params is not None @@ -464,7 +464,7 @@ def test_openai_subclass_merges_user_disabled_params_with_derived( # Our setup_model_info should merge it with the gateway-derived sampling set. llm = UiPathChatOpenAI( model="some-reasoning-openai-model", - client_settings=client_settings, + settings=client_settings, model_details={"shouldSkipTemperature": True}, disabled_params={"parallel_tool_calls": None}, ) @@ -480,7 +480,7 @@ def test_openai_subclass_user_override_wins_on_conflict( # derivation. llm = UiPathChatOpenAI( model="some-reasoning-openai-model", - client_settings=client_settings, + settings=client_settings, model_details={"shouldSkipTemperature": True}, disabled_params={"temperature": [0.0]}, ) @@ -499,7 +499,7 @@ def test_azure_autoinit_parallel_tool_calls_still_fires_without_flag( # None, so Azure's native logic must still fire. llm = UiPathAzureChatOpenAI( model="gpt-5.1", # not gpt-4o -> Azure auto-init applies - client_settings=client_settings, + settings=client_settings, model_details={}, ) assert llm.disabled_params == {"parallel_tool_calls": None} @@ -517,7 +517,7 @@ def test_azure_autoinit_parallel_tool_calls_merges_with_our_derivation( # neither convention is lost. llm = UiPathAzureChatOpenAI( model="gpt-5.1", # not gpt-4o -> Azure auto-init applies - client_settings=client_settings, + settings=client_settings, model_details={"shouldSkipTemperature": True}, ) assert llm.disabled_params is not None @@ -532,7 +532,7 @@ def test_openai_subclass_runtime_strip_honors_merged_disabled_params( # user-supplied parallel_tool_calls. llm = UiPathChatOpenAI( model="some-reasoning-openai-model", - client_settings=client_settings, + settings=client_settings, model_details={"shouldSkipTemperature": True}, disabled_params={"parallel_tool_calls": None}, )