Skip to content
Closed
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 10 additions & 3 deletions packages/uipath_langchain_client/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

2 changes: 1 addition & 1 deletion packages/uipath_langchain_client/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -357,13 +393,39 @@ 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],
stop: list[str] | None = None,
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)
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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",
]
2 changes: 1 addition & 1 deletion src/uipath/llm_client/__version__.py
Original file line number Diff line number Diff line change
@@ -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"
20 changes: 20 additions & 0 deletions src/uipath/llm_client/utils/model_family.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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))
Binary file modified tests/cassettes.db
Binary file not shown.
14 changes: 14 additions & 0 deletions tests/langchain/clients/bedrock/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}


Expand Down
Loading