diff --git a/CHANGELOG.md b/CHANGELOG.md index 935890f..541365a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,8 @@ All notable changes to `uipath_llm_client` (core package) will be documented in ## [1.9.9] - 2026-04-23 -### Changed -- Bumped dependency floors to the latest installed versions: `uipath-platform>=0.1.35`. +### Added +- `is_claude_opus_4_or_above(model_name)` and `CLAUDE_OPUS_4_UNSUPPORTED_SAMPLING_PARAMS` added to `uipath.llm_client.utils.model_family` — identifies Claude Opus 4+ reasoning models and the sampling parameters (`temperature`, `top_k`, `top_p`) that the Anthropic API rejects for them. ## [1.9.8] - 2026-04-22 diff --git a/packages/uipath_langchain_client/CHANGELOG.md b/packages/uipath_langchain_client/CHANGELOG.md index 5701366..09fcfe8 100644 --- a/packages/uipath_langchain_client/CHANGELOG.md +++ b/packages/uipath_langchain_client/CHANGELOG.md @@ -4,9 +4,15 @@ All notable changes to `uipath_langchain_client` will be documented in this file ## [1.9.9] - 2026-04-23 -### Changed -- Bumped dependency floors to the latest installed versions: `langchain-openai>=1.2.0`, `langchain-aws>=1.4.5`. -- Minimum `uipath-llm-client` bumped to 1.9.9 to match the core dependency-floor release. +### Added +- `UiPathBaseLLMClient.model_details` — optional constructor field carrying the discovery `modelDetails` dict (`shouldSkipTemperature`, `maxOutputTokens`, etc.). If omitted, resolved lazily via `get_model_info()`. Pass explicitly to skip the discovery call at first use. +- `UiPathBaseLLMClient.resolved_model_details` / `_should_skip_sampling_params` cached properties — read `modelDetails.shouldSkipTemperature` with a name-based fallback (`is_claude_opus_4_or_above`) for models not found in discovery. +- `get_chat_model` / `get_embedding_model` now forward `modelDetails` from the discovery lookup into the constructed client's `model_details` kwarg (via `setdefault`, so an explicit user-provided override still wins). Eliminates a redundant discovery fetch inside `resolved_model_details`. + +### Fixed +- Sampling params (`temperature`, `top_k`, `top_p`) are now stripped centrally in `UiPathBaseChatModel` for models whose discovery marks `shouldSkipTemperature: True` (e.g. `anthropic.claude-opus-4-7`). Stripping happens at both sites: (1) a `model_validator` nulls the instance fields and discards them from `__pydantic_fields_set__` at construction time — so `UiPathChat(model="...", temperature=0.6)` no longer forwards the value; and (2) the `_generate` / `_agenerate` / `_stream` / `_astream` wrappers pop the same keys from invocation kwargs — so `llm.invoke("msg", temperature=0.7)` and `.bind(temperature=0.7)` are also handled. Previously each client had its own ad-hoc stripping path. +- `UiPathChatBedrockConverse._converse_params` defensively drops `None` `temperature` / `topP` from `inferenceConfig` so boto3 doesn't serialize them as explicit nulls to the wire. +- `UiPathChat._default_params` still drops `temperature` when `thinking` is set (Anthropic's extended thinking API requires `temperature=1`). ## [1.9.8] - 2026-04-22 @@ -437,3 +443,4 @@ All notable changes to `uipath_langchain_client` will be documented in this file - Tool/function calling support - Full compatibility with LangChain's `BaseChatModel` interface - httpx-based HTTP handling for consistent behavior + diff --git a/packages/uipath_langchain_client/pyproject.toml b/packages/uipath_langchain_client/pyproject.toml index bca0acc..afc9945 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.9.10,<2.0.0", ] [project.optional-dependencies] diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/__version__.py b/packages/uipath_langchain_client/src/uipath_langchain_client/__version__.py index b612be4..fca0434 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.9.10" 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..1a18649 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 @@ -38,7 +38,8 @@ 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 typing_extensions import Self from uipath.llm_client.httpx_client import ( UiPathHttpxAsyncClient, @@ -49,6 +50,10 @@ get_captured_response_headers, set_captured_response_headers, ) +from uipath.llm_client.utils.model_family import ( + CLAUDE_OPUS_4_UNSUPPORTED_SAMPLING_PARAMS, + is_claude_opus_4_or_above, +) from uipath_langchain_client.settings import ( UiPathAPIConfig, UiPathBaseSettings, @@ -98,6 +103,13 @@ class UiPathBaseLLMClient(BaseModel, ABC): "Use this when you have enrolled your own model deployment and received a connection ID.", ) + model_details: dict[str, Any] | None = Field( + default=None, + description="Optional modelDetails from discovery (e.g. `shouldSkipTemperature`, " + "`maxOutputTokens`). If omitted, resolved lazily via `client_settings.get_model_info()`. " + "Pass explicitly to avoid a discovery call at first use.", + ) + api_config: UiPathAPIConfig = Field( ..., description="Settings for the UiPath API", @@ -190,6 +202,30 @@ def uipath_async_client(self) -> UiPathHttpxAsyncClient: logger=self.logger, ) + @cached_property + def resolved_model_details(self) -> dict[str, Any]: + """Resolved modelDetails: the ``model_details`` field if provided, otherwise + fetched via ``client_settings.get_model_info()``; empty dict on failure.""" + if self.model_details is not None: + return self.model_details + try: + info = self.client_settings.get_model_info(model_name=self.model_name) + return info.get("modelDetails", {}) or {} + except Exception: + return {} + + @cached_property + def _should_skip_sampling_params(self) -> bool: + """True if the model's discovery metadata marks temperature/top_k/top_p as unsupported. + + Reads ``modelDetails.shouldSkipTemperature`` from the model discovery API. + Falls back to a name-based heuristic when the model is not found in discovery. + """ + flag = self.resolved_model_details.get("shouldSkipTemperature") + if flag is not None: + return bool(flag) + return is_claude_opus_4_or_above(self.model_name) + def uipath_request( self, method: Literal["POST", "GET"] = "POST", @@ -357,6 +393,31 @@ class UiPathBaseChatModel(UiPathBaseLLMClient, BaseChatModel): so that headers are captured transparently. """ + @model_validator(mode="after") + def _strip_unsupported_sampling_params_at_init(self) -> Self: + """If the model rejects sampling params, null those instance fields and + remove them from ``__pydantic_fields_set__`` so downstream payload builders + (including ``_default_params`` filters on ``model_fields_set``) treat them + as unset.""" + if not self._should_skip_sampling_params: + return self + for param in CLAUDE_OPUS_4_UNSUPPORTED_SAMPLING_PARAMS: + if param not in type(self).model_fields: + continue + try: + setattr(self, param, None) + except Exception: + continue + self.__pydantic_fields_set__.discard(param) + return self + + def _strip_sampling_from_kwargs(self, kwargs: dict[str, Any]) -> None: + """Pop temperature/top_k/top_p from invocation-time kwargs for unsupported models.""" + if not self._should_skip_sampling_params: + return + for param in CLAUDE_OPUS_4_UNSUPPORTED_SAMPLING_PARAMS: + kwargs.pop(param, None) + def _generate( self, messages: list[BaseMessage], @@ -364,6 +425,7 @@ def _generate( run_manager: CallbackManagerForLLMRun | None = None, **kwargs: Any, ) -> ChatResult: + self._strip_sampling_from_kwargs(kwargs) set_captured_response_headers({}) try: result = self._uipath_generate(messages, stop=stop, run_manager=run_manager, **kwargs) @@ -389,6 +451,7 @@ async def _agenerate( run_manager: AsyncCallbackManagerForLLMRun | None = None, **kwargs: Any, ) -> ChatResult: + self._strip_sampling_from_kwargs(kwargs) set_captured_response_headers({}) try: result = await self._uipath_agenerate( @@ -416,6 +479,7 @@ def _stream( run_manager: CallbackManagerForLLMRun | None = None, **kwargs: Any, ) -> Generator[ChatGenerationChunk, None, None]: + self._strip_sampling_from_kwargs(kwargs) set_captured_response_headers({}) try: first = True @@ -446,6 +510,7 @@ async def _astream( run_manager: AsyncCallbackManagerForLLMRun | None = None, **kwargs: Any, ) -> AsyncGenerator[ChatGenerationChunk, None]: + self._strip_sampling_from_kwargs(kwargs) set_captured_response_headers({}) try: first = True diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/clients/bedrock/chat_models.py b/packages/uipath_langchain_client/src/uipath_langchain_client/clients/bedrock/chat_models.py index 5df1cf2..b68d7da 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/clients/bedrock/chat_models.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/clients/bedrock/chat_models.py @@ -2,6 +2,7 @@ from typing import Any, Self from pydantic import Field, model_validator +from typing_extensions import override from uipath_langchain_client.base_client import UiPathBaseChatModel from uipath_langchain_client.settings import ( @@ -76,6 +77,23 @@ def setup_uipath_client(self) -> Self: self.client = WrappedBotoClient(self.uipath_sync_client) return self + @override + def _converse_params(self, **kwargs: Any) -> dict: + """Defensively strip None sampling values from inferenceConfig. + + The parent populates ``inferenceConfig["temperature"]`` / ``["topP"]`` from + ``self.temperature`` / ``self.top_p`` unconditionally; if those are None + (e.g. because ``UiPathBaseChatModel`` nulled them for a model that rejects + sampling params) boto3 would still serialize ``null`` to the wire. + """ + params = super()._converse_params(**kwargs) + inference = params.get("inferenceConfig") + if isinstance(inference, dict): + for key in ("temperature", "topP"): + if inference.get(key) is None: + inference.pop(key, None) + return params + class UiPathChatBedrock(UiPathBaseChatModel, ChatBedrock): # type: ignore[override] api_config: UiPathAPIConfig = UiPathAPIConfig( 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..821d723 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 @@ -243,10 +243,15 @@ def _default_params(self) -> dict[str, Any]: } set_fields = self.model_fields_set - return { - **{k: v for k, v in candidates.items() if k in set_fields}, - **self.model_kwargs, - } + params = {k: v for k, v in candidates.items() if k in set_fields} + + # Anthropic extended thinking requires temperature=1 (the API default). + # Sending any explicit temperature alongside thinking causes a validation error, + # so we drop it here and let the gateway apply the correct default. + if "thinking" in params: + params.pop("temperature", None) + + return {**params, **self.model_kwargs} def _get_usage_metadata(self, json_data: dict[str, Any]) -> UsageMetadata: return UsageMetadata( 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..68a82d1 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/factory.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/factory.py @@ -86,6 +86,10 @@ def get_chat_model( ) model_family = model_info.get("modelFamily", None) + model_details = model_info.get("modelDetails") + if model_details is not None: + model_kwargs.setdefault("model_details", model_details) + if custom_class is not None: return custom_class( model=model_name, @@ -266,6 +270,10 @@ def get_embedding_model( ) model_family = model_info.get("modelFamily", None) + model_details = model_info.get("modelDetails") + if model_details is not None: + model_kwargs.setdefault("model_details", model_details) + if custom_class is not None: return custom_class( model=model_name, 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..b28bb1a 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/utils.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/utils.py @@ -15,7 +15,9 @@ ) from uipath.llm_client.utils.model_family import ( ANTHROPIC_MODEL_NAME_KEYWORDS, + CLAUDE_OPUS_4_UNSUPPORTED_SAMPLING_PARAMS, is_anthropic_model_name, + is_claude_opus_4_or_above, ) from uipath.llm_client.utils.retry import RetryConfig @@ -36,4 +38,6 @@ "UiPathTooManyRequestsError", "ANTHROPIC_MODEL_NAME_KEYWORDS", "is_anthropic_model_name", + "CLAUDE_OPUS_4_UNSUPPORTED_SAMPLING_PARAMS", + "is_claude_opus_4_or_above", ] diff --git a/src/uipath/llm_client/__version__.py b/src/uipath/llm_client/__version__.py index bd9af64..ae9cfbf 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.9.10" diff --git a/src/uipath/llm_client/utils/model_family.py b/src/uipath/llm_client/utils/model_family.py index a7c7618..df1f115 100644 --- a/src/uipath/llm_client/utils/model_family.py +++ b/src/uipath/llm_client/utils/model_family.py @@ -4,6 +4,8 @@ deployments do not expose it. These helpers provide a name-based fallback. """ +import re + ANTHROPIC_MODEL_NAME_KEYWORDS: tuple[str, ...] = ( "anthropic", "claude", @@ -13,8 +15,26 @@ "mythos", ) +# Sampling parameters that Claude Opus 4+ (and similar reasoning models) do not support. +# The Anthropic API returns 400 Bad Request if any of these appear in the request payload. +CLAUDE_OPUS_4_UNSUPPORTED_SAMPLING_PARAMS: frozenset[str] = frozenset( + {"temperature", "top_k", "top_p"} +) + def is_anthropic_model_name(model_name: str) -> bool: """Return True if ``model_name`` looks like an Anthropic Claude-family model.""" lower = model_name.lower() return any(kw in lower for kw in ANTHROPIC_MODEL_NAME_KEYWORDS) + + +def is_claude_opus_4_or_above(model_name: str) -> bool: + """Return True for Claude models confirmed to reject sampling parameters. + + Tested against the Anthropic API: only ``claude-opus-4-7`` rejects + ``temperature``, ``top_k``, and ``top_p`` with ``400 Bad Request``. + ``claude-opus-4-5`` and ``claude-opus-4-6`` accept sampling parameters. + + Extend the pattern here as additional models are confirmed to restrict them. + """ + return bool(re.search(r"claude-opus-4-7", model_name, re.IGNORECASE)) diff --git a/tests/cassettes.db b/tests/cassettes.db index bbd58c8..7d8e4e7 100644 Binary files a/tests/cassettes.db and b/tests/cassettes.db differ diff --git a/tests/langchain/clients/bedrock/conftest.py b/tests/langchain/clients/bedrock/conftest.py index 7091394..20a0272 100644 --- a/tests/langchain/clients/bedrock/conftest.py +++ b/tests/langchain/clients/bedrock/conftest.py @@ -46,8 +46,22 @@ }, ] +CLAUDE_ANTHROPIC_BEDROCK_CONFIGS_NO_THINKING = [ + c + for c in CLAUDE_BEDROCK_CONFIGS + if c["model_class"] is UiPathChatAnthropicBedrock + and "thinking" not in c.get("model_kwargs", {}) +] + COMPLETIONS_MODELS_WITH_CONFIGS = { "anthropic.claude-haiku-4-5-20251001-v1:0": CLAUDE_BEDROCK_CONFIGS, + # claude-opus-4-7: sampling params not supported — stripped by the fix. + # UiPathChatAnthropicBedrock only; thinking cassettes not yet recorded. + "anthropic.claude-opus-4-7": CLAUDE_ANTHROPIC_BEDROCK_CONFIGS_NO_THINKING, + # claude-opus-4-5 and claude-opus-4-6: sampling params accepted by the API. + # UiPathChatAnthropicBedrock only; thinking cassettes not yet recorded. + "anthropic.claude-opus-4-5-20251101-v1:0": CLAUDE_ANTHROPIC_BEDROCK_CONFIGS_NO_THINKING, + "anthropic.claude-opus-4-6-v1": CLAUDE_ANTHROPIC_BEDROCK_CONFIGS_NO_THINKING, } diff --git a/tests/langchain/clients/bedrock/test_unit.py b/tests/langchain/clients/bedrock/test_unit.py index f7086fa..159e279 100644 --- a/tests/langchain/clients/bedrock/test_unit.py +++ b/tests/langchain/clients/bedrock/test_unit.py @@ -1,10 +1,12 @@ """LangChain unit tests for Bedrock provider clients.""" from typing import Any +from unittest.mock import MagicMock, patch import pytest from langchain_core.embeddings import Embeddings from langchain_core.language_models.chat_models import BaseChatModel +from langchain_core.messages import HumanMessage from langchain_tests.unit_tests import ChatModelUnitTests, EmbeddingsUnitTests from uipath_langchain_client.clients.bedrock.chat_models import ( UiPathChatAnthropicBedrock, @@ -14,6 +16,10 @@ from uipath_langchain_client.clients.bedrock.embeddings import UiPathBedrockEmbeddings from uipath.llm_client.settings import UiPathBaseSettings +from uipath.llm_client.utils.model_family import ( + CLAUDE_OPUS_4_UNSUPPORTED_SAMPLING_PARAMS, + is_claude_opus_4_or_above, +) BEDROCK_CHAT_CLASSES = [UiPathChatAnthropicBedrock, UiPathChatBedrock, UiPathChatBedrockConverse] BEDROCK_EMBEDDINGS_CLASSES = [UiPathBedrockEmbeddings] @@ -40,6 +46,72 @@ def chat_model_params(self) -> dict[str, Any]: def test_serdes(self, *args: Any, **kwargs: Any) -> None: ... +class TestSamplingParamStripping: + """UiPathBaseChatModel must strip sampling params for models whose modelDetails + declare ``shouldSkipTemperature: True`` — both at instantiation (ctor kwargs) and + at invocation time (``invoke(..., temperature=...)``).""" + + @pytest.fixture() + def opus4_client(self, client_settings: UiPathBaseSettings) -> UiPathChatAnthropicBedrock: + # model_details bypasses the discovery network call at init. + return UiPathChatAnthropicBedrock( + model="anthropic.claude-opus-4-7", + settings=client_settings, + model_details={"shouldSkipTemperature": True}, + temperature=0.7, + top_k=40, + top_p=0.9, + ) + + def test_is_claude_opus_4_or_above(self) -> None: + # Name-based fallback for models not found in discovery. + assert is_claude_opus_4_or_above("anthropic.claude-opus-4-7") + assert is_claude_opus_4_or_above("claude-opus-4-7-20250514") + assert not is_claude_opus_4_or_above("anthropic.claude-opus-4-5-20251101-v1:0") + assert not is_claude_opus_4_or_above("anthropic.claude-opus-4-6-v1") + assert not is_claude_opus_4_or_above("anthropic.claude-3-5-sonnet-20240620-v1:0") + assert not is_claude_opus_4_or_above("anthropic.claude-haiku-4-5-20251001-v1:0") + + def test_instance_fields_nulled_at_init(self, opus4_client: UiPathChatAnthropicBedrock) -> None: + # Model validator on UiPathBaseChatModel nulled the sampling fields and + # discarded them from __pydantic_fields_set__ so downstream payload builders + # treat them as unset. + for param in CLAUDE_OPUS_4_UNSUPPORTED_SAMPLING_PARAMS: + if param in type(opus4_client).model_fields: + assert getattr(opus4_client, param) is None + assert param not in opus4_client.model_fields_set + + def test_invocation_kwargs_stripped(self, opus4_client: UiPathChatAnthropicBedrock) -> None: + # llm.invoke("...", temperature=0.5) — the kwargs must not reach the SDK call. + with patch.object(opus4_client, "_client") as mock_client: + mock_client.messages.create.return_value = MagicMock( + content=[MagicMock(type="text", text="hi")], + stop_reason="end_turn", + usage=MagicMock(input_tokens=10, output_tokens=5), + model="anthropic.claude-opus-4-7", + id="msg_123", + ) + opus4_client.invoke( + [HumanMessage(content="hi")], + temperature=0.5, + top_k=10, + top_p=0.5, + ) + call_kwargs = mock_client.messages.create.call_args.kwargs + for param in CLAUDE_OPUS_4_UNSUPPORTED_SAMPLING_PARAMS: + assert param not in call_kwargs, f"{param} must be stripped" + + def test_supported_model_keeps_params(self, client_settings: UiPathBaseSettings) -> None: + haiku = UiPathChatAnthropicBedrock( + model="anthropic.claude-haiku-4-5-20251001-v1:0", + settings=client_settings, + model_details={"shouldSkipTemperature": False}, + temperature=0.5, + ) + assert haiku.temperature == 0.5 + assert "temperature" in haiku.model_fields_set + + class TestBedrockEmbeddings(EmbeddingsUnitTests): @pytest.fixture(autouse=True, params=BEDROCK_EMBEDDINGS_CLASSES) def setup_models(self, request: pytest.FixtureRequest, client_settings: UiPathBaseSettings): diff --git a/tests/langchain/features/test_factory_function.py b/tests/langchain/features/test_factory_function.py index 3677157..ac2318d 100644 --- a/tests/langchain/features/test_factory_function.py +++ b/tests/langchain/features/test_factory_function.py @@ -128,3 +128,37 @@ def test_openai_chat_respects_discovered_byom_chat_completions( }, ) assert captured["api_flavor"] == ApiFlavor.CHAT_COMPLETIONS + + def test_factory_forwards_model_details_from_discovery(self, monkeypatch: pytest.MonkeyPatch): + """modelDetails from discovery should be piped into the client ctor as model_details, + so resolved_model_details does not need to re-fetch.""" + captured = self._captured_kwargs( + monkeypatch, + { + "modelName": "gpt-4o", + "vendor": "OpenAi", + "apiFlavor": None, + "modelFamily": "OpenAi", + "modelDetails": {"shouldSkipTemperature": True, "maxOutputTokens": 4096}, + }, + ) + assert captured["model_details"] == { + "shouldSkipTemperature": True, + "maxOutputTokens": 4096, + } + + def test_factory_user_model_details_wins_over_discovery(self, monkeypatch: pytest.MonkeyPatch): + """Explicit model_details from the caller should not be overwritten by discovery.""" + user_details = {"shouldSkipTemperature": False} + captured = self._captured_kwargs( + monkeypatch, + { + "modelName": "gpt-4o", + "vendor": "OpenAi", + "apiFlavor": None, + "modelFamily": "OpenAi", + "modelDetails": {"shouldSkipTemperature": True}, + }, + model_details=user_details, + ) + assert captured["model_details"] is user_details