diff --git a/CHANGELOG.md b/CHANGELOG.md index b9f0c0f8..8a9286f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to `uipath_llm_client` (core package) will be documented in this file. +## [1.17.2] - 2026-08-13 + +### Fixed +- Resource binding overwrites are now applied to `byo_connection_id`, in both the `get_model_info` discovery lookup and the `X-UiPath-LlmGateway-ByoIsConnectionId` routing header. + ## [1.17.1] - 2026-07-17 ### Added diff --git a/src/uipath/llm_client/__version__.py b/src/uipath/llm_client/__version__.py index ca1169f8..f0399198 100644 --- a/src/uipath/llm_client/__version__.py +++ b/src/uipath/llm_client/__version__.py @@ -1,3 +1,3 @@ __title__ = "UiPath LLM Client" __description__ = "A Python client for interacting with UiPath's LLM services." -__version__ = "1.17.1" +__version__ = "1.17.2" diff --git a/src/uipath/llm_client/settings/base.py b/src/uipath/llm_client/settings/base.py index 661182fd..b0614113 100644 --- a/src/uipath/llm_client/settings/base.py +++ b/src/uipath/llm_client/settings/base.py @@ -12,6 +12,7 @@ from httpx import Auth from pydantic import BaseModel, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict +from uipath.platform.common import resource_override from uipath.llm_client.settings.constants import ApiFlavor, ApiType, RoutingMode, VendorType from uipath.llm_client.utils.exceptions import ModelNotFoundError @@ -183,6 +184,7 @@ def validate_byo_model(self, model_info: dict[str, Any]) -> None: """Validate that the model is a BYOM model.""" return + @resource_override(resource_type="connection", resource_identifier="byo_connection_id") def get_model_info( self, model_name: str, diff --git a/src/uipath/llm_client/utils/headers.py b/src/uipath/llm_client/utils/headers.py index 82256389..92cedf40 100644 --- a/src/uipath/llm_client/utils/headers.py +++ b/src/uipath/llm_client/utils/headers.py @@ -2,6 +2,7 @@ from collections.abc import Mapping, Sequence from httpx import Headers, Request +from uipath.platform.common import resource_override from uipath.llm_client.settings.base import UiPathAPIConfig from uipath.llm_client.settings.constants import ApiType, RoutingMode @@ -101,6 +102,7 @@ def extract_matching_headers( return result +@resource_override(resource_type="connection", resource_identifier="byo_connection_id") def build_routing_headers( *, model_name: str | None = None, diff --git a/tests/conftest.py b/tests/conftest.py index 9e34bb12..ffcdcd7f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,32 @@ import pytest +from uipath.platform.common import ResourceOverwriteParser +from uipath.platform.common._bindings import _resource_overwrites from uipath.llm_client.settings import UiPathBaseSettings from uipath.llm_client.settings.llmgateway import LLMGatewaySettings +@pytest.fixture +def activate_connection_overwrite(): + """Activate a ``connection.`` resource overwrite for one test.""" + tokens = [] + + def _activate(design_time_id: str, connection_id: str, folder_key: str = "test-folder-key"): + key = f"connection.{design_time_id}" + overwrites = { + key: ResourceOverwriteParser.parse( + key=key, + value={"connectionId": connection_id, "folderKey": folder_key}, + ) + } + tokens.append(_resource_overwrites.set(overwrites)) + + yield _activate + + for token in reversed(tokens): + _resource_overwrites.reset(token) + + @pytest.fixture(autouse=True, scope="session") def setup_env(): from dotenv import find_dotenv, load_dotenv diff --git a/tests/core/features/settings/test_llmgateway.py b/tests/core/features/settings/test_llmgateway.py index c9e02638..454980f8 100644 --- a/tests/core/features/settings/test_llmgateway.py +++ b/tests/core/features/settings/test_llmgateway.py @@ -619,3 +619,33 @@ def test_skips_validate_byo_model_for_uipath_owned(self, llmgw_env_vars): with patch.object(settings, "validate_byo_model") as mock_validate: settings.get_model_info("claude-3-opus") mock_validate.assert_not_called() + + def test_remaps_byo_connection_id_from_resource_overwrite( + self, llmgw_env_vars, activate_connection_overwrite + ): + """A design-time connection id resolves to the one bound in the target folder.""" + activate_connection_overwrite("design-time-conn", "conn-123") + settings = self._make_settings(llmgw_env_vars) + + info = settings.get_model_info("gpt-4o", byo_connection_id="design-time-conn") + + assert info["byomDetails"]["integrationServiceConnectionId"] == "conn-123" + + def test_keeps_byo_connection_id_when_no_overwrite_matches( + self, llmgw_env_vars, activate_connection_overwrite + ): + activate_connection_overwrite("other-conn", "conn-123") + settings = self._make_settings(llmgw_env_vars) + + with pytest.raises(ValueError, match="not found"): + settings.get_model_info("gpt-4o", byo_connection_id="design-time-conn") + + def test_ignores_overwrites_for_uipath_owned_models( + self, llmgw_env_vars, activate_connection_overwrite + ): + activate_connection_overwrite("design-time-conn", "conn-123") + settings = self._make_settings(llmgw_env_vars) + + info = settings.get_model_info("gpt-4o") + + assert info["modelSubscriptionType"] == "UiPathOwned" diff --git a/tests/core/features/test_headers.py b/tests/core/features/test_headers.py index 74eaedc6..8de5a2c3 100644 --- a/tests/core/features/test_headers.py +++ b/tests/core/features/test_headers.py @@ -134,3 +134,30 @@ def test_normalized_without_model_name(self): ) headers = build_routing_headers(model_name=None, api_config=api_config) assert "X-UiPath-LlmGateway-NormalizedApi-ModelName" not in headers + + def test_byo_connection_id_header(self): + from uipath.llm_client.utils.headers import build_routing_headers + + headers = build_routing_headers(byo_connection_id="conn-123") + assert headers["X-UiPath-LlmGateway-ByoIsConnectionId"] == "conn-123" + + def test_byo_connection_id_header_uses_resource_overwrite(self, activate_connection_overwrite): + """The bound connection id must reach the gateway, not the design-time one.""" + from uipath.llm_client.utils.headers import build_routing_headers + + activate_connection_overwrite("design-time-conn", "conn-123") + + headers = build_routing_headers(byo_connection_id="design-time-conn") + + assert headers["X-UiPath-LlmGateway-ByoIsConnectionId"] == "conn-123" + + def test_byo_connection_id_header_kept_when_no_overwrite_matches( + self, activate_connection_overwrite + ): + from uipath.llm_client.utils.headers import build_routing_headers + + activate_connection_overwrite("other-conn", "conn-123") + + headers = build_routing_headers(byo_connection_id="design-time-conn") + + assert headers["X-UiPath-LlmGateway-ByoIsConnectionId"] == "design-time-conn" diff --git a/tests/langchain/features/test_connection_overwrites.py b/tests/langchain/features/test_connection_overwrites.py new file mode 100644 index 00000000..3014e380 --- /dev/null +++ b/tests/langchain/features/test_connection_overwrites.py @@ -0,0 +1,106 @@ +"""Tests that a job's connection binding reaches BYOM models. + +A solution deployment binds the agent to a connection in the target folder, while the +design-time ``byo_connection_id`` travels with the package. Callers that build their own +chat models never see the binding, so the ``connection.`` overwrite has to be applied +where the id is consumed: the discovery lookup and the routing header. +""" + +import os +from unittest.mock import MagicMock, patch + +from httpx import Client +from uipath_langchain_client.clients.normalized.chat_models import UiPathChat +from uipath_langchain_client.factory import get_chat_model + +from uipath.llm_client.settings import LLMGatewaySettings, RoutingMode +from uipath.llm_client.settings.base import UiPathBaseSettings +from uipath.llm_client.settings.utils import SingletonMeta + +LLMGW_ENV = { + "LLMGW_URL": "https://cloud.uipath.com", + "LLMGW_SEMANTIC_ORG_ID": "test-org-id", + "LLMGW_SEMANTIC_TENANT_ID": "test-tenant-id", + "LLMGW_REQUESTING_PRODUCT": "test-product", + "LLMGW_REQUESTING_FEATURE": "test-feature", + "LLMGW_ACCESS_TOKEN": "test-access-token", +} + +DESIGN_TIME_CONNECTION_ID = "design-time-conn" +BOUND_CONNECTION_ID = "bound-conn" +BYO_MODEL_DETAILS = {"contextWindowSize": 128000} +BYO_CONNECTION_HEADER = "x-uipath-llmgateway-byoisconnectionid" + +MODELS = [ + {"modelName": "gpt-4o", "vendor": "OpenAi", "modelSubscriptionType": "UiPathOwned"}, + { + "modelName": "gpt-4o", + "vendor": "OpenAi", + "modelSubscriptionType": "BYO", + "byomDetails": {"integrationServiceConnectionId": BOUND_CONNECTION_ID}, + "modelDetails": BYO_MODEL_DETAILS, + }, +] + + +class TestConnectionOverwriteReachesByoModels: + def setup_method(self): + SingletonMeta._instances.clear() + UiPathBaseSettings._discovery_cache.clear() + + def teardown_method(self): + SingletonMeta._instances.clear() + UiPathBaseSettings._discovery_cache.clear() + + def _settings(self): + """Build settings with the discovery cache pre-populated from ``MODELS``.""" + settings = LLMGatewaySettings() + response = MagicMock() + response.is_error = False + response.json.return_value = MODELS + with patch.object(Client, "get", return_value=response): + settings.get_available_models() + return settings + + def test_direct_instantiation_routes_to_bound_connection(self, activate_connection_overwrite): + activate_connection_overwrite(DESIGN_TIME_CONNECTION_ID, BOUND_CONNECTION_ID) + + with patch.dict(os.environ, LLMGW_ENV, clear=True): + chat = UiPathChat( + model="gpt-4o", + settings=self._settings(), + byo_connection_id=DESIGN_TIME_CONNECTION_ID, + ) + headers = chat.uipath_sync_client.headers + + assert chat.model_details == BYO_MODEL_DETAILS + assert headers[BYO_CONNECTION_HEADER] == BOUND_CONNECTION_ID + + def test_factory_routes_to_bound_connection(self, activate_connection_overwrite): + activate_connection_overwrite(DESIGN_TIME_CONNECTION_ID, BOUND_CONNECTION_ID) + + with patch.dict(os.environ, LLMGW_ENV, clear=True): + chat = get_chat_model( + model_name="gpt-4o", + client_settings=self._settings(), + byo_connection_id=DESIGN_TIME_CONNECTION_ID, + routing_mode=RoutingMode.NORMALIZED, + ) + headers = chat.uipath_sync_client.headers + + assert chat.model_details == BYO_MODEL_DETAILS + assert headers[BYO_CONNECTION_HEADER] == BOUND_CONNECTION_ID + + def test_unbound_connection_id_is_left_alone(self, activate_connection_overwrite): + activate_connection_overwrite("some-other-conn", BOUND_CONNECTION_ID) + + with patch.dict(os.environ, LLMGW_ENV, clear=True): + chat = UiPathChat( + model="gpt-4o", + settings=self._settings(), + byo_connection_id=BOUND_CONNECTION_ID, + ) + headers = chat.uipath_sync_client.headers + + assert chat.model_details == BYO_MODEL_DETAILS + assert headers[BYO_CONNECTION_HEADER] == BOUND_CONNECTION_ID