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
4 changes: 2 additions & 2 deletions .github/workflows/cd-langchain.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,12 @@ jobs:
- name: Smoke test (wheel with all extras)
run: |
WHEEL=$(ls dist/*.whl)
uv run --isolated --no-project --with "${WHEEL}[all]" tests/langchain/langchain_smoke_test.py
uv run --isolated --no-project --with "${WHEEL}[all]" tests/langchain/smoke_test.py

- name: Smoke test (source distribution with all extras)
run: |
SDIST=$(ls dist/*.tar.gz)
uv run --isolated --no-project --with "${SDIST}[all]" tests/langchain/langchain_smoke_test.py
uv run --isolated --no-project --with "${SDIST}[all]" tests/langchain/smoke_test.py

- name: Publish package
run: uv publish
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,12 @@ jobs:
- name: Smoke test (wheel with all extras)
run: |
WHEEL=$(ls dist/*.whl)
uv run --isolated --no-project --with "${WHEEL}[all]" tests/core/core_smoke_test.py
uv run --isolated --no-project --with "${WHEEL}[all]" tests/core/smoke_test.py

- name: Smoke test (source distribution with all extras)
run: |
SDIST=$(ls dist/*.tar.gz)
uv run --isolated --no-project --with "${SDIST}[all]" tests/core/core_smoke_test.py
uv run --isolated --no-project --with "${SDIST}[all]" tests/core/smoke_test.py

- name: Publish package
run: uv publish
Expand Down
Binary file modified tests/cassettes.db
Binary file not shown.
16 changes: 4 additions & 12 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

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 @@ -56,15 +55,8 @@ def pytest_recording_configure(config, vcr):
vcr.register_persister(SQLitePersister)


# Only "llmgw" is parameterized because Platform (agenthub) requires `uipath auth`
# credentials that are not available in CI. Platform-specific logic is tested
# via mocked settings in test_base_client.py.
@pytest.fixture(scope="session", params=["llmgw"])
# Only "llmgateway" is parameterized because Platform (agenthub) requires `uipath auth`
# credentials that are not available in CI.
@pytest.fixture(scope="session", params=["llmgateway"])
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}")
return LLMGatewaySettings()
Empty file.
Empty file.
Empty file.
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"""

import json
from collections.abc import Generator
from typing import TypedDict
from unittest.mock import AsyncMock, MagicMock, patch

Expand All @@ -33,9 +34,11 @@
)
from uipath.llm_client.clients.normalized.completions import (
Completions,
_aiter_sse,
_build_request,
_build_response_format,
_build_tool_definition,
_iter_sse,
_parse_response,
_parse_stream_chunk,
_parse_structured_output,
Expand Down Expand Up @@ -1384,3 +1387,143 @@ def test_tool_call_arguments_always_dict(self):
tc = result.choices[0].message.tool_calls[0]
assert isinstance(tc.arguments, dict)
assert tc.arguments == {"city": "London"}


# ============================================================================
# Test: SSE parsing helpers
# ============================================================================


def _make_generator(lines: list[str]) -> Generator[str, None, None]:
"""Helper to create a generator from a list of strings."""
yield from lines


class TestIterSSE:
def test_parses_multiple_data_lines(self):
lines = [
f"data: {json.dumps({'id': 'chunk-1', 'choices': [{'delta': {'content': 'Hi'}}]})}",
f"data: {json.dumps({'id': 'chunk-2', 'choices': [{'delta': {'content': ' there'}}]})}",
]
results = list(_iter_sse(_make_generator(lines)))
assert len(results) == 2
assert results[0]["id"] == "chunk-1"
assert results[1]["id"] == "chunk-2"

def test_handles_done_marker(self):
lines = [
f"data: {json.dumps({'id': 'chunk-1', 'choices': [{'delta': {'content': 'Hi'}}]})}",
"data: [DONE]",
]
results = list(_iter_sse(_make_generator(lines)))
assert len(results) == 1
assert results[0]["id"] == "chunk-1"

def test_skips_invalid_json(self):
lines = [
"data: not-valid-json",
f"data: {json.dumps({'id': 'chunk-1', 'choices': []})}",
]
results = list(_iter_sse(_make_generator(lines)))
assert len(results) == 1

def test_skips_empty_id(self):
lines = [
f"data: {json.dumps({'id': '', 'choices': []})}",
f"data: {json.dumps({'id': 'chunk-1', 'choices': []})}",
]
results = list(_iter_sse(_make_generator(lines)))
assert len(results) == 1
assert results[0]["id"] == "chunk-1"

def test_handles_lines_without_data_prefix(self):
lines = [
json.dumps({"id": "chunk-1", "choices": [{"delta": {"content": "Hi"}}]}),
]
results = list(_iter_sse(_make_generator(lines)))
assert len(results) == 1
assert results[0]["id"] == "chunk-1"


class TestAiterSSE:
@pytest.mark.asyncio
async def test_parses_multiple_data_lines(self):
async def async_lines():
yield f"data: {json.dumps({'id': 'chunk-1', 'choices': [{'delta': {'content': 'Hi'}}]})}"
yield f"data: {json.dumps({'id': 'chunk-2', 'choices': [{'delta': {'content': ' there'}}]})}"

results = []
async for data in _aiter_sse(async_lines()):
results.append(data)
assert len(results) == 2
assert results[0]["id"] == "chunk-1"
assert results[1]["id"] == "chunk-2"

@pytest.mark.asyncio
async def test_handles_done_marker(self):
async def async_lines():
yield f"data: {json.dumps({'id': 'chunk-1', 'choices': []})}"
yield "data: [DONE]"

results = []
async for data in _aiter_sse(async_lines()):
results.append(data)
assert len(results) == 1

@pytest.mark.asyncio
async def test_skips_invalid_json(self):
async def async_lines():
yield "data: {bad json"
yield f"data: {json.dumps({'id': 'chunk-1', 'choices': []})}"

results = []
async for data in _aiter_sse(async_lines()):
results.append(data)
assert len(results) == 1
assert results[0]["id"] == "chunk-1"


# ============================================================================
# Test: Embeddings.acreate (async)
# ============================================================================


class TestEmbeddingsAcreateExtended:
@pytest.mark.asyncio
async def test_acreate_string_input_wrapped(self):
mock_response = MagicMock()
mock_response.json.return_value = SAMPLE_EMBEDDING_RESPONSE

mock_client = AsyncMock()
mock_client.request.return_value = mock_response

client_obj = MagicMock()
client_obj._embedding_async_client = mock_client

from uipath.llm_client.clients.normalized.embeddings import Embeddings

embeddings = Embeddings(client_obj)
result = await embeddings.acreate(input="single string")

assert isinstance(result, EmbeddingResponse)
body = mock_client.request.call_args.kwargs["json"]
assert body["input"] == ["single string"]

@pytest.mark.asyncio
async def test_acreate_with_kwargs(self):
mock_response = MagicMock()
mock_response.json.return_value = SAMPLE_EMBEDDING_RESPONSE

mock_client = AsyncMock()
mock_client.request.return_value = mock_response

client_obj = MagicMock()
client_obj._embedding_async_client = mock_client

from uipath.llm_client.clients.normalized.embeddings import Embeddings

embeddings = Embeddings(client_obj)
await embeddings.acreate(input=["hello"], dimensions=256)

body = mock_client.request.call_args.kwargs["json"]
assert body["dimensions"] == 256
Empty file.
100 changes: 100 additions & 0 deletions tests/core/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Core test fixtures shared across all core test modules."""

from unittest.mock import patch

import pytest

from uipath.llm_client.settings import UiPathAPIConfig
from uipath.llm_client.settings.constants import ApiType, RoutingMode
from uipath.llm_client.settings.utils import SingletonMeta


@pytest.fixture(autouse=True)
def clear_singleton_instances():
"""Clear singleton instances before each test to ensure isolation."""
SingletonMeta._instances.clear()
yield
SingletonMeta._instances.clear()


@pytest.fixture
def llmgw_env_vars():
"""Environment variables for LLMGatewaySettings."""
return {
"LLMGW_URL": "https://cloud.uipath.com",
"LLMGW_SEMANTIC_ORG_ID": "test-org-id",
"LLMGW_SEMANTIC_TENANT_ID": "test-tenant-id",
"LLMGW_REQUESTING_PRODUCT": "test-product",
"LLMGW_REQUESTING_FEATURE": "test-feature",
"LLMGW_ACCESS_TOKEN": "test-access-token",
}


@pytest.fixture
def llmgw_s2s_env_vars():
"""Environment variables for LLMGatewaySettings with S2S auth."""
return {
"LLMGW_URL": "https://cloud.uipath.com",
"LLMGW_SEMANTIC_ORG_ID": "test-org-id",
"LLMGW_SEMANTIC_TENANT_ID": "test-tenant-id",
"LLMGW_REQUESTING_PRODUCT": "test-product",
"LLMGW_REQUESTING_FEATURE": "test-feature",
"LLMGW_CLIENT_ID": "test-client-id",
"LLMGW_CLIENT_SECRET": "test-client-secret",
}


@pytest.fixture
def platform_env_vars():
"""Environment variables for PlatformSettings."""
return {
"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",
}


@pytest.fixture
def mock_platform_auth():
"""Patches is_token_expired and 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",
return_value={"client_id": "test-client-id"},
),
):
yield


@pytest.fixture
def passthrough_api_config():
"""API config for passthrough mode."""
return UiPathAPIConfig(
api_type=ApiType.COMPLETIONS,
routing_mode=RoutingMode.PASSTHROUGH,
vendor_type="openai",
)


@pytest.fixture
def normalized_api_config():
"""API config for normalized mode."""
return UiPathAPIConfig(
api_type=ApiType.COMPLETIONS,
routing_mode=RoutingMode.NORMALIZED,
)


@pytest.fixture
def embeddings_api_config():
"""API config for embeddings."""
return UiPathAPIConfig(
api_type=ApiType.EMBEDDINGS,
routing_mode=RoutingMode.PASSTHROUGH,
vendor_type="vertexai",
)
Empty file.
Empty file.
Loading