Skip to content
Merged
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.5.1] - 2026-03-17

### Fix
- Added error message for normalized embeddings on UiPath Platform (AgentHub/Orchestrator) as there is no supported endpoint
- Fix endpoints for platform to remove api version

## [1.5.0] - 2026-03-16

### Stable Version 1.5.0
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.5.1] - 2026-03-17

### Fixes
- Fixes to core package, version bump

## [1.5.0] - 2026-03-16

### Stable Version 1.5.0
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.12",
"uipath-llm-client>=1.5.0",
"uipath-llm-client>=1.5.1",
]

[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.5.0"
__version__ = "1.5.1"
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.5.0"
__version__ = "1.5.1"
34 changes: 30 additions & 4 deletions src/uipath/llm_client/settings/platform/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,20 @@ def validate_environment(self) -> Self:
self.client_id = parsed_token_data.get("client_id", None)
return self

@staticmethod
def _format_endpoint(endpoint: str, **kwargs: str | None) -> str:
"""Format an endpoint template, stripping query params with None values."""
# Remove query parameters whose values are None
if "?" in endpoint:
base, query = endpoint.split("?", 1)
params = [
p
for p in query.split("&")
if not any(f"{{{k}}}" in p for k, v in kwargs.items() if v is None)
]
endpoint = f"{base}?{'&'.join(params)}" if params else base
return endpoint.format(**{k: v for k, v in kwargs.items() if v is not None})

@override
def build_base_url(
self,
Expand All @@ -85,12 +99,24 @@ def build_base_url(
assert api_config is not None
if api_config.routing_mode == "normalized" and api_config.api_type == "completions":
url = f"{self.base_url}/{EndpointManager.get_normalized_endpoint()}"
elif api_config.routing_mode == "normalized" and api_config.api_type == "embeddings":
raise ValueError(
"Normalized embeddings are not supported on UiPath Platform (AgentHub/Orchestrator). "
"Use passthrough routing mode for embeddings instead."
)
elif api_config.routing_mode == "passthrough" and api_config.api_type == "completions":
endpoint = EndpointManager.get_vendor_endpoint()
url = f"{self.base_url}/{self._format_endpoint(endpoint, model=model_name, vendor=api_config.vendor_type, api_version=api_config.api_version)}"
elif api_config.routing_mode == "passthrough" and api_config.api_type == "embeddings":
assert api_config.api_version is not None
url = f"{self.base_url}/{EndpointManager.get_embeddings_endpoint().format(model=model_name, api_version=api_config.api_version)}"
if api_config.vendor_type is not None and api_config.vendor_type != "openai":
raise ValueError(
f"Platform embeddings endpoint only supports OpenAI-compatible models, "
f"got vendor_type='{api_config.vendor_type}'."
)
endpoint = EndpointManager.get_embeddings_endpoint()
url = f"{self.base_url}/{self._format_endpoint(endpoint, model=model_name, api_version=api_config.api_version)}"
else:
assert api_config.vendor_type is not None
url = f"{self.base_url}/{EndpointManager.get_vendor_endpoint().format(model=model_name, vendor=api_config.vendor_type)}"
raise ValueError(f"Invalid API configuration: {api_config}")
return url

@override
Expand Down
Binary file modified tests/cassettes.db
Binary file not shown.
3 changes: 3 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from uipath.llm_client.settings import UiPathBaseSettings
from uipath.llm_client.settings.llmgateway import LLMGatewaySettings
from uipath.llm_client.settings.platform import PlatformSettings


@pytest.fixture(autouse=True, scope="session")
Expand Down Expand Up @@ -60,5 +61,7 @@ def client_settings(request: pytest.FixtureRequest) -> UiPathBaseSettings:
match request.param:
case "llmgw":
return LLMGatewaySettings()
case "agenthub":
return PlatformSettings()
case _:
raise ValueError(f"Invalid client type: {request.param}")
169 changes: 167 additions & 2 deletions tests/core/test_base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -480,14 +480,33 @@ class TestPlatformSettings:
def test_build_base_url_passthrough(
self, platform_env_vars, mock_platform_auth, passthrough_api_config
):
"""Test build_base_url for passthrough mode."""
"""Test build_base_url for passthrough completions mode."""
with patch.dict(os.environ, platform_env_vars, clear=True):
settings = PlatformSettings()
url = settings.build_base_url(
model_name="gpt-4o",
api_config=passthrough_api_config,
)
assert "agenthub_/llm/raw/vendor/openai/model/gpt-4o/completions" in url
assert "llm/raw/vendor/openai/model/gpt-4o/completions" in url

def test_build_base_url_passthrough_with_api_version(
self, platform_env_vars, mock_platform_auth
):
"""Test build_base_url for passthrough completions with api_version (vendor endpoint ignores it)."""
api_config = UiPathAPIConfig(
api_type=ApiType.COMPLETIONS,
routing_mode=RoutingMode.PASSTHROUGH,
vendor_type="openai",
api_version="2025-03-01",
)
with patch.dict(os.environ, platform_env_vars, clear=True):
settings = PlatformSettings()
url = settings.build_base_url(
model_name="gpt-4o",
api_config=api_config,
)
assert "llm/raw/vendor/openai/model/gpt-4o/completions" in url
assert "api-version" not in url

def test_build_base_url_normalized(
self, platform_env_vars, mock_platform_auth, normalized_api_config
Expand Down Expand Up @@ -532,6 +551,152 @@ def test_build_auth_pipeline_returns_auth(self, platform_env_vars, mock_platform
auth = settings.build_auth_pipeline()
assert isinstance(auth, Auth)

def test_build_auth_pipeline_with_access_token(self, platform_env_vars, mock_platform_auth):
"""Test auth pipeline uses access_token when provided."""
from uipath.llm_client.settings.platform.auth import PlatformAuth

with patch.dict(os.environ, platform_env_vars, clear=True):
settings = PlatformSettings()
auth = settings.build_auth_pipeline()
assert isinstance(auth, PlatformAuth)

def test_build_base_url_passthrough_embeddings(self, platform_env_vars, mock_platform_auth):
"""Test build_base_url for passthrough embeddings with api_version."""
api_config = UiPathAPIConfig(
api_type=ApiType.EMBEDDINGS,
routing_mode=RoutingMode.PASSTHROUGH,
vendor_type="openai",
api_version="2024-02-01",
)
with patch.dict(os.environ, platform_env_vars, clear=True):
settings = PlatformSettings()
url = settings.build_base_url(
model_name="text-embedding-3-large",
api_config=api_config,
)
assert "embeddings" in url
assert "text-embedding-3-large" in url
assert "api-version=2024-02-01" in url

def test_build_base_url_passthrough_embeddings_no_api_version(
self, platform_env_vars, mock_platform_auth
):
"""Test build_base_url for passthrough embeddings without api_version."""
api_config = UiPathAPIConfig(
api_type=ApiType.EMBEDDINGS,
routing_mode=RoutingMode.PASSTHROUGH,
vendor_type="openai",
)
with patch.dict(os.environ, platform_env_vars, clear=True):
settings = PlatformSettings()
url = settings.build_base_url(
model_name="text-embedding-3-large",
api_config=api_config,
)
assert "embeddings" in url
assert "text-embedding-3-large" in url
assert "api-version" not in url

def test_build_base_url_passthrough_embeddings_non_openai_raises(
self, platform_env_vars, mock_platform_auth
):
"""Test build_base_url raises for non-OpenAI passthrough embeddings."""
api_config = UiPathAPIConfig(
api_type=ApiType.EMBEDDINGS,
routing_mode=RoutingMode.PASSTHROUGH,
vendor_type="vertexai",
)
with patch.dict(os.environ, platform_env_vars, clear=True):
settings = PlatformSettings()
with pytest.raises(ValueError, match="only supports OpenAI-compatible models"):
settings.build_base_url(
model_name="gemini-embedding-001",
api_config=api_config,
)

def test_build_base_url_normalized_embeddings_raises(
self, platform_env_vars, mock_platform_auth
):
"""Test build_base_url raises ValueError for normalized embeddings."""
normalized_embeddings_config = UiPathAPIConfig(
api_type=ApiType.EMBEDDINGS,
routing_mode=RoutingMode.NORMALIZED,
)
with patch.dict(os.environ, platform_env_vars, clear=True):
settings = PlatformSettings()
with pytest.raises(ValueError, match="Normalized embeddings are not supported"):
settings.build_base_url(
model_name="text-embedding-3-large",
api_config=normalized_embeddings_config,
)

def test_build_base_url_requires_model_name(
self, platform_env_vars, mock_platform_auth, normalized_api_config
):
"""Test build_base_url asserts model_name is not None."""
with patch.dict(os.environ, platform_env_vars, clear=True):
settings = PlatformSettings()
with pytest.raises(AssertionError):
settings.build_base_url(model_name=None, api_config=normalized_api_config)

def test_build_base_url_requires_api_config(self, platform_env_vars, mock_platform_auth):
"""Test build_base_url asserts api_config is not None."""
with patch.dict(os.environ, platform_env_vars, clear=True):
settings = PlatformSettings()
with pytest.raises(AssertionError):
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):
"""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
headers = settings.build_auth_headers()
assert headers == {}

def test_validation_requires_all_fields(self, mock_platform_auth):
"""Test validation fails without required fields."""
env = {
"UIPATH_ACCESS_TOKEN": "test-access-token",
# 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"):
PlatformSettings()

def test_validation_fails_on_expired_token(self):
"""Test validation fails when access token is expired."""
with (
patch(
"uipath.llm_client.settings.platform.settings.is_token_expired",
return_value=True,
),
patch(
"uipath.llm_client.settings.platform.settings.parse_access_token",
return_value={"client_id": "test-client-id"},
),
):
env = {
"UIPATH_ACCESS_TOKEN": "test-access-token",
"UIPATH_URL": "https://cloud.uipath.com/org/tenant",
"UIPATH_TENANT_ID": "test-tenant-id",
"UIPATH_ORGANIZATION_ID": "test-org-id",
}
with patch.dict(os.environ, env, clear=True):
with pytest.raises(ValueError, match="Access token is expired"):
PlatformSettings()

def test_validate_byo_model_is_noop(self, platform_env_vars, mock_platform_auth):
"""Test validate_byo_model does nothing (no-op)."""
with patch.dict(os.environ, platform_env_vars, clear=True):
settings = PlatformSettings()
result = settings.validate_byo_model({"modelName": "custom-model"})
assert result is None


# ============================================================================
# Test Platform Auth Refresh Logic
Expand Down
19 changes: 19 additions & 0 deletions tests/langchain/test_provider_integrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,13 @@
UiPathChatBedrockConverse,
)
from uipath_langchain_client.clients.google.chat_models import UiPathChatGoogleGenerativeAI
from uipath_langchain_client.clients.google.embeddings import UiPathGoogleGenerativeAIEmbeddings
from uipath_langchain_client.clients.normalized.chat_models import UiPathChat
from uipath_langchain_client.clients.normalized.embeddings import UiPathEmbeddings
from uipath_langchain_client.clients.vertexai.chat_models import UiPathChatAnthropicVertex

from tests.langchain.utils import search_accommodation, search_attractions, search_flights
from uipath.llm_client.settings import PlatformSettings


@pytest.mark.asyncio
Expand Down Expand Up @@ -503,6 +506,22 @@ class TestIntegrationEmbeddings(EmbeddingsIntegrationTests):
def setup_models(self, embeddings_config: tuple[type[Embeddings], dict[str, Any]]):
self._embeddings_class, self._embeddings_kwargs = embeddings_config

@pytest.fixture(autouse=True)
def skip_on_specific_configs(
self,
embeddings_config: tuple[type[Embeddings], dict[str, Any]],
) -> None:
model_class, model_kwargs = embeddings_config
client_settings = model_kwargs.get("client_settings")
if model_class == UiPathEmbeddings and isinstance(client_settings, PlatformSettings):
pytest.skip(
"Normalized embeddings are not supported on UiPath Platform (AgentHub/Orchestrator)"
)
if model_class == UiPathGoogleGenerativeAIEmbeddings and isinstance(
client_settings, PlatformSettings
):
pytest.skip("Platform embeddings endpoint only supports OpenAI-compatible models")

@property
def embeddings_class(self) -> type[Embeddings]:
return self._embeddings_class
Expand Down