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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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.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
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,<2.0.0",
"uipath-llm-client>=1.19.0,<2.0.0",
"uipath-llm-client>=1.20.0,<2.0.0",
]

[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.19.0"
__version__ = "1.20.0"
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.19.0"
__version__ = "1.20.0"
35 changes: 29 additions & 6 deletions src/uipath/llm_client/settings/llmgateway/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand All @@ -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(
Expand All @@ -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(),
Expand Down
90 changes: 90 additions & 0 deletions tests/core/features/settings/test_llmgateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
Loading