diff --git a/packages/uipath_langchain_client/CHANGELOG.md b/packages/uipath_langchain_client/CHANGELOG.md index 03c8382..dfbddb0 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-08-13 + +### 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 (by name or alias, e.g. `timeout` -> `request_timeout`) is coerced against the field's declared type and set, anything else routes to `model_kwargs`, and keys listed in `disabled_params` are skipped. A value the field can't coerce fails at construction instead of surfacing as a provider 400. Coercion deliberately avoids pydantic assignment validation: re-running the validator chain lets LangChain's `build_extra` sweep cached non-field entries into `model_kwargs`. No per-provider mapping — discovery is the source of truth for the shape. + ## [1.17.3] - 2026-08-06 ### Fixed 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 b8851fc..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.3" +__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..3ca587d 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,15 @@ 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, + TypeAdapter, + model_validator, +) +from pydantic.errors import PydanticSchemaGenerationError from uipath.llm_client.httpx_client import ( UiPathHttpxAsyncClient, @@ -423,6 +431,84 @@ 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 _assign_validated(self, field_name: str, value: Any) -> None: + """Coerce ``value`` against the field's declared type, then set it. + + Values arrive as untyped JSON (agent.json / discovery data), so a plain + ``setattr`` would store e.g. ``"8192"`` on an ``int`` field. Coercion + runs through a ``TypeAdapter`` for the field annotation rather than + pydantic assignment validation: ``validate_assignment`` re-runs the + model's validator chain, and LangChain's ``build_extra`` (a before + validator) then sweeps cached non-field entries from ``__dict__`` into + ``model_kwargs``. Fields whose annotations can't produce a schema + (arbitrary types) are set as-is. + """ + annotation = type(self).model_fields[field_name].annotation + if annotation is not None: + try: + adapter = TypeAdapter(annotation) + except PydanticSchemaGenerationError: + pass + else: + value = adapter.validate_python(value) + setattr(self, field_name, value) + + def _resolve_settings_field(self, key: str) -> str | None: + """The field name a settings key targets: the name itself, or the name + whose alias/validation alias matches (e.g. ``timeout`` -> ``request_timeout``).""" + fields = type(self).model_fields + if key in fields: + return key + for name, field in fields.items(): + if key == field.alias or key == field.validation_alias: + return name + if isinstance(field.validation_alias, AliasChoices) and any( + key == choice for choice in field.validation_alias.choices + ): + return name + return None + + def _apply_model_settings(self) -> None: + """Apply each ``model_settings`` key onto the model. + + Keys naming a field (by name or alias) are coerced against the field's + type and set, the rest are 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 + field_name = self._resolve_settings_field(key) + if field_name is not None: + self._assign_validated(field_name, 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..5270c6c 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(): + self._assign_validated(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..adc9ea4 --- /dev/null +++ b/tests/langchain/clients/bedrock/test_model_settings_mapping.py @@ -0,0 +1,61 @@ +"""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..56ac6e7 100644 --- a/tests/langchain/features/test_factory_function.py +++ b/tests/langchain/features/test_factory_function.py @@ -477,3 +477,190 @@ 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 + + def test_string_value_coerced_to_field_type(self, settings: UiPathBaseSettings): + """Values arrive as untyped JSON (UI forms, gateway data); a field key must go + through pydantic assignment validation, not raw setattr, so '8192' becomes 8192 + instead of a string serialized into the request body.""" + from uipath_langchain_client.clients.openai.chat_models import UiPathChatOpenAI + + model = UiPathChatOpenAI( + model="some-openai-model", + settings=settings, + model_details={}, + model_settings={"max_tokens": "8192"}, + ) + assert model.max_tokens == 8192 + assert isinstance(model.max_tokens, int) + + def test_alias_key_sets_field_not_model_kwargs(self, settings: UiPathBaseSettings): + """A key matching only a field alias ('timeout' -> request_timeout) must set the + field instead of leaking into model_kwargs as an unknown completion parameter.""" + from uipath_langchain_client.clients.openai.chat_models import UiPathChatOpenAI + + model = UiPathChatOpenAI( + model="some-openai-model", + settings=settings, + model_details={}, + model_settings={"timeout": 42.0}, + ) + assert model.request_timeout == 42.0 + assert "timeout" not in (model.model_kwargs or {}) + + def test_multiple_field_keys_do_not_pollute_model_kwargs(self, settings: UiPathBaseSettings): + """Regression: applying settings via pydantic assignment validation re-ran + LangChain's ``build_extra`` validator, which swept the cached uipath httpx + clients out of ``__dict__`` into ``model_kwargs`` — from where they would + be sent as completion parameters.""" + from uipath_langchain_client.clients.openai.chat_models import UiPathChatOpenAI + + model = UiPathChatOpenAI( + model="some-openai-model", + settings=settings, + model_details={}, + model_settings={"max_tokens": "8192", "timeout": 30}, + ) + assert model.max_tokens == 8192 + assert model.request_timeout == 30 + assert "uipath_sync_client" not in (model.model_kwargs or {}) + assert "uipath_async_client" not in (model.model_kwargs or {}) + + def test_invalid_field_value_raises_at_construction(self, settings: UiPathBaseSettings): + """A value pydantic can't coerce fails fast with a clear error instead of being + stored raw and rejected by the provider at request time.""" + from pydantic import ValidationError + from uipath_langchain_client.clients.openai.chat_models import UiPathChatOpenAI + + with pytest.raises(ValidationError): + UiPathChatOpenAI( + model="some-openai-model", + settings=settings, + model_details={}, + model_settings={"max_tokens": "not-a-number"}, + )