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.13.1] - 2026-06-09

### Fixed
- `PlatformSettings` now accepts non-JWT access tokens (e.g. opaque UiPath reference tokens) for `UIPATH_ACCESS_TOKEN`. Previously any token that was not a parseable JWT failed validation with "Invalid access token: expected JWT with at least 2 dot-separated parts". Token introspection is now best-effort: `is_token_expired` returns `False` when the token is not a parseable JWT, and the settings validator only extracts `client_id` when the token is a parseable JWT. Added `try_parse_access_token` helper in `uipath.llm_client.settings.platform.utils`.

## [1.13.0] - 2026-05-27

### 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.13.1] - 2026-06-09

### Fixed
- Picks up the core `uipath-llm-client` 1.13.1 fix allowing non-JWT access tokens (e.g. opaque UiPath reference tokens) as `UIPATH_ACCESS_TOKEN`, so LangChain clients built on `PlatformSettings` no longer fail validation with "Invalid access token: expected JWT with at least 2 dot-separated parts".

## [1.13.0] - 2026-05-27

### Changed
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.13.0,<2.0.0",
"uipath-llm-client>=1.13.1,<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.13.0"
__version__ = "1.13.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.13.0"
__version__ = "1.13.1"
12 changes: 9 additions & 3 deletions src/uipath/llm_client/settings/platform/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@

from uipath.llm_client.settings.base import UiPathAPIConfig, UiPathBaseSettings
from uipath.llm_client.settings.constants import ApiType, RoutingMode
from uipath.llm_client.settings.platform.utils import is_token_expired, parse_access_token
from uipath.llm_client.settings.platform.utils import (
is_token_expired,
try_parse_access_token,
)


class PlatformBaseSettings(UiPathBaseSettings):
Expand Down Expand Up @@ -81,8 +84,11 @@ def validate_environment(self) -> Self:
"Access token is expired. Try running `uipath auth` to refresh the token."
)

parsed_token_data = parse_access_token(access_token)
self.client_id = parsed_token_data.get("client_id")
# The access token may be any form, not just a JWT (e.g. opaque
# reference tokens). Only extract client_id when it is a parseable JWT.
parsed_token_data = try_parse_access_token(access_token)
if parsed_token_data is not None:
self.client_id = parsed_token_data.get("client_id")
return self

@staticmethod
Expand Down
34 changes: 30 additions & 4 deletions src/uipath/llm_client/settings/platform/utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import base64
import binascii
import json
import time
from typing import Any
Expand Down Expand Up @@ -26,16 +27,41 @@ def parse_access_token(access_token: str) -> dict[str, Any]:
raise ValueError(f"Invalid access token: failed to decode payload: {e}") from e


def try_parse_access_token(access_token: str) -> dict[str, Any] | None:
"""Best-effort parse of an access token's JWT payload.

Access tokens are not guaranteed to be JWTs — UiPath also issues opaque
tokens (e.g. reference tokens) that carry no client-readable claims. This
returns the decoded payload when the token is a parseable JWT, or ``None``
when it is not, instead of raising.

Args:
access_token: An access token string of any form.

Returns:
The decoded payload as a dictionary, or ``None`` if the token is not a
parseable JWT.
"""
try:
return parse_access_token(access_token)
except (ValueError, binascii.Error):
return None


def is_token_expired(token: str) -> bool:
"""Check whether a JWT access token has expired.
"""Check whether an access token has expired.

Args:
token: A JWT token string.
token: An access token string of any form.

Returns:
True if the token is expired, False if it is still valid or has no ``exp`` claim.
True if the token is a JWT with an ``exp`` claim in the past; False if
it is still valid, has no ``exp`` claim, or is an opaque token whose
expiry cannot be inspected.
"""
token_data = parse_access_token(token)
token_data = try_parse_access_token(token)
if token_data is None:
return False
exp = token_data.get("exp")
if exp is None:
return False
Expand Down
4 changes: 2 additions & 2 deletions tests/core/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,14 @@ def platform_env_vars():

@pytest.fixture
def mock_platform_auth():
"""Patches is_token_expired and parse_access_token for PlatformSettings tests."""
"""Patches is_token_expired and try_parse_access_token for PlatformSettings tests."""
with (
patch(
"uipath.llm_client.settings.platform.settings.is_token_expired",
return_value=False,
),
patch(
"uipath.llm_client.settings.platform.settings.parse_access_token",
"uipath.llm_client.settings.platform.settings.try_parse_access_token",
return_value={"client_id": "test-client-id"},
),
):
Expand Down
23 changes: 22 additions & 1 deletion tests/core/features/settings/test_platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ def test_validation_fails_on_expired_token(self):
return_value=True,
),
patch(
"uipath.llm_client.settings.platform.settings.parse_access_token",
"uipath.llm_client.settings.platform.settings.try_parse_access_token",
return_value={"client_id": "test-client-id"},
),
):
Expand All @@ -250,6 +250,27 @@ def test_validation_fails_on_expired_token(self):
with pytest.raises(ValueError, match="Access token is expired"):
PlatformSettings()

@pytest.mark.parametrize(
"token",
["rt_abc123", "some-opaque-token", "not.a.valid.jwt"],
)
def test_non_jwt_token_is_accepted(self, token):
"""Non-JWT access tokens (e.g. opaque reference tokens) are accepted.

The token may be any form; client_id is only extracted when it is a
parseable JWT, otherwise it stays None.
"""
env = {
"UIPATH_ACCESS_TOKEN": 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):
settings = PlatformSettings()
assert settings.access_token.get_secret_value() == token
assert settings.client_id is None

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):
Expand Down
43 changes: 42 additions & 1 deletion tests/core/features/test_platform_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@

import pytest

from uipath.llm_client.settings.platform.utils import is_token_expired, parse_access_token
from uipath.llm_client.settings.platform.utils import (
is_token_expired,
parse_access_token,
try_parse_access_token,
)


class TestParseAccessToken:
Expand Down Expand Up @@ -69,3 +73,40 @@ def test_missing_exp_claim(self):
token = f"header.{encoded_payload}.signature"

assert is_token_expired(token) is False

@pytest.mark.parametrize(
"token",
[
"rt_abc123", # opaque reference token
"not-a-jwt-token", # no dot-separated parts
"header.!!!invalid-base64!!!.signature", # undecodable payload
"", # empty
],
)
def test_opaque_token_not_expired(self, token):
# Tokens that are not parseable JWTs cannot be introspected, so they
# are never treated as expired (and must not raise during parsing).
assert is_token_expired(token) is False


class TestTryParseAccessToken:
def test_valid_jwt_returns_payload(self):
payload = {"sub": "user-123", "client_id": "abc"}
encoded_payload = (
base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")
)
token = f"header.{encoded_payload}.signature"

assert try_parse_access_token(token) == payload

@pytest.mark.parametrize(
"token",
[
"rt_abc123", # opaque reference token
"not-a-jwt-token", # no dot-separated parts
"header.!!!invalid-base64!!!.signature", # undecodable payload (binascii.Error)
"", # empty
],
)
def test_non_jwt_returns_none(self, token):
assert try_parse_access_token(token) is None
15 changes: 15 additions & 0 deletions tests/langchain/clients/bedrock/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,21 @@ def skip_on_specific_configs(
]:
pytest.skip(f"Skipping {test_name}: file blocks not supported on this client")

# UiPathChatBedrock: these are multi-turn exchanges (image/PDF -> tool call ->
# follow-up) that issue several identical `POST /` invoke requests. With the
# VCR config (`allow_playback_repeats: False`, path-only matching) they cannot
# be replayed deterministically from a recorded cassette, so they fail in CI
# (empty response body -> JSONDecodeError) even though they pass against a live
# gateway. Skip until per-turn (body-based) cassette matching is added.
if model_class == UiPathChatBedrock and test_name in [
"test_image_tool_message",
"test_pdf_tool_message",
]:
pytest.skip(
f"Skipping {test_name}: multi-turn invoke exchange is not reproducible "
"via VCR cassettes (see allow_playback_repeats in tests/conftest.py)"
)

# UiPathChatBedrockConverse: parallel tool calling not supported
if model_class == UiPathChatBedrockConverse and test_name in [
"test_parallel_and_sequential_tool_calling",
Expand Down
Loading