diff --git a/packages/uipath_langchain_client/CHANGELOG.md b/packages/uipath_langchain_client/CHANGELOG.md index 0e31b82..e683a45 100644 --- a/packages/uipath_langchain_client/CHANGELOG.md +++ b/packages/uipath_langchain_client/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to `uipath_langchain_client` will be documented in this file. +## [1.18.0] - 2026-07-31 + +### Added +- `model_settings` field on `UiPathBaseChatModel` and a matching `model_settings` param on `get_chat_model`. Provider-native settings from agent.json's `settings.modelSettings` are applied verbatim: a key matching a native field is set directly on the model, anything else routes to `model_kwargs`, and keys listed in `disabled_params` are skipped. No per-provider mapping — discovery is the source of truth for the shape. + ## [1.17.1] - 2026-07-17 ### 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 618fd90..66e880d 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.17.1" +__version__ = "1.18.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 8d064a6..3b27d6e 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 @@ -423,6 +423,45 @@ class UiPathBaseChatModel(UiPathBaseLLMClient, BaseChatModel): so that headers are captured transparently. """ + model_settings: Mapping[str, Any] | None = Field( + default=None, + description="Provider-native model settings from agent.json " + "(settings.modelSettings), applied verbatim — no per-provider mapping.", + ) + + @model_validator(mode="after") + def apply_model_settings(self) -> Self: + self._apply_model_settings() + return self + + def _apply_model_settings(self) -> None: + """Apply each ``model_settings`` key onto the model. + + Set directly when it's a native field, else routed to ``model_kwargs``; + keys in ``disabled_params`` are skipped. + """ + if not self.model_settings: + return + fields = type(self).model_fields + disabled = self.disabled_params or {} + extra: dict[str, Any] = {} + for key, value in self.model_settings.items(): + if key in disabled: + continue + if key in fields: + setattr(self, key, value) + else: + extra[key] = value + if extra: + if "model_kwargs" in fields: + self.model_kwargs = {**(self.model_kwargs or {}), **extra} + else: + (self.logger or logging.getLogger(__name__)).debug( + "Dropping unsupported model settings %s for %s", + list(extra), + type(self).__name__, + ) + def _generate( self, messages: list[BaseMessage], 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 e0b45bb..dc65f89 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 @@ -1,3 +1,4 @@ +from collections.abc import Container, Mapping from functools import cached_property from typing import Any, Self @@ -56,6 +57,33 @@ def _setup_model_id(values: Any) -> Any: return values +_CONVERSE_PASSTHROUGH_KEYS = frozenset({"output_config"}) + + +def _partition_converse_settings( + model_settings: Mapping[str, Any], + native_fields: Container[str], + disabled_params: Container[str], +) -> tuple[dict[str, Any], dict[str, Any]]: + """Split model settings into (direct, passthrough) for ChatBedrockConverse. + + Converse only takes provider params via additional_model_request_fields, not + model_kwargs. Keys that aren't native fields go to passthrough; so does output_config + (a field, but it must be nested anyway). Real fields are set directly, disabled + dropped. Returns (setattr these, merge these into additional_model_request_fields). + """ + direct: dict[str, Any] = {} + passthrough: dict[str, Any] = {} + for key, value in model_settings.items(): + if key in disabled_params: + continue + if key in native_fields and key not in _CONVERSE_PASSTHROUGH_KEYS: + direct[key] = value + else: + passthrough[key] = value + return direct, passthrough + + class UiPathChatBedrockConverse(UiPathBaseChatModel, ChatBedrockConverse): # type: ignore[override] api_config: UiPathAPIConfig = UiPathAPIConfig( api_type=ApiType.COMPLETIONS, @@ -80,6 +108,22 @@ def setup_uipath_client(self) -> Self: self.client = WrappedBotoClient(self.uipath_sync_client) return self + def _apply_model_settings(self) -> None: + if not self.model_settings: + return + direct, passthrough = _partition_converse_settings( + self.model_settings, + native_fields=type(self).model_fields, + disabled_params=self.disabled_params or {}, + ) + for key, value in direct.items(): + setattr(self, key, value) + if passthrough: + self.additional_model_request_fields = { + **(self.additional_model_request_fields or {}), + **passthrough, + } + class UiPathChatBedrock(UiPathBaseChatModel, ChatBedrock): # type: ignore[override] api_config: UiPathAPIConfig = UiPathAPIConfig( 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 aac3fb9..73286bf 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/factory.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/factory.py @@ -20,6 +20,7 @@ >>> embeddings = get_embedding_model(model_name="text-embedding-3-large", client_settings=settings) """ +from collections.abc import Mapping from typing import Any from uipath_langchain_client.base_client import ( @@ -48,6 +49,7 @@ def get_chat_model( api_flavor: ApiFlavor | str | None = None, custom_class: type[UiPathBaseChatModel] | None = None, agenthub_config: str | None = None, + model_settings: Mapping[str, Any] | None = None, **model_kwargs: Any, ) -> UiPathBaseChatModel: """Factory function to create the appropriate LangChain chat model for a given model name. @@ -93,6 +95,9 @@ def get_chat_model( model_family = model_info.get("modelFamily", None) model_details = model_info.get("modelDetails") or {} + if model_settings is not None: + model_kwargs["model_settings"] = model_settings + if custom_class is not None: return custom_class( model=model_name, diff --git a/tests/langchain/clients/bedrock/test_model_settings_mapping.py b/tests/langchain/clients/bedrock/test_model_settings_mapping.py new file mode 100644 index 0000000..f954027 --- /dev/null +++ b/tests/langchain/clients/bedrock/test_model_settings_mapping.py @@ -0,0 +1,63 @@ +"""Unit tests for Converse model_settings -> request-shape mapping. + +Bedrock Converse has no top-level ``thinking`` field, so a transport-agnostic +``thinking`` from agent.json must be routed into ``additional_model_request_fields`` +rather than ``model_kwargs``. These test the pure partition helper against the real +class field set (static — no client construction / network). +""" + +from uipath_langchain_client.clients.bedrock.chat_models import ( + UiPathChatBedrockConverse, + _partition_converse_settings, +) + +FIELDS = UiPathChatBedrockConverse.model_fields + + +def test_field_assumptions() -> None: + # Documents the invariants the mapping relies on. + assert "thinking" not in FIELDS + assert "additional_model_request_fields" in FIELDS + + +def test_reasoning_bundle_goes_to_passthrough() -> None: + direct, passthrough = _partition_converse_settings( + {"thinking": {"type": "adaptive"}, "output_config": {"effort": "high"}}, + FIELDS, + {}, + ) + # Both must nest in additional_model_request_fields — output_config as a + # top-level Converse field makes the provider 400. + assert passthrough == { + "thinking": {"type": "adaptive"}, + "output_config": {"effort": "high"}, + } + assert direct == {} + + +def test_output_config_is_a_field_but_still_nested() -> None: + # Guards the regression: output_config IS a field, yet must go to passthrough. + assert "output_config" in FIELDS + _, passthrough = _partition_converse_settings( + {"output_config": {"effort": "high"}}, FIELDS, {} + ) + assert passthrough == {"output_config": {"effort": "high"}} + + +def test_explicit_additional_fields_stay_direct() -> None: + # Backward compatibility: an explicit wrapper is a real field -> set directly. + settings = {"additional_model_request_fields": {"thinking": {"type": "enabled"}}} + direct, passthrough = _partition_converse_settings(settings, FIELDS, {}) + assert direct == settings + assert passthrough == {} + + +def test_disabled_key_is_dropped() -> None: + direct, passthrough = _partition_converse_settings( + {"temperature": 0.5, "thinking": {"type": "adaptive"}}, + FIELDS, + {"temperature": True}, + ) + assert "temperature" not in direct + assert "temperature" not in passthrough + assert passthrough == {"thinking": {"type": "adaptive"}} diff --git a/tests/langchain/features/test_factory_function.py b/tests/langchain/features/test_factory_function.py index 566e28d..030726b 100644 --- a/tests/langchain/features/test_factory_function.py +++ b/tests/langchain/features/test_factory_function.py @@ -477,3 +477,129 @@ def test_invoke_byo_alias_gets_provider(self, client_settings): assert model.base_model_id == "anthropic.claude-sonnet-4-5-20250929-v1:0" assert model.provider == "anthropic" assert model._get_provider() == "anthropic" + + +class TestModelSettingsForwarding: + """get_chat_model forwards model_settings into the chosen client's constructor.""" + + def test_factory_forwards_model_settings_to_constructor(self, monkeypatch: pytest.MonkeyPatch): + settings = MagicMock() + settings.get_model_info.return_value = { + "modelName": "gpt-4o", + "vendor": "OpenAi", + "apiFlavor": "responses", + "modelFamily": "OpenAi", + } + captured: dict = {} + + class _StubModel: + def __init__(self, **kwargs): + captured.update(kwargs) + + monkeypatch.setattr( + "uipath_langchain_client.clients.openai.chat_models.UiPathAzureChatOpenAI", + _StubModel, + ) + get_chat_model( + model_name="gpt-4o", + client_settings=settings, + model_settings={"reasoning_effort": "high", "temperature": 1.0}, + ) + assert captured["model_settings"] == { + "reasoning_effort": "high", + "temperature": 1.0, + } + + +class TestModelSettingsApplied: + """model_settings is applied during real construction (via the model_validator). + + Native provider keys land as real fields (no per-provider mapping); unknown keys + route to model_kwargs; keys named in disabled_params are skipped. + """ + + @pytest.fixture() + def settings(self) -> UiPathBaseSettings: + import os + from unittest.mock import patch + + from uipath.llm_client.settings.llmgateway import LLMGatewaySettings + + env = { + "LLMGW_URL": "http://test", + "LLMGW_SEMANTIC_ORG_ID": "org", + "LLMGW_SEMANTIC_TENANT_ID": "tenant", + "LLMGW_REQUESTING_PRODUCT": "test", + "LLMGW_REQUESTING_FEATURE": "test", + "LLMGW_ACCESS_TOKEN": "dummy-token", + } + with patch.dict(os.environ, env, clear=True): + return LLMGatewaySettings() + + def test_openai_native_key_set_unknown_key_to_model_kwargs(self, settings: UiPathBaseSettings): + from uipath_langchain_client.clients.openai.chat_models import UiPathChatOpenAI + + model = UiPathChatOpenAI( + model="some-openai-model", + settings=settings, + model_details={}, + model_settings={"reasoning_effort": "high", "made_up_key": 1}, + ) + assert model.reasoning_effort == "high" + assert model.model_kwargs == {"made_up_key": 1} + + def test_anthropic_native_keys_set_verbatim(self, settings: UiPathBaseSettings): + from uipath_langchain_client.clients.anthropic.chat_models import ( + UiPathChatAnthropic, + ) + + model = UiPathChatAnthropic( + model="anthropic.claude-sonnet-4-6", + settings=settings, + model_details={}, + model_settings={ + "thinking": {"type": "adaptive"}, + "output_config": {"effort": "high"}, + }, + ) + assert model.thinking == {"type": "adaptive"} + assert model.output_config == {"effort": "high"} + + def test_bedrock_additional_model_request_fields_set_verbatim( + self, settings: UiPathBaseSettings + ): + UiPathBaseSettings._discovery_cache.clear() + settings._discovery_cache[settings._discovery_cache_key()] = [ + { + "modelName": "AWS - Bedrock", + "vendor": "Bedrock", + "apiFlavor": "AwsBedrockConverse", + "modelFamily": "Anthropic", + "modelDetails": {"customerModelName": "anthropic.claude-sonnet-4-5-20250929-v1:0"}, + } + ] + amrf = {"thinking": {"type": "enabled", "budget_tokens": 4096}} + try: + model = UiPathChatBedrockConverse( + model="AWS - Bedrock", + settings=settings, + byo_connection_id="conn-x", + base_model="anthropic.claude-sonnet-4-5-20250929-v1:0", + provider="anthropic", + model_settings={"additional_model_request_fields": amrf}, + ) + finally: + UiPathBaseSettings._discovery_cache.clear() + assert model.additional_model_request_fields == amrf + + def test_disabled_key_is_skipped(self, settings: UiPathBaseSettings): + from uipath_langchain_client.clients.openai.chat_models import UiPathChatOpenAI + + model = UiPathChatOpenAI( + model="some-openai-model", + settings=settings, + model_details={}, + disabled_params={"temperature": None}, + model_settings={"temperature": 0.2}, + ) + assert model.temperature is None