Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/uipath_langchain_client/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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.17.1"
__version__ = "1.18.0"
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from collections.abc import Container, Mapping
from functools import cached_property
from typing import Any, Self

Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
63 changes: 63 additions & 0 deletions tests/langchain/clients/bedrock/test_model_settings_mapping.py
Original file line number Diff line number Diff line change
@@ -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"}}
126 changes: 126 additions & 0 deletions tests/langchain/features/test_factory_function.py
Original file line number Diff line number Diff line change
Expand Up @@ -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