From e546f315ab345a8ee69bd3b47b834ca6f1df17d0 Mon Sep 17 00:00:00 2001 From: Andrei Tava Date: Thu, 10 Sep 2026 12:22:00 +0300 Subject: [PATCH] feat: allow overriding llmgw url for local development --- CHANGELOG.md | 5 ++ README.md | 3 + packages/uipath_langchain_client/CHANGELOG.md | 5 ++ .../uipath_langchain_client/pyproject.toml | 2 +- .../uipath_langchain_client/__version__.py | 2 +- src/uipath/llm_client/__version__.py | 2 +- .../settings/llmgateway/settings.py | 35 ++++++-- .../core/features/settings/test_llmgateway.py | 90 +++++++++++++++++++ 8 files changed, 135 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5eea4277..b4da28fe 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.20.0] - 2026-09-10 + +### Added +- `LLMGatewaySettings` honours `UIPATH_SERVICE_URL_LLMGATEWAY`, the per-service local override the Platform backend already uses. Request and discovery URLs go to that host as `/api/...`, without the `{org}/{tenant}/llmgateway_` prefix the cloud front door adds; the S2S token is still minted at `LLMGW_URL`. Unset, URLs are unchanged. + ## [1.19.0] - 2026-09-09 ### Added diff --git a/README.md b/README.md index be3a18b8..3e3b1d19 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,9 @@ settings = LLMGatewaySettings( - Either `access_token` OR both `client_id` and `client_secret` must be provided - S2S authentication uses `client_id`/`client_secret` to obtain tokens automatically +**Local development:** +- `UIPATH_SERVICE_URL_LLMGATEWAY=http://localhost:7091` sends request and discovery URLs to a standalone gateway as `/api/...`, without the `{org}/{tenant}/llmgateway_` prefix the cloud front door adds. The S2S token is still minted at `LLMGW_URL`. + ## Usage Examples ### Quick Start with Direct Client Classes diff --git a/packages/uipath_langchain_client/CHANGELOG.md b/packages/uipath_langchain_client/CHANGELOG.md index 0d5fba94..f94c4564 100644 --- a/packages/uipath_langchain_client/CHANGELOG.md +++ b/packages/uipath_langchain_client/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to `uipath_langchain_client` will be documented in this file. +## [1.20.0] - 2026-09-10 + +### Changed +- Bumped the `uipath-llm-client` floor to `>=1.20.0`, which adds `UIPATH_SERVICE_URL_LLMGATEWAY` for pointing `LLMGatewaySettings` at a standalone (e.g. local) gateway. + ## [1.19.0] - 2026-09-09 ### Added diff --git a/packages/uipath_langchain_client/pyproject.toml b/packages/uipath_langchain_client/pyproject.toml index c58d5eb9..cefc8978 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,<2.0.0", - "uipath-llm-client>=1.19.0,<2.0.0", + "uipath-llm-client>=1.20.0,<2.0.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 c1171734..d6973470 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.19.0" +__version__ = "1.20.0" diff --git a/src/uipath/llm_client/__version__.py b/src/uipath/llm_client/__version__.py index 73c73458..a59009d9 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.19.0" +__version__ = "1.20.0" diff --git a/src/uipath/llm_client/settings/llmgateway/settings.py b/src/uipath/llm_client/settings/llmgateway/settings.py index 4bf85ecb..d493a51a 100644 --- a/src/uipath/llm_client/settings/llmgateway/settings.py +++ b/src/uipath/llm_client/settings/llmgateway/settings.py @@ -5,6 +5,7 @@ from httpx import Client from pydantic import Field, SecretStr, model_validator from typing_extensions import override +from uipath.platform.common import resolve_service_url from uipath.llm_client.settings.base import UiPathAPIConfig, UiPathBaseSettings from uipath.llm_client.settings.constants import ApiType, RoutingMode @@ -59,6 +60,17 @@ class LLMGatewayBaseSettings(UiPathBaseSettings): default_factory=dict, validation_alias="LLMGW_ADDITIONAL_HEADERS" ) + def _build_gateway_url(self, path: str) -> str: + """Build a gateway URL, honouring ``UIPATH_SERVICE_URL_LLMGATEWAY``. + + The ``{org}/{tenant}/llmgateway_`` prefix is added by the cloud front door, + so a standalone gateway serves ``/api/...`` directly. + """ + override_url = resolve_service_url(path) + if override_url: + return override_url + return f"{self.base_url}/{self.org_id}/{self.tenant_id}/{path}" + @model_validator(mode="after") def validate_auth_settings(self) -> Self: """Validate that either access_token or S2S credentials are provided.""" @@ -81,14 +93,19 @@ def build_base_url( raise ValueError( "api_config.routing_mode is required for LLMGatewaySettings.build_base_url" ) - base_url = f"{self.base_url}/{self.org_id}/{self.tenant_id}" if api_config.routing_mode == RoutingMode.NORMALIZED: - url = f"{base_url}/{LLMGatewayEndpoints.NORMALIZED_ENDPOINT.value.format(api_type='chat/completions' if api_config.api_type == ApiType.COMPLETIONS else 'embeddings')}" + path = LLMGatewayEndpoints.NORMALIZED_ENDPOINT.value.format( + api_type="chat/completions" + if api_config.api_type == ApiType.COMPLETIONS + else "embeddings" + ) elif api_config.routing_mode == RoutingMode.PASSTHROUGH: - url = f"{base_url}/{LLMGatewayEndpoints.PASSTHROUGH_ENDPOINT.value.format(vendor=api_config.vendor_type, model=model_name, api_type=api_config.api_type)}" + path = LLMGatewayEndpoints.PASSTHROUGH_ENDPOINT.value.format( + vendor=api_config.vendor_type, model=model_name, api_type=api_config.api_type + ) else: raise ValueError(f"Unsupported routing_mode: {api_config.routing_mode}") - return url + return self._build_gateway_url(path) @override def build_auth_headers( @@ -115,11 +132,17 @@ def build_auth_headers( @override def _discovery_cache_key(self) -> tuple[str, ...]: - return (self.base_url, self.org_id, self.tenant_id, self.requesting_product) + # Effective URL, so an active service override gets its own cache entry. + return ( + self._build_gateway_url(LLMGatewayEndpoints.DISCOVERY_ENDPOINT.value), + 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}" + discovery_url = self._build_gateway_url(LLMGatewayEndpoints.DISCOVERY_ENDPOINT.value) with Client( auth=self.build_auth_pipeline(), headers=self.build_auth_headers(), diff --git a/tests/core/features/settings/test_llmgateway.py b/tests/core/features/settings/test_llmgateway.py index 454980f8..497732a5 100644 --- a/tests/core/features/settings/test_llmgateway.py +++ b/tests/core/features/settings/test_llmgateway.py @@ -158,6 +158,96 @@ def test_get_available_models_raises_on_unauthorized(self, llmgw_env_vars): assert exc_info.value.status_code == 401 +class TestLLMGatewayServiceUrlOverride: + """Tests for UIPATH_SERVICE_URL_LLMGATEWAY.""" + + LOCAL = "http://localhost:7091" + + def test_urls_unchanged_without_override(self, llmgw_env_vars, passthrough_api_config): + with patch.dict(os.environ, llmgw_env_vars, clear=True): + settings = LLMGatewaySettings() + url = settings.build_base_url(model_name="gpt-4o", api_config=passthrough_api_config) + + assert url == ( + "https://cloud.uipath.com/test-org-id/test-tenant-id/" + "llmgateway_/api/raw/vendor/openai/model/gpt-4o/completions" + ) + + def test_override_redirects_passthrough_url(self, llmgw_env_vars, passthrough_api_config): + env = {**llmgw_env_vars, "UIPATH_SERVICE_URL_LLMGATEWAY": self.LOCAL} + with patch.dict(os.environ, env, clear=True): + settings = LLMGatewaySettings() + url = settings.build_base_url(model_name="gpt-4o", api_config=passthrough_api_config) + + assert url == f"{self.LOCAL}/api/raw/vendor/openai/model/gpt-4o/completions" + + def test_override_redirects_normalized_url(self, llmgw_env_vars, normalized_api_config): + env = {**llmgw_env_vars, "UIPATH_SERVICE_URL_LLMGATEWAY": self.LOCAL} + with patch.dict(os.environ, env, clear=True): + settings = LLMGatewaySettings() + url = settings.build_base_url(model_name="gpt-4o", api_config=normalized_api_config) + + assert url == f"{self.LOCAL}/api/chat/completions" + + def test_override_redirects_discovery_url(self, llmgw_env_vars): + UiPathBaseSettings._discovery_cache.clear() + env = {**llmgw_env_vars, "UIPATH_SERVICE_URL_LLMGATEWAY": self.LOCAL} + mock_response = MagicMock() + mock_response.is_error = False + mock_response.json.return_value = [] + + with patch.dict(os.environ, env, clear=True): + settings = LLMGatewaySettings() + with patch.object(Client, "get", return_value=mock_response) as mock_get: + settings.get_available_models(refresh=True) + + assert mock_get.call_args.args[0] == f"{self.LOCAL}/api/discovery" + + def test_override_and_cloud_do_not_share_discovery_cache(self, llmgw_env_vars): + UiPathBaseSettings._discovery_cache.clear() + mock_response = MagicMock() + mock_response.is_error = False + mock_response.json.return_value = [] + + with patch.object(Client, "get", return_value=mock_response) as mock_get: + with patch.dict(os.environ, llmgw_env_vars, clear=True): + LLMGatewaySettings().get_available_models() + env = {**llmgw_env_vars, "UIPATH_SERVICE_URL_LLMGATEWAY": self.LOCAL} + with patch.dict(os.environ, env, clear=True): + LLMGatewaySettings().get_available_models() + + assert [c.args[0] for c in mock_get.call_args_list] == [ + "https://cloud.uipath.com/test-org-id/test-tenant-id/llmgateway_/api/discovery", + f"{self.LOCAL}/api/discovery", + ] + + def test_unrelated_service_override_is_ignored(self, llmgw_env_vars, passthrough_api_config): + env = {**llmgw_env_vars, "UIPATH_SERVICE_URL_AGENTHUB": self.LOCAL} + with patch.dict(os.environ, env, clear=True): + settings = LLMGatewaySettings() + url = settings.build_base_url(model_name="gpt-4o", api_config=passthrough_api_config) + + assert "localhost" not in url + assert url.startswith("https://cloud.uipath.com/test-org-id/test-tenant-id/llmgateway_/") + + def test_s2s_token_is_still_minted_at_base_url(self, llmgw_s2s_env_vars): + """A standalone gateway has no identity_ endpoint.""" + from uipath.llm_client.settings.llmgateway.auth import LLMGatewayS2SAuth + + env = {**llmgw_s2s_env_vars, "UIPATH_SERVICE_URL_LLMGATEWAY": self.LOCAL} + mock_response = MagicMock() + mock_response.is_error = False + mock_response.json.return_value = {"access_token": "s2s-token-value"} + + with patch.dict(os.environ, env, clear=True): + settings = LLMGatewaySettings() + with patch.object(Client, "post", return_value=mock_response) as mock_post: + auth = LLMGatewayS2SAuth(settings=settings) + + assert auth.access_token == "s2s-token-value" + assert mock_post.call_args.args[0] == "https://cloud.uipath.com/identity_/connect/token" + + class TestLLMGatewayAuthRefresh: """Tests for LLMGatewayS2SAuth token refresh logic."""