Skip to content
Open
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.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
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.15.0"
__version__ = "1.15.1"
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
83 changes: 83 additions & 0 deletions tests/langchain/clients/bedrock/test_model_resolution.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading