diff --git a/packages/uipath_langchain_client/CHANGELOG.md b/packages/uipath_langchain_client/CHANGELOG.md index 991543b6..8bcf7039 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.15.1] - 2026-06-30 + +### Fixed +- BYO AWS Bedrock backing-model resolution now reads the authoritative `byomDetails.customerModel` field that LLM Gateway discovery exposes for "add your own" (BYOMAdded) connections (LLM-3900). This is the upstream model the customer configured; `get_chat_model` uses it as `base_model_id`/`provider` for capability detection while the connection alias is still sent as `model_id` for gateway routing. Replaces the earlier speculative `byomDetails.baseModel`/`backingModel` lookups, which guessed at a field that did not yet exist. When `customerModel` is absent (UiPath-owned models) it falls back to the discovery `modelName`, which is itself the real model id. + ## [1.15.0] - 2026-06-25 ### Added 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 b33880d1..2e858bcd 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.15.0" +__version__ = "1.15.1" 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 5df1cf2d..e9685849 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 @@ -4,6 +4,10 @@ from pydantic import Field, model_validator from uipath_langchain_client.base_client import UiPathBaseChatModel +from uipath_langchain_client.clients.bedrock.model_resolution import ( + apply_backing_model_detection_hints, + provider_from_model, +) from uipath_langchain_client.settings import ( ApiFlavor, ApiType, @@ -48,6 +52,47 @@ def _patched_format_data_content_block(block: dict) -> dict: ) from e +def _setup_model_id_and_bedrock_hints(values: Any) -> Any: + if not isinstance(values, dict): + return values + + updated = values + if "model_id" not in updated: + model = updated.get("model") or updated.get("model_name") + if model: + updated = {**updated, "model_id": model} + + try: + base_model_id = updated.get("base_model_id") or updated.get("base_model") + if base_model_id and "provider" not in updated: + provider = provider_from_model(base_model_id) + if provider: + updated = {**updated, "provider": provider} + + if "base_model_id" in updated or "base_model" in updated: + return updated + + settings = updated.get("client_settings") or updated.get("settings") + model = updated.get("model") or updated.get("model_name") or updated.get("model_id") + if settings is None or model is None: + return updated + + model_info = settings.get_model_info( + model, + byo_connection_id=updated.get("byo_connection_id"), + ) + hints: dict[str, Any] = {} + apply_backing_model_detection_hints(hints, model_info) + if not hints: + return updated + + updated = {**updated, **{k: v for k, v in hints.items() if k not in updated}} + except Exception: + return updated + + return updated + + class UiPathChatBedrockConverse(UiPathBaseChatModel, ChatBedrockConverse): # type: ignore[override] api_config: UiPathAPIConfig = UiPathAPIConfig( api_type=ApiType.COMPLETIONS, @@ -65,11 +110,7 @@ class UiPathChatBedrockConverse(UiPathBaseChatModel, ChatBedrockConverse): # ty @model_validator(mode="before") @classmethod def setup_model_id(cls, values: Any) -> Any: - if isinstance(values, dict) and "model_id" not in values: - model = values.get("model") or values.get("model_name") - if model: - values = {**values, "model_id": model} - return values + return _setup_model_id_and_bedrock_hints(values) @model_validator(mode="after") def setup_uipath_client(self) -> Self: @@ -94,11 +135,7 @@ class UiPathChatBedrock(UiPathBaseChatModel, ChatBedrock): # type: ignore[overr @model_validator(mode="before") @classmethod def setup_model_id(cls, values: Any) -> Any: - if isinstance(values, dict) and "model_id" not in values: - model = values.get("model") or values.get("model_name") - if model: - values = {**values, "model_id": model} - return values + return _setup_model_id_and_bedrock_hints(values) @model_validator(mode="after") def setup_uipath_client(self) -> Self: diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/clients/bedrock/model_resolution.py b/packages/uipath_langchain_client/src/uipath_langchain_client/clients/bedrock/model_resolution.py new file mode 100644 index 00000000..bf138aec --- /dev/null +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/clients/bedrock/model_resolution.py @@ -0,0 +1,94 @@ +"""Bedrock backing-model and provider resolution. + +Pure string/dict helpers that derive the real upstream Bedrock model id (and its +provider) from an LLM Gateway discovery record. Kept free of ``botocore`` / +``langchain_aws`` so they can be imported and tested without the ``bedrock`` extra, +and so both the factory and the Bedrock clients depend on this module rather than on +each other. +""" + +from typing import Any + +_BEDROCK_REGION_PREFIXES = ( + "eu.", + "us.", + "us-gov.", + "apac.", + "sa.", + "amer.", + "global.", + "jp.", + "au.", +) + + +def _normalize_model_id(model_id: Any) -> str | None: + if not isinstance(model_id, str): + return None + + model_id = model_id.strip() + if not model_id: + return None + + foundation_model_marker = "/foundation-model/" + if foundation_model_marker in model_id: + model_id = model_id.rsplit(foundation_model_marker, 1)[1] + + model_id_without_region = model_id + for region_prefix in _BEDROCK_REGION_PREFIXES: + if model_id_without_region.startswith(region_prefix): + model_id_without_region = model_id_without_region[len(region_prefix) :] + break + + if "." not in model_id_without_region: + return None + + provider = model_id_without_region.split(".", 1)[0] + if not provider or " " in provider or "/" in provider: + return None + + return model_id + + +def _resolve_backing_model_id(model_info: dict[str, Any]) -> str | None: + byo_details = model_info.get("byomDetails") or {} + + candidates = ( + # Authoritative source: the upstream model the customer configured for a + # BYO ("add your own") connection. LLM Gateway discovery exposes it as + # byomDetails.customerModel (LLM-3900). For UiPath-owned models it is + # absent and we fall back to the discovery model name (the alias the + # factory was instantiated with, which is itself the real model id). + byo_details.get("customerModel"), + model_info.get("modelName"), + ) + for candidate in candidates: + backing_model_id = _normalize_model_id(candidate) + if backing_model_id: + return backing_model_id + return None + + +def provider_from_model(base_model_id: str | None) -> str | None: + base_model_id = _normalize_model_id(base_model_id) + if not base_model_id: + return None + model_id_without_region = base_model_id + for region_prefix in _BEDROCK_REGION_PREFIXES: + if model_id_without_region.startswith(region_prefix): + model_id_without_region = model_id_without_region[len(region_prefix) :] + break + provider = model_id_without_region.split(".", 1)[0] + return provider + + +def apply_backing_model_detection_hints( + model_kwargs: dict[str, Any], model_info: dict[str, Any] +) -> None: + # These fields are read by both Invoke and Converse clients to determine vendor + backing_model_id = _resolve_backing_model_id(model_info) + if backing_model_id: + model_kwargs.setdefault("base_model_id", backing_model_id) + provider = provider_from_model(backing_model_id) + if provider: + model_kwargs.setdefault("provider", provider) 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 39b438b7..aac3fb99 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/factory.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/factory.py @@ -205,20 +205,26 @@ def get_chat_model( **model_kwargs, ) - if api_flavor == ApiFlavor.INVOKE: - if model_family == ModelFamily.ANTHROPIC_CLAUDE: - from uipath_langchain_client.clients.bedrock.chat_models import ( - UiPathChatAnthropicBedrock, - ) - - return UiPathChatAnthropicBedrock( - model=model_name, - settings=client_settings, - byo_connection_id=byo_connection_id, - model_details=model_details, - **model_kwargs, - ) + if api_flavor == ApiFlavor.INVOKE and model_family == ModelFamily.ANTHROPIC_CLAUDE: + from uipath_langchain_client.clients.bedrock.chat_models import ( + UiPathChatAnthropicBedrock, + ) + + return UiPathChatAnthropicBedrock( + model=model_name, + settings=client_settings, + byo_connection_id=byo_connection_id, + model_details=model_details, + **model_kwargs, + ) + from uipath_langchain_client.clients.bedrock.model_resolution import ( + apply_backing_model_detection_hints, + ) + + apply_backing_model_detection_hints(model_kwargs, model_info) + + if api_flavor == ApiFlavor.INVOKE: from uipath_langchain_client.clients.bedrock.chat_models import ( UiPathChatBedrock, ) diff --git a/tests/langchain/clients/bedrock/test_model_resolution.py b/tests/langchain/clients/bedrock/test_model_resolution.py new file mode 100644 index 00000000..881414c1 --- /dev/null +++ b/tests/langchain/clients/bedrock/test_model_resolution.py @@ -0,0 +1,83 @@ +"""Unit tests for Bedrock backing-model resolution. + +Exercised through the module's public surface (``apply_backing_model_detection_hints`` +and ``provider_from_model``); the internal ``_resolve_backing_model_id`` / +``_normalize_model_id`` helpers are covered transitively by the hints they produce. +""" + +import pytest +from uipath_langchain_client.clients.bedrock.model_resolution import ( + apply_backing_model_detection_hints, + provider_from_model, +) + + +class TestApplyBackingModelDetectionHints: + def test_byo_uses_customer_model(self): + kwargs: dict = {} + apply_backing_model_detection_hints( + kwargs, + { + "modelName": "AWS - Bedrock", + "byomDetails": { + "customerModel": "anthropic.claude-sonnet-4-5-20250929-v1:0", + "integrationServiceConnectionId": "conn-x", + }, + }, + ) + assert kwargs["base_model_id"] == "anthropic.claude-sonnet-4-5-20250929-v1:0" + assert kwargs["provider"] == "anthropic" + + def test_uipath_owned_uses_model_name(self): + kwargs: dict = {} + apply_backing_model_detection_hints( + kwargs, + { + "modelName": "anthropic.claude-3-5-sonnet-20240620-v1:0", + "byomDetails": None, + }, + ) + assert kwargs["base_model_id"] == "anthropic.claude-3-5-sonnet-20240620-v1:0" + assert kwargs["provider"] == "anthropic" + + def test_falls_back_to_model_name(self): + kwargs: dict = {} + apply_backing_model_detection_hints(kwargs, {"modelName": "amazon.nova-pro-v1:0"}) + assert kwargs["base_model_id"] == "amazon.nova-pro-v1:0" + assert kwargs["provider"] == "amazon" + + def test_byo_alias_without_customer_model_sets_no_hints(self): + kwargs: dict = {} + apply_backing_model_detection_hints( + kwargs, + { + "modelName": "VeryCustomBedddrockAlias", + "byomDetails": {"integrationServiceConnectionId": "conn-x"}, + }, + ) + assert "base_model_id" not in kwargs + assert "provider" not in kwargs + + def test_does_not_override_caller_supplied_hints(self): + kwargs = {"base_model_id": "amazon.nova-pro-v1:0", "provider": "amazon"} + apply_backing_model_detection_hints( + kwargs, + {"byomDetails": {"customerModel": "anthropic.claude-sonnet-4-5-20250929-v1:0"}}, + ) + assert kwargs["base_model_id"] == "amazon.nova-pro-v1:0" + assert kwargs["provider"] == "amazon" + + +@pytest.mark.parametrize( + "model_id,expected", + [ + ("anthropic.claude-sonnet-4-5-20250929-v1:0", "anthropic"), + ("global.anthropic.claude-sonnet-4-6", "anthropic"), + ("amazon.nova-pro-v1:0", "amazon"), + ("AWS - Bedrock", None), + ("my-claude-sonnet-4-5", None), + (None, None), + ], +) +def test_provider_from_model(model_id, expected): + assert provider_from_model(model_id) == expected diff --git a/tests/langchain/features/test_factory_function.py b/tests/langchain/features/test_factory_function.py index 11479548..58b18b45 100644 --- a/tests/langchain/features/test_factory_function.py +++ b/tests/langchain/features/test_factory_function.py @@ -1,9 +1,16 @@ from unittest.mock import MagicMock import pytest +from uipath_langchain_client.clients.bedrock.chat_models import ( + UiPathChatBedrock, + UiPathChatBedrockConverse, +) from uipath_langchain_client.clients.normalized.chat_models import UiPathChat from uipath_langchain_client.clients.normalized.embeddings import UiPathEmbeddings -from uipath_langchain_client.factory import get_chat_model, get_embedding_model +from uipath_langchain_client.factory import ( + get_chat_model, + get_embedding_model, +) from tests.langchain.conftest import COMPLETION_MODEL_NAMES, EMBEDDING_MODEL_NAMES from uipath.llm_client.settings import ApiFlavor, UiPathBaseSettings, VendorType @@ -379,3 +386,93 @@ def test_anthropic_messages_routes_to_uipath_chat_anthropic( ) assert captured["vendor_type"] == VendorType.AWSBEDROCK assert captured["api_flavor"] == ApiFlavor.ANTHROPIC_MESSAGES + + +_BYO_BEDROCK_CONVERSE = { + "modelName": "AWS - Bedrock", + "vendor": "AwsBedrock", + "modelFamily": None, + "apiFlavor": None, + "modelSubscriptionType": "BYOMAdded", + "byomDetails": { + "customerModel": "anthropic.claude-sonnet-4-5-20250929-v1:0", + "integrationServiceConnectionId": "conn-x", + }, +} +_BYO_BEDROCK_INVOKE = {**_BYO_BEDROCK_CONVERSE, "apiFlavor": "AwsBedrockInvoke"} + + +class TestBedrockFactoryBaseModel: + @pytest.fixture() + def client_settings(self): + import os + from unittest.mock import patch + + from uipath.llm_client.settings.llmgateway import LLMGatewaySettings + + env = { + "LLMGW_URL": "http://test-bedrock", + "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() + + @pytest.fixture(autouse=True) + def _clear_discovery_cache(self): + UiPathBaseSettings._discovery_cache.clear() + yield + UiPathBaseSettings._discovery_cache.clear() + + def _seed(self, client_settings, model_info): + key = client_settings._discovery_cache_key() + client_settings._discovery_cache[key] = [model_info] + + def test_converse_byo_alias_gets_backing_base_model(self, client_settings): + self._seed(client_settings, _BYO_BEDROCK_CONVERSE) + model = get_chat_model( + "AWS - Bedrock", + byo_connection_id="conn-x", + client_settings=client_settings, + ) + assert isinstance(model, UiPathChatBedrockConverse) + assert model.model_id == "AWS - Bedrock" + assert model.base_model_id == "anthropic.claude-sonnet-4-5-20250929-v1:0" + assert model.supports_tool_choice_values == ("auto", "any", "tool") + + from langchain_core.tools import tool + + @tool + def ping() -> str: + """ping.""" + return "pong" + + model.bind_tools([ping], tool_choice="any") + + def test_converse_direct_construction_resolves_backing_model_from_discovery( + self, client_settings + ): + self._seed(client_settings, _BYO_BEDROCK_CONVERSE) + model = UiPathChatBedrockConverse( + model="AWS - Bedrock", + settings=client_settings, + byo_connection_id="conn-x", + ) + assert model.base_model_id == "anthropic.claude-sonnet-4-5-20250929-v1:0" + assert model.supports_tool_choice_values == ("auto", "any", "tool") + + def test_invoke_byo_alias_gets_provider(self, client_settings): + self._seed(client_settings, _BYO_BEDROCK_INVOKE) + model = get_chat_model( + "AWS - Bedrock", + byo_connection_id="conn-x", + client_settings=client_settings, + ) + assert isinstance(model, UiPathChatBedrock) + assert model.model_id == "AWS - Bedrock" + assert model.base_model_id == "anthropic.claude-sonnet-4-5-20250929-v1:0" + assert model.provider == "anthropic" + assert model._get_provider() == "anthropic"