From e7d2fe5c34b6a61cea6e7a821551de379a6d4252 Mon Sep 17 00:00:00 2001 From: Cosmin Maria Date: Fri, 17 Apr 2026 10:01:56 +0300 Subject: [PATCH 1/2] Feat: discovery cache, get_model_info, and ModelFamily constants (#62) Add caching for model discovery and centralize model lookup logic in UiPathBaseSettings so all consumers (LangChain factory, LiteLLM client) share the same cached discovery and filtering. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 12 ++ packages/uipath_langchain_client/CHANGELOG.md | 6 + .../uipath_langchain_client/pyproject.toml | 2 +- .../uipath_langchain_client/__version__.py | 2 +- .../src/uipath_langchain_client/factory.py | 67 +------ .../src/uipath_langchain_client/settings.py | 2 + src/uipath/llm_client/__version__.py | 2 +- .../llm_client/clients/litellm/client.py | 33 +--- src/uipath/llm_client/settings/__init__.py | 2 + src/uipath/llm_client/settings/base.py | 114 +++++++++++- src/uipath/llm_client/settings/constants.py | 6 + .../settings/llmgateway/settings.py | 6 +- .../llm_client/settings/platform/settings.py | 9 +- tests/core/clients/litellm/test_unit.py | 19 +- .../core/features/settings/test_llmgateway.py | 170 ++++++++++++++++++ 15 files changed, 346 insertions(+), 106 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fc2405..1fb1d74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to `uipath_llm_client` (core package) will be documented in this file. +## [1.9.0] - 2026-04-17 + +### Added +- `ModelFamily` StrEnum constants (`OPENAI`, `GOOGLE_GEMINI`, `ANTHROPIC_CLAUDE`) for model family matching +- `get_model_info()` on `UiPathBaseSettings` — centralized model lookup with filtering by name, vendor, and BYO connection ID +- Discovery cache on `get_available_models()` keyed by settings properties, with `refresh` parameter to bypass + +### Changed +- `get_available_models()` is now a concrete cached method on the base class; subclasses implement `_fetch_available_models()` instead +- `validate_byo_model()` is now a default no-op on the base class (only LLMGateway overrides it) and is called automatically inside `get_model_info()` +- LiteLLM client uses `get_model_info()` instead of duplicating model discovery logic + ## [1.8.3] - 2026-04-16 ### Added diff --git a/packages/uipath_langchain_client/CHANGELOG.md b/packages/uipath_langchain_client/CHANGELOG.md index 9a34ed2..7f03f5c 100644 --- a/packages/uipath_langchain_client/CHANGELOG.md +++ b/packages/uipath_langchain_client/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to `uipath_langchain_client` will be documented in this file. +## [1.9.0] - 2026-04-17 + +### Changed +- Factory functions use `ModelFamily` constants and `get_model_info()` from core instead of inline discovery logic +- Azure vs non-Azure OpenAI routing now uses `modelFamily` instead of `modelSubscriptionType` + ## [1.8.3] - 2026-04-16 ### Added diff --git a/packages/uipath_langchain_client/pyproject.toml b/packages/uipath_langchain_client/pyproject.toml index 942c8e5..fe516b2 100644 --- a/packages/uipath_langchain_client/pyproject.toml +++ b/packages/uipath_langchain_client/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" requires-python = ">=3.11" dependencies = [ "langchain>=1.2.15", - "uipath-llm-client>=1.8.3", + "uipath-llm-client>=1.9.0", ] [project.optional-dependencies] 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 514ff65..0876433 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.8.3" +__version__ = "1.9.0" 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 f4b8c05..d73b43e 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/factory.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/factory.py @@ -30,6 +30,7 @@ API_FLAVOR_TO_VENDOR_TYPE, BYOM_TO_ROUTING_FLAVOR, ApiFlavor, + ModelFamily, RoutingMode, UiPathBaseSettings, VendorType, @@ -37,49 +38,6 @@ ) -def _get_model_info( - model_name: str, - *, - client_settings: UiPathBaseSettings, - byo_connection_id: str | None = None, - vendor_type: VendorType | str | None = None, -) -> dict[str, Any]: - available_models = client_settings.get_available_models() - - matching_models = [m for m in available_models if m["modelName"].lower() == model_name.lower()] - - if vendor_type is not None: - matching_models = [ - m for m in matching_models if m.get("vendor", "").lower() == str(vendor_type).lower() - ] - - if byo_connection_id: - matching_models = [ - m - for m in matching_models - if (byom_details := m.get("byomDetails")) - and byom_details.get("integrationServiceConnectionId", "").lower() - == byo_connection_id.lower() - ] - - if not byo_connection_id and len(matching_models) > 1: - matching_models = [ - m - for m in matching_models - if ( - (m.get("modelSubscriptionType", "") == "UiPathOwned") - or (m.get("byomDetails") is None) - ) - ] - - if not matching_models: - raise ValueError( - f"Model {model_name} not found. Available models are: {[m['modelName'] for m in available_models]}" - ) - - return matching_models[0] - - def get_chat_model( model_name: str, *, @@ -120,18 +78,12 @@ def get_chat_model( ValueError: If the model is not found in available models or vendor is not supported. """ client_settings = client_settings or get_default_client_settings() - model_info = _get_model_info( + model_info = client_settings.get_model_info( model_name, - client_settings=client_settings, byo_connection_id=byo_connection_id, vendor_type=vendor_type, ) model_family = model_info.get("modelFamily", None) - if model_family is not None: - model_family = model_family.lower() - is_uipath_owned = model_info.get("modelSubscriptionType") == "UiPathOwned" - if not is_uipath_owned: - client_settings.validate_byo_model(model_info) if custom_class is not None: return custom_class( @@ -171,7 +123,7 @@ def get_chat_model( match discovered_vendor_type: case VendorType.OPENAI: - if is_uipath_owned: + if model_family == ModelFamily.OPENAI: from uipath_langchain_client.clients.openai.chat_models import ( UiPathAzureChatOpenAI, ) @@ -196,7 +148,7 @@ def get_chat_model( **model_kwargs, ) case VendorType.VERTEXAI: - if model_family == "anthropicclaude": + if model_family == ModelFamily.ANTHROPIC_CLAUDE: from uipath_langchain_client.clients.anthropic.chat_models import ( UiPathChatAnthropic, ) @@ -220,7 +172,7 @@ def get_chat_model( **model_kwargs, ) case VendorType.AWSBEDROCK: - if model_family == "anthropicclaude" and api_flavor is None: + if model_family == ModelFamily.ANTHROPIC_CLAUDE and api_flavor is None: from uipath_langchain_client.clients.bedrock.chat_models import ( UiPathChatAnthropicBedrock, ) @@ -300,15 +252,12 @@ def get_embedding_model( >>> vectors = embeddings.embed_documents(["Hello world"]) """ client_settings = client_settings or get_default_client_settings() - model_info = _get_model_info( + model_info = client_settings.get_model_info( model_name, - client_settings=client_settings, byo_connection_id=byo_connection_id, vendor_type=vendor_type, ) - is_uipath_owned = model_info.get("modelSubscriptionType") == "UiPathOwned" - if not is_uipath_owned: - client_settings.validate_byo_model(model_info) + model_family = model_info.get("modelFamily", None) if custom_class is not None: return custom_class( @@ -342,7 +291,7 @@ def get_embedding_model( discovered_vendor_type = discovered_vendor_type.lower() match discovered_vendor_type: case VendorType.OPENAI: - if is_uipath_owned: + if model_family == ModelFamily.OPENAI: from uipath_langchain_client.clients.openai.embeddings import ( UiPathAzureOpenAIEmbeddings, ) diff --git a/packages/uipath_langchain_client/src/uipath_langchain_client/settings.py b/packages/uipath_langchain_client/src/uipath_langchain_client/settings.py index 83d739a..20a1514 100644 --- a/packages/uipath_langchain_client/src/uipath_langchain_client/settings.py +++ b/packages/uipath_langchain_client/src/uipath_langchain_client/settings.py @@ -28,6 +28,7 @@ ApiFlavor, ApiType, ByomApiFlavor, + ModelFamily, RoutingMode, VendorType, ) @@ -42,6 +43,7 @@ "RoutingMode", "ApiFlavor", "ByomApiFlavor", + "ModelFamily", "VendorType", "API_FLAVOR_TO_VENDOR_TYPE", "BYOM_TO_ROUTING_FLAVOR", diff --git a/src/uipath/llm_client/__version__.py b/src/uipath/llm_client/__version__.py index ea22869..b7d80f3 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.8.3" +__version__ = "1.9.0" diff --git a/src/uipath/llm_client/clients/litellm/client.py b/src/uipath/llm_client/clients/litellm/client.py index 93d55b5..0f9ca01 100644 --- a/src/uipath/llm_client/clients/litellm/client.py +++ b/src/uipath/llm_client/clients/litellm/client.py @@ -31,6 +31,7 @@ ApiFlavor, ApiType, ByomApiFlavor, + ModelFamily, RoutingMode, VendorType, ) @@ -99,8 +100,6 @@ ByomApiFlavor.AWS_BEDROCK_CONVERSE: "bedrock", } -_ANTHROPIC_FAMILY = "anthropicclaude" - def _drop_nones(**kwargs: Any) -> dict[str, Any]: return {k: v for k, v in kwargs.items() if v is not None} @@ -189,27 +188,9 @@ def _discover_and_build_api_config( User-supplied ``vendor_type`` filters models during discovery. User-supplied ``api_flavor`` overrides the discovered value. """ - available_models = self._client_settings.get_available_models() - matching = [ - m for m in available_models if m["modelName"].lower() == self._model_name.lower() - ] - - if vendor_type is not None: - matching = [ - m for m in matching if m.get("vendor", "").lower() == str(vendor_type).lower() - ] - - if not matching: - raise ValueError( - f"Model '{self._model_name}' not found. " - f"Available: {[m['modelName'] for m in available_models]}" - ) - model_info = matching[0] - - model_family: str | None = None - raw_family = model_info.get("modelFamily", None) - if raw_family is not None: - model_family = raw_family.lower() + model_info = self._client_settings.get_model_info(self._model_name, vendor_type=vendor_type) + + model_family = model_info.get("modelFamily", None) discovered_vendor = model_info.get("vendor", None) discovered_flavor = model_info.get("apiFlavor", None) @@ -240,7 +221,7 @@ def _discover_and_build_api_config( if ( resolved_flavor is None and resolved_vendor == "awsbedrock" - and model_family == _ANTHROPIC_FAMILY + and model_family == ModelFamily.ANTHROPIC_CLAUDE ): resolved_flavor = ApiFlavor.INVOKE @@ -248,7 +229,7 @@ def _discover_and_build_api_config( if ( resolved_flavor is None and resolved_vendor == "vertexai" - and model_family == _ANTHROPIC_FAMILY + and model_family == ModelFamily.ANTHROPIC_CLAUDE ): resolved_flavor = ApiFlavor.ANTHROPIC_CLAUDE @@ -266,7 +247,7 @@ def _resolve_llm_provider(self) -> str: The model_family disambiguates cases where the same vendor hosts models from different providers (e.g. Claude on Vertex AI or Bedrock). """ - is_claude = self._model_family == _ANTHROPIC_FAMILY + is_claude = self._model_family == ModelFamily.ANTHROPIC_CLAUDE vendor = str(self._api_config.vendor_type or "openai") # Claude on Vertex AI → vertex_ai (uses VertexAIAnthropicConfig) diff --git a/src/uipath/llm_client/settings/__init__.py b/src/uipath/llm_client/settings/__init__.py index 13660dd..5fff6d2 100644 --- a/src/uipath/llm_client/settings/__init__.py +++ b/src/uipath/llm_client/settings/__init__.py @@ -34,6 +34,7 @@ ApiFlavor, ApiType, ByomApiFlavor, + ModelFamily, RoutingMode, VendorType, ) @@ -103,4 +104,5 @@ def get_default_client_settings( "VendorType", "ApiFlavor", "ByomApiFlavor", + "ModelFamily", ] diff --git a/src/uipath/llm_client/settings/base.py b/src/uipath/llm_client/settings/base.py index c2c974c..2a2b8bf 100644 --- a/src/uipath/llm_client/settings/base.py +++ b/src/uipath/llm_client/settings/base.py @@ -7,7 +7,7 @@ from abc import ABC, abstractmethod from collections.abc import Mapping -from typing import Any, Self +from typing import Any, ClassVar, Self from httpx import Auth from pydantic import BaseModel, model_validator @@ -80,6 +80,8 @@ class UiPathBaseSettings(BaseSettings, ABC): extra="allow", ) + _discovery_cache: ClassVar[dict[tuple[str, ...], list[dict[str, Any]]]] = {} + @abstractmethod def build_base_url( self, @@ -132,10 +134,18 @@ def build_auth_pipeline( ... @abstractmethod - def get_available_models( - self, - ) -> list[dict[str, Any]]: - """Get the list of available models from the backend. + def _discovery_cache_key(self) -> tuple[str, ...]: + """Return a tuple that uniquely identifies the discovery endpoint. + + Used as the cache key for ``get_available_models``. Subclasses should + include all settings properties that affect which models are returned + (e.g. base URL, org/tenant IDs, requesting product). + """ + ... + + @abstractmethod + def _fetch_available_models(self) -> list[dict[str, Any]]: + """Fetch the list of available models from the backend. Subclasses must implement this method to query the backend's model discovery endpoint. @@ -145,7 +155,97 @@ def get_available_models( """ ... - @abstractmethod + def get_available_models(self, *, refresh: bool = False) -> list[dict[str, Any]]: + """Get the list of available models, with caching. + + Returns cached results if available. Pass ``refresh=True`` to + bypass the cache and fetch fresh results. + + Args: + refresh: If True, skip the cache and fetch from the backend. + + Returns: + A list of dictionaries containing model information. + """ + key = self._discovery_cache_key() + + if not refresh: + cached = self._discovery_cache.get(key) + if cached is not None: + return cached + + models = self._fetch_available_models() + self._discovery_cache[key] = models + return models + def validate_byo_model(self, model_info: dict[str, Any]) -> None: """Validate that the model is a BYOM model.""" - ... + return + + def get_model_info( + self, + model_name: str, + *, + byo_connection_id: str | None = None, + vendor_type: VendorType | str | None = None, + ) -> dict[str, Any]: + """Look up a model by name from the available models list. + + Filters the cached available models by name, and optionally by + vendor type and BYO connection ID. When multiple matches exist and + no ``byo_connection_id`` is given, UiPath-owned models are preferred. + + Args: + model_name: The model name to search for (case-insensitive). + byo_connection_id: Filter by BYO integration connection ID. + vendor_type: Filter by vendor (e.g. ``VendorType.OPENAI``). + + Returns: + The first matching model info dictionary. + + Raises: + ValueError: If no matching model is found. + """ + available_models = self.get_available_models() + + matching_models = [ + m for m in available_models if m["modelName"].lower() == model_name.lower() + ] + + if vendor_type is not None: + matching_models = [ + m + for m in matching_models + if m.get("vendor", "").lower() == str(vendor_type).lower() + ] + + if byo_connection_id: + matching_models = [ + m + for m in matching_models + if (byom_details := m.get("byomDetails")) + and byom_details.get("integrationServiceConnectionId", "").lower() + == byo_connection_id.lower() + ] + + if not byo_connection_id and len(matching_models) > 1: + matching_models = [ + m + for m in matching_models + if ( + (m.get("modelSubscriptionType", "") == "UiPathOwned") + or (m.get("byomDetails") is None) + ) + ] + + if not matching_models: + raise ValueError( + f"Model {model_name} not found. " + f"Available models are: {[m['modelName'] for m in available_models]}" + ) + + model_info = matching_models[0] + is_uipath_owned = model_info.get("modelSubscriptionType") == "UiPathOwned" + if not is_uipath_owned: + self.validate_byo_model(model_info) + return model_info diff --git a/src/uipath/llm_client/settings/constants.py b/src/uipath/llm_client/settings/constants.py index 5d6e2c6..6f073b7 100644 --- a/src/uipath/llm_client/settings/constants.py +++ b/src/uipath/llm_client/settings/constants.py @@ -19,6 +19,12 @@ class VendorType(StrEnum): ANTHROPIC = "anthropic" +class ModelFamily(StrEnum): + OPENAI = "OpenAi" + GOOGLE_GEMINI = "GoogleGemini" + ANTHROPIC_CLAUDE = "AnthropicClaude" + + class ApiFlavor(StrEnum): CHAT_COMPLETIONS = "chat-completions" RESPONSES = "responses" diff --git a/src/uipath/llm_client/settings/llmgateway/settings.py b/src/uipath/llm_client/settings/llmgateway/settings.py index 8c0b491..4bf85ec 100644 --- a/src/uipath/llm_client/settings/llmgateway/settings.py +++ b/src/uipath/llm_client/settings/llmgateway/settings.py @@ -114,7 +114,11 @@ def build_auth_headers( return headers @override - def get_available_models(self) -> list[dict[str, Any]]: + def _discovery_cache_key(self) -> tuple[str, ...]: + return (self.base_url, self.org_id, self.tenant_id, self.requesting_product) + + @override + def _fetch_available_models(self) -> list[dict[str, Any]]: discovery_url = f"{self.base_url}/{self.org_id}/{self.tenant_id}/{LLMGatewayEndpoints.DISCOVERY_ENDPOINT.value}" with Client( auth=self.build_auth_pipeline(), diff --git a/src/uipath/llm_client/settings/platform/settings.py b/src/uipath/llm_client/settings/platform/settings.py index a20abdc..e6bd1f2 100644 --- a/src/uipath/llm_client/settings/platform/settings.py +++ b/src/uipath/llm_client/settings/platform/settings.py @@ -163,13 +163,12 @@ def build_auth_headers( return headers @override - def get_available_models(self) -> list[dict[str, Any]]: + def _discovery_cache_key(self) -> tuple[str, ...]: + return (self.base_url or "", self.organization_id or "", self.tenant_id or "") + @override + def _fetch_available_models(self) -> list[dict[str, Any]]: models = UiPath().agenthub.get_available_llm_models( headers=dict(self.build_auth_headers()), ) return [model.model_dump(by_alias=True) for model in models] - - @override - def validate_byo_model(self, model_info: dict[str, Any]) -> None: - return diff --git a/tests/core/clients/litellm/test_unit.py b/tests/core/clients/litellm/test_unit.py index 3acbf9c..de8616b 100644 --- a/tests/core/clients/litellm/test_unit.py +++ b/tests/core/clients/litellm/test_unit.py @@ -9,13 +9,12 @@ import pytest from uipath.llm_client.clients.litellm.client import ( - _ANTHROPIC_FAMILY, _FLAVOR_TO_LITELLM, _VENDOR_TO_LITELLM, UiPathLiteLLM, _drop_nones, ) -from uipath.llm_client.settings.constants import ApiFlavor, RoutingMode, VendorType +from uipath.llm_client.settings.constants import ApiFlavor, ModelFamily, RoutingMode, VendorType MODULE = "uipath.llm_client.clients.litellm.client" @@ -59,9 +58,19 @@ } +def _find_model(models: list[dict], model_name: str, **kwargs: object) -> dict: + matching = [m for m in models if m["modelName"].lower() == model_name.lower()] + if not matching: + raise ValueError(f"Model {model_name} not found.") + return matching[0] + + def _mock_settings(models: list[dict]) -> MagicMock: settings = MagicMock() settings.get_available_models.return_value = models + settings.get_model_info.side_effect = lambda model_name, **kwargs: _find_model( + models, model_name, **kwargs + ) settings.build_base_url.return_value = "https://example.com/api" settings.build_auth_headers.return_value = {} settings.build_auth_pipeline.return_value = None @@ -238,15 +247,15 @@ def _make_client(self, model_data: dict) -> UiPathLiteLLM: def test_claude_family_detected(self): client = self._make_client(_BEDROCK_CLAUDE_MODEL) - assert client._model_family == _ANTHROPIC_FAMILY + assert client._model_family == ModelFamily.ANTHROPIC_CLAUDE def test_openai_family_not_anthropic(self): client = self._make_client(_OPENAI_MODEL) - assert client._model_family != _ANTHROPIC_FAMILY + assert client._model_family != ModelFamily.ANTHROPIC_CLAUDE def test_gemini_family_not_anthropic(self): client = self._make_client(_GEMINI_MODEL) - assert client._model_family != _ANTHROPIC_FAMILY + assert client._model_family != ModelFamily.ANTHROPIC_CLAUDE # ============================================================================ diff --git a/tests/core/features/settings/test_llmgateway.py b/tests/core/features/settings/test_llmgateway.py index 0cb12ab..c9e0263 100644 --- a/tests/core/features/settings/test_llmgateway.py +++ b/tests/core/features/settings/test_llmgateway.py @@ -7,12 +7,17 @@ from httpx import Client, Request, Response from uipath.llm_client.settings import LLMGatewaySettings +from uipath.llm_client.settings.base import UiPathBaseSettings from uipath.llm_client.utils.exceptions import UiPathAPIError, UiPathAuthenticationError class TestLLMGatewaySettings: """Tests for LLMGatewaySettings.""" + @pytest.fixture(autouse=True) + def _clear_discovery_cache(self): + UiPathBaseSettings._discovery_cache.clear() + def test_build_base_url_passthrough(self, llmgw_env_vars, passthrough_api_config): """Test build_base_url for passthrough mode.""" with patch.dict(os.environ, llmgw_env_vars, clear=True): @@ -449,3 +454,168 @@ def test_cache_key_includes_base_url(self, llmgw_env_vars): key = LLMGatewayS2SAuth._singleton_cache_key(settings) assert key[0] == settings.base_url + + +class TestDiscoveryCache: + """Tests for get_available_models caching behavior.""" + + @pytest.fixture(autouse=True) + def _clear_discovery_cache(self): + UiPathBaseSettings._discovery_cache.clear() + + def test_second_call_returns_cached_result(self, llmgw_env_vars): + """Second call should not hit the network.""" + with patch.dict(os.environ, llmgw_env_vars, clear=True): + settings = LLMGatewaySettings() + + mock_response = MagicMock() + mock_response.is_error = False + mock_response.json.return_value = [{"modelName": "gpt-4o", "vendor": "openai"}] + + with patch.object(Client, "get", return_value=mock_response) as mock_get: + first = settings.get_available_models() + second = settings.get_available_models() + + assert first == second + mock_get.assert_called_once() + + def test_refresh_bypasses_cache(self, llmgw_env_vars): + """refresh=True should fetch again even if cached.""" + with patch.dict(os.environ, llmgw_env_vars, clear=True): + settings = LLMGatewaySettings() + + mock_response = MagicMock() + mock_response.is_error = False + mock_response.json.return_value = [{"modelName": "gpt-4o", "vendor": "openai"}] + + with patch.object(Client, "get", return_value=mock_response) as mock_get: + settings.get_available_models() + settings.get_available_models(refresh=True) + + assert mock_get.call_count == 2 + + def test_different_settings_have_separate_caches(self, llmgw_env_vars): + """Different cache keys should not share cached results.""" + env1 = {**llmgw_env_vars, "LLMGW_REQUESTING_PRODUCT": "product-a"} + env2 = {**llmgw_env_vars, "LLMGW_REQUESTING_PRODUCT": "product-b"} + + mock_response = MagicMock() + mock_response.is_error = False + mock_response.json.return_value = [{"modelName": "gpt-4o", "vendor": "openai"}] + + with patch.object(Client, "get", return_value=mock_response) as mock_get: + with patch.dict(os.environ, env1, clear=True): + settings1 = LLMGatewaySettings() + settings1.get_available_models() + + with patch.dict(os.environ, env2, clear=True): + settings2 = LLMGatewaySettings() + settings2.get_available_models() + + assert mock_get.call_count == 2 + + def test_cache_key_includes_requesting_product(self, llmgw_env_vars): + """LLMGateway cache key should include requesting_product.""" + with patch.dict(os.environ, llmgw_env_vars, clear=True): + settings = LLMGatewaySettings() + key = settings._discovery_cache_key() + assert settings.requesting_product in key + + +class TestGetModelInfo: + """Tests for UiPathBaseSettings.get_model_info.""" + + @pytest.fixture(autouse=True) + def _clear_discovery_cache(self): + UiPathBaseSettings._discovery_cache.clear() + + _MODELS = [ + {"modelName": "gpt-4o", "vendor": "OpenAi", "modelSubscriptionType": "UiPathOwned"}, + { + "modelName": "gpt-4o", + "vendor": "OpenAi", + "modelSubscriptionType": "BYO", + "byomDetails": { + "integrationServiceConnectionId": "conn-123", + "availableOperationCodes": ["op1"], + }, + }, + { + "modelName": "claude-3-opus", + "vendor": "Anthropic", + "modelSubscriptionType": "UiPathOwned", + }, + { + "modelName": "gemini-2.0-flash", + "vendor": "VertexAi", + "modelSubscriptionType": "UiPathOwned", + }, + ] + + def _make_settings(self, llmgw_env_vars, models=None): + with patch.dict(os.environ, llmgw_env_vars, clear=True): + settings = LLMGatewaySettings() + mock_response = MagicMock() + mock_response.is_error = False + mock_response.json.return_value = models if models is not None else self._MODELS + # Pre-populate the cache so we don't need to mock Client.get on every call + with patch.object(Client, "get", return_value=mock_response): + settings.get_available_models() + return settings + + def test_finds_model_by_name(self, llmgw_env_vars): + settings = self._make_settings(llmgw_env_vars) + info = settings.get_model_info("claude-3-opus") + assert info["modelName"] == "claude-3-opus" + + def test_case_insensitive_lookup(self, llmgw_env_vars): + settings = self._make_settings(llmgw_env_vars) + info = settings.get_model_info("Claude-3-Opus") + assert info["modelName"] == "claude-3-opus" + + def test_raises_on_unknown_model(self, llmgw_env_vars): + settings = self._make_settings(llmgw_env_vars) + with pytest.raises(ValueError, match="not found"): + settings.get_model_info("nonexistent-model") + + def test_filters_by_vendor_type(self, llmgw_env_vars): + models = [ + { + "modelName": "shared-name", + "vendor": "OpenAi", + "modelSubscriptionType": "UiPathOwned", + }, + { + "modelName": "shared-name", + "vendor": "Anthropic", + "modelSubscriptionType": "UiPathOwned", + }, + ] + settings = self._make_settings(llmgw_env_vars, models=models) + info = settings.get_model_info("shared-name", vendor_type="anthropic") + assert info["vendor"] == "Anthropic" + + def test_filters_by_byo_connection_id(self, llmgw_env_vars): + settings = self._make_settings(llmgw_env_vars) + info = settings.get_model_info("gpt-4o", byo_connection_id="conn-123") + assert info["modelSubscriptionType"] == "BYO" + + def test_prefers_uipath_owned_when_no_byo_id(self, llmgw_env_vars): + """When multiple matches exist and no byo_connection_id, prefer UiPathOwned.""" + settings = self._make_settings(llmgw_env_vars) + info = settings.get_model_info("gpt-4o") + assert info["modelSubscriptionType"] == "UiPathOwned" + + def test_calls_validate_byo_model_for_non_uipath_owned(self, llmgw_env_vars): + """get_model_info should call validate_byo_model for BYO models.""" + settings = self._make_settings(llmgw_env_vars) + with patch.object(settings, "validate_byo_model") as mock_validate: + settings.get_model_info("gpt-4o", byo_connection_id="conn-123") + mock_validate.assert_called_once() + + def test_skips_validate_byo_model_for_uipath_owned(self, llmgw_env_vars): + """get_model_info should not call validate_byo_model for UiPath-owned models.""" + settings = self._make_settings(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() From 55a45712af7e837283e75fb4741c96f05d2557da Mon Sep 17 00:00:00 2001 From: Cosmin Maria Date: Fri, 17 Apr 2026 10:14:52 +0300 Subject: [PATCH 2/2] Refactor: make PlatformBaseSettings required fields non-optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit base_url, tenant_id, organization_id, and access_token are required for Platform settings — declare them as required fields (like LLMGateway) instead of Optional with a validator check. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../llm_client/settings/platform/settings.py | 31 ++++++------------- tests/core/features/settings/test_platform.py | 15 +++++---- 2 files changed, 19 insertions(+), 27 deletions(-) diff --git a/src/uipath/llm_client/settings/platform/settings.py b/src/uipath/llm_client/settings/platform/settings.py index e6bd1f2..87bc121 100644 --- a/src/uipath/llm_client/settings/platform/settings.py +++ b/src/uipath/llm_client/settings/platform/settings.py @@ -34,10 +34,10 @@ class PlatformBaseSettings(UiPathBaseSettings): """ # Authentication fields - retrieved from uipath auth as well - access_token: SecretStr | None = Field(default=None, validation_alias="UIPATH_ACCESS_TOKEN") - base_url: str | None = Field(default=None, validation_alias="UIPATH_URL") - tenant_id: str | None = Field(default=None, validation_alias="UIPATH_TENANT_ID") - organization_id: str | None = Field(default=None, validation_alias="UIPATH_ORGANIZATION_ID") + access_token: SecretStr = Field(default=..., validation_alias="UIPATH_ACCESS_TOKEN") + base_url: str = Field(default=..., validation_alias="UIPATH_URL") + tenant_id: str = Field(default=..., validation_alias="UIPATH_TENANT_ID") + organization_id: str = Field(default=..., validation_alias="UIPATH_ORGANIZATION_ID") # Credentials used for refreshing the access token client_id: str | None = Field(default=None) @@ -56,17 +56,7 @@ class PlatformBaseSettings(UiPathBaseSettings): @model_validator(mode="after") def validate_environment(self) -> Self: - """Validate environment and trigger authentication.""" - if ( - self.access_token is None - or self.base_url is None - or self.tenant_id is None - or self.organization_id is None - ): - raise ValueError( - "Base URL, access token, tenant ID, and organization ID are required. Try running `uipath auth` to authenticate." - ) - + """Validate access token expiry and extract client_id.""" access_token = self.access_token.get_secret_value() if is_token_expired(access_token): raise ValueError( @@ -145,11 +135,10 @@ def build_auth_headers( api_config: UiPathAPIConfig | None = None, ) -> Mapping[str, str]: """Build authentication and routing headers for API requests.""" - headers: dict[str, str] = {} - if self.organization_id: - headers["X-UiPath-Internal-AccountId"] = self.organization_id - if self.tenant_id: - headers["X-UiPath-Internal-TenantId"] = self.tenant_id + headers: dict[str, str] = { + "X-UiPath-Internal-AccountId": self.organization_id, + "X-UiPath-Internal-TenantId": self.tenant_id, + } if self.agenthub_config: headers["X-UiPath-AgentHub-Config"] = self.agenthub_config if self.process_key: @@ -164,7 +153,7 @@ def build_auth_headers( @override def _discovery_cache_key(self) -> tuple[str, ...]: - return (self.base_url or "", self.organization_id or "", self.tenant_id or "") + return (self.base_url, self.organization_id, self.tenant_id) @override def _fetch_available_models(self) -> list[dict[str, Any]]: diff --git a/tests/core/features/settings/test_platform.py b/tests/core/features/settings/test_platform.py index 4f7f8dd..8e2a53a 100644 --- a/tests/core/features/settings/test_platform.py +++ b/tests/core/features/settings/test_platform.py @@ -5,6 +5,7 @@ import pytest from httpx import Request, Response +from pydantic import ValidationError from uipath.llm_client.settings import PlatformSettings, UiPathAPIConfig from uipath.llm_client.settings.constants import ApiType, RoutingMode @@ -186,19 +187,21 @@ def test_build_base_url_requires_api_config(self, platform_env_vars, mock_platfo with pytest.raises(ValueError, match="api_config is required"): settings.build_base_url(model_name="gpt-4o", api_config=None) - def test_build_auth_headers_empty_when_no_optional(self, platform_env_vars, mock_platform_auth): + def test_build_auth_headers_only_required_when_no_optional( + self, platform_env_vars, mock_platform_auth + ): """Test build_auth_headers with no optional tracing fields set.""" env = {**platform_env_vars, "UIPATH_AGENTHUB_CONFIG": ""} with patch.dict(os.environ, env, clear=True): settings = PlatformSettings() - # Override to empty to test the falsy path settings.agenthub_config = "" settings.process_key = None settings.job_key = None - settings.organization_id = None - settings.tenant_id = None headers = settings.build_auth_headers() - assert headers == {} + assert headers == { + "X-UiPath-Internal-AccountId": "test-org-id", + "X-UiPath-Internal-TenantId": "test-tenant-id", + } def test_validation_requires_all_fields(self, mock_platform_auth): """Test validation fails without required fields.""" @@ -207,7 +210,7 @@ def test_validation_requires_all_fields(self, mock_platform_auth): # Missing base_url, tenant_id, organization_id } with patch.dict(os.environ, env, clear=True): - with pytest.raises(ValueError, match="Base URL, access token, tenant ID"): + with pytest.raises(ValidationError): PlatformSettings() def test_validation_fails_on_expired_token(self):