Skip to content
Closed
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

All notable changes to `uipath_llm_client` (core package) will be documented in this file.

## [1.8.4] - 2026-04-16

### Added
- `lru_cache` on `get_available_models()` — discovery endpoint results are cached per settings instance, avoiding redundant network calls when creating multiple models in a session
- `get_model_info()` shared utility for looking up a model by name from the discovery endpoint results, with optional vendor and BYOM connection ID filters

## [1.8.3] - 2026-04-16

### Added
Expand Down
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.8.4] - 2026-04-16

### Changed
- Factory functions (`get_chat_model`, `get_embedding_model`) now use the shared `get_model_info()` utility from the core package instead of an inline implementation

## [1.8.3] - 2026-04-16

### Added
Expand Down
2 changes: 1 addition & 1 deletion packages/uipath_langchain_client/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.8.4",
]

[project.optional-dependencies]
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.8.3"
__version__ = "1.8.4"
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

from typing import Any

from uipath.llm_client.utils.discovery import get_model_info
from uipath_langchain_client.base_client import (
UiPathBaseChatModel,
UiPathBaseEmbeddings,
Expand All @@ -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,
*,
Expand Down Expand Up @@ -120,11 +78,11 @@ 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 = get_model_info(
client_settings.get_available_models(),
model_name,
client_settings=client_settings,
vendor_type=str(vendor_type) if vendor_type is not None else None,
byo_connection_id=byo_connection_id,
vendor_type=vendor_type,
)
model_family = model_info.get("modelFamily", None)
if model_family is not None:
Expand Down Expand Up @@ -300,11 +258,11 @@ 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 = get_model_info(
client_settings.get_available_models(),
model_name,
client_settings=client_settings,
vendor_type=str(vendor_type) if vendor_type is not None else None,
byo_connection_id=byo_connection_id,
vendor_type=vendor_type,
)
is_uipath_owned = model_info.get("modelSubscriptionType") == "UiPathOwned"
if not is_uipath_owned:
Expand Down
3 changes: 3 additions & 0 deletions src/uipath/llm_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
PlatformSettings,
get_default_client_settings,
)
from uipath.llm_client.utils.discovery import get_model_info
from uipath.llm_client.utils.exceptions import (
UiPathAPIError,
UiPathAuthenticationError,
Expand Down Expand Up @@ -64,6 +65,8 @@
# HTTPX clients
"UiPathHttpxClient",
"UiPathHttpxAsyncClient",
# Discovery
"get_model_info",
# Retry
"RetryConfig",
# Exceptions
Expand Down
2 changes: 1 addition & 1 deletion src/uipath/llm_client/__version__.py
Original file line number Diff line number Diff line change
@@ -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.8.4"
22 changes: 6 additions & 16 deletions src/uipath/llm_client/clients/litellm/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
RoutingMode,
VendorType,
)
from uipath.llm_client.utils.discovery import get_model_info
from uipath.llm_client.utils.retry import RetryConfig

# Route OpenAI chat completions through base_llm_http_handler (accepts HTTPHandler)
Expand Down Expand Up @@ -189,22 +190,11 @@ 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_info = get_model_info(
self._client_settings.get_available_models(),
self._model_name,
vendor_type=str(vendor_type) if vendor_type is not None else None,
)

model_family: str | None = None
raw_family = model_info.get("modelFamily", None)
Expand Down
11 changes: 7 additions & 4 deletions src/uipath/llm_client/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ class UiPathBaseSettings(BaseSettings, ABC):
extra="allow",
)

# Pydantic models are not hashable by default; restore object identity
# hashing so that @lru_cache can be used on instance methods.
__hash__ = object.__hash__ # type: ignore[assignment]

@abstractmethod
def build_base_url(
self,
Expand Down Expand Up @@ -132,13 +136,12 @@ def build_auth_pipeline(
...

@abstractmethod
def get_available_models(
self,
) -> list[dict[str, Any]]:
def get_available_models(self) -> list[dict[str, Any]]:
"""Get the list of available models from the backend.

Subclasses must implement this method to query the backend's
model discovery endpoint.
model discovery endpoint. Implementations should use
``@lru_cache`` to avoid redundant network calls.

Returns:
A list of dictionaries containing model information.
Expand Down
4 changes: 3 additions & 1 deletion src/uipath/llm_client/settings/llmgateway/settings.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
from collections.abc import Mapping
from functools import lru_cache
from typing import Any, Self

from httpx import Client
Expand Down Expand Up @@ -114,7 +115,8 @@ def build_auth_headers(
return headers

@override
def get_available_models(self) -> list[dict[str, Any]]:
@lru_cache # noqa: B019
def get_available_models(self) -> list[dict[str, Any]]: # type: ignore[override]
discovery_url = f"{self.base_url}/{self.org_id}/{self.tenant_id}/{LLMGatewayEndpoints.DISCOVERY_ENDPOINT.value}"
with Client(
auth=self.build_auth_pipeline(),
Expand Down
5 changes: 3 additions & 2 deletions src/uipath/llm_client/settings/platform/settings.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Base settings for UiPath Platform (AgentHub/Orchestrator) client."""

from collections.abc import Mapping
from functools import lru_cache
from typing import Any, Self

from pydantic import Field, SecretStr, model_validator
Expand Down Expand Up @@ -163,8 +164,8 @@ def build_auth_headers(
return headers

@override
def get_available_models(self) -> list[dict[str, Any]]:

@lru_cache # noqa: B019
def get_available_models(self) -> list[dict[str, Any]]: # type: ignore[override]
models = UiPath().agenthub.get_available_llm_models(
headers=dict(self.build_auth_headers()),
)
Expand Down
67 changes: 67 additions & 0 deletions src/uipath/llm_client/utils/discovery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""Shared model discovery helpers."""

from typing import Any


def get_model_info(
available_models: list[dict[str, Any]],
model_name: str,
*,
vendor_type: str | None = None,
byo_connection_id: str | None = None,
) -> dict[str, Any]:
"""Find and return a single model entry from the discovery endpoint results.

Applies the following filters in order:

1. Match by ``modelName`` (case-insensitive).
2. If ``vendor_type`` is given, keep only models whose ``vendor`` matches.
3. If ``byo_connection_id`` is given, keep only models whose
``byomDetails.integrationServiceConnectionId`` matches.
4. When no ``byo_connection_id`` is provided and multiple candidates remain,
prefer UiPath-owned (non-BYOM) models.

Args:
available_models: Full list of model dictionaries from the discovery
endpoint (as returned by :meth:`UiPathBaseSettings.get_available_models`).
model_name: Name of the model to look up.
vendor_type: Optional vendor filter (e.g. ``"openai"``).
byo_connection_id: Optional BYOM connection ID filter.

Returns:
The first matching model dictionary.

Raises:
ValueError: If no model matches the given criteria.
"""
matching = [m for m in available_models if m["modelName"].lower() == 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 byo_connection_id:
matching = [
m
for m in matching
if (byom_details := m.get("byomDetails"))
and byom_details.get("integrationServiceConnectionId", "").lower()
== byo_connection_id.lower()
]

if not byo_connection_id and len(matching) > 1:
matching = [
m
for m in matching
if (
(m.get("modelSubscriptionType", "") == "UiPathOwned")
or (m.get("byomDetails") is None)
)
]

if not matching:
raise ValueError(
f"Model '{model_name}' not found. "
f"Available models: {[m['modelName'] for m in available_models]}"
)

return matching[0]
34 changes: 34 additions & 0 deletions tests/core/features/settings/test_llmgateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,40 @@ def test_get_available_models_raises_on_unauthorized(self, llmgw_env_vars):
assert exc_info.value.status_code == 401


class TestLLMGatewayDiscoveryCache:
"""Tests for get_available_models lru_cache."""

def test_second_call_returns_cached_result(self, llmgw_env_vars):
"""Second call should return the cached result without hitting the endpoint."""
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_cache_is_per_instance(self, llmgw_env_vars):
"""Each settings instance should have its own independent cache."""
with patch.dict(os.environ, llmgw_env_vars, clear=True):
settings1 = LLMGatewaySettings()
settings2 = 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:
settings1.get_available_models()
settings2.get_available_models()
assert mock_get.call_count == 2


class TestLLMGatewayAuthRefresh:
"""Tests for LLMGatewayS2SAuth token refresh logic."""

Expand Down
21 changes: 21 additions & 0 deletions tests/core/features/settings/test_platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,27 @@ def test_validate_byo_model_is_noop(self, platform_env_vars, mock_platform_auth)
assert result is None


class TestPlatformDiscoveryCache:
"""Tests for get_available_models lru_cache on PlatformSettings."""

def test_second_call_returns_cached_result(self, platform_env_vars, mock_platform_auth):
"""Second call should return the cached result without querying the backend."""
with patch.dict(os.environ, platform_env_vars, clear=True):
settings = PlatformSettings()

mock_model = MagicMock()
mock_model.model_dump.return_value = {"modelName": "gpt-4o", "vendor": "openai"}

with patch("uipath.llm_client.settings.platform.settings.UiPath") as mock_uipath:
mock_uipath.return_value.agenthub.get_available_llm_models.return_value = [
mock_model
]
first = settings.get_available_models()
second = settings.get_available_models()
assert first == second
mock_uipath.return_value.agenthub.get_available_llm_models.assert_called_once()


class TestPlatformAuthRefresh:
"""Tests for PlatformAuth token refresh logic."""

Expand Down
Loading