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
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*.db filter=lfs diff=lfs merge=lfs -text
**/cached_embeddings/** filter=lfs diff=lfs merge=lfs -text
8 changes: 8 additions & 0 deletions .github/workflows/langchain_ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ defaults:
run:
working-directory: "sdk/langchain"

env:
UIPATH_URL: ${{ secrets.UIPATH_URL }}
UIPATH_CLIENT_ID: ${{ secrets.UIPATH_CLIENT_ID }}
UIPATH_CLIENT_SECRET: ${{ secrets.UIPATH_CLIENT_SECRET }}
UIPATH_ORGANIZATION_ID: ${{ vars.UIPATH_ORGANIZATION_ID }}
UIPATH_TENANT_ID: ${{ vars.UIPATH_TENANT_ID }}


jobs:
lint:
uses: ./.github/workflows/lint.yml
Expand Down
10 changes: 9 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ name: Reusable Test Workflow
on:
workflow_call:
inputs:

folder_path:
description: 'The folder path to run actions in'
required: true
Expand All @@ -17,12 +18,12 @@ jobs:
test:
name: Test
runs-on: "ubuntu-24.04"
environment: PYPY_TOKEN
if: inputs.should_skip == false
defaults:
run:
working-directory: ${{ inputs.folder_path }}


steps:
- uses: actions/checkout@v4

Expand All @@ -39,5 +40,12 @@ jobs:

- name: "Run tests"
run: |
printenv | sort
uv run pytest
env:
UIPATH_URL: ${{ secrets.UIPATH_URL }}
UIPATH_CLIENT_ID: ${{ secrets.UIPATH_CLIENT_ID }}
UIPATH_CLIENT_SECRET: ${{ secrets.UIPATH_CLIENT_SECRET }}
UIPATH_ORGANIZATION_ID: ${{ vars.UIPATH_ORGANIZATION_ID }}
UIPATH_TENANT_ID: ${{ vars.UIPATH_TENANT_ID }}

4 changes: 1 addition & 3 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
".ruff_cache": true,
".venv": true
},

// Formatting
"editor.formatOnSave": true,
"[python]": {
Expand All @@ -18,7 +17,6 @@
"source.organizeImports": "explicit"
}
},

"workbench.colorCustomizations": {
"titleBar.activeBackground": "#0099cc",
"titleBar.inactiveBackground": "#0099cc"
Expand All @@ -28,4 +26,4 @@
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true
}
}
10 changes: 9 additions & 1 deletion sdk/langchain/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,15 @@ dependencies = [
"langgraph>=0.2.70",
"langchain-core>=0.3.34",
"langgraph-checkpoint-sqlite>=2.0.3",
"langchain-community>=0.3.18",
"langchain-openai>=0.3.3",
"langchain>=0.3.4",
"requests>=2.23.3",
"types-requests>=2.32.0.20241016"
"types-requests>=2.32.0.20241016",
"pydantic-settings>=2.6.0",
"python-dotenv>=1.0.1",
"httpx>=0.27.0",
"openai>=1.65.5",
]
classifiers = [
"Development Status :: 3 - Alpha",
Expand Down Expand Up @@ -51,6 +58,7 @@ dev = [
"pytest-cov>=4.1.0",
"pytest-mock>=3.11.1",
"pre-commit>=4.1.0",
"numpy>=1.24.0",
]

[project.optional-dependencies]
Expand Down
76 changes: 76 additions & 0 deletions sdk/langchain/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import logging
import os
from os import environ as env
from typing import Generator, Optional

import httpx
import pytest
from langchain.embeddings import CacheBackedEmbeddings
from langchain.globals import set_llm_cache
from langchain.storage import LocalFileStore
from langchain_community.cache import SQLiteCache

from uipath_langchain.embeddings import UiPathOpenAIEmbeddings
from uipath_langchain.utils._settings import uipath_cached_paths_settings


def get_token():
url_get_token = f"{env.get('UIPATH_URL', '').rstrip('/')}/identity_/connect/token"

token_credentials = {
"client_id": env.get("UIPATH_CLIENT_ID"),
"client_secret": env.get("UIPATH_CLIENT_SECRET"),
"grant_type": "client_credentials",
}

try:
with httpx.Client() as client:
response = client.post(url_get_token, data=token_credentials)
response.raise_for_status()
res_json = response.json()
token = res_json.get("access_token")

if not token:
pytest.skip("Authentication token is empty or missing")
except (httpx.HTTPError, ValueError, KeyError) as e:
pytest.skip(f"Failed to obtain authentication token: {str(e)}")

return token


@pytest.fixture(autouse=True)
def setup_test_env():
env["UIPATH_ACCESS_TOKEN"] = get_token()


@pytest.fixture(scope="session")
def cached_llmgw_calls() -> Generator[Optional[SQLiteCache], None, None]:
if not os.environ.get("UIPATH_TESTS_CACHE_LLMGW"):
yield None
else:
logging.info("Setting up LLMGW cache")
db_path = uipath_cached_paths_settings.cached_completion_db
os.makedirs(os.path.dirname(db_path), exist_ok=True)
cache = SQLiteCache(database_path=db_path)
set_llm_cache(cache)
yield cache
set_llm_cache(None)
return


@pytest.fixture(scope="session")
def cached_embedder() -> Generator[Optional[CacheBackedEmbeddings], None, None]:
if not os.environ.get("UIPATH_TESTS_CACHE_LLMGW"):
yield None
else:
logging.info("Setting up embeddings cache")
model = "text-embedding-3-large"
embedder = CacheBackedEmbeddings.from_bytes_store(
underlying_embeddings=UiPathOpenAIEmbeddings(model=model),
document_embedding_cache=LocalFileStore(
uipath_cached_paths_settings.cached_embeddings_dir
),
namespace=model,
)
yield embedder
return
152 changes: 152 additions & 0 deletions sdk/langchain/tests/test_langchain_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
from typing import Optional

import numpy as np
import pytest
from langchain_community.cache import SQLiteCache
from langchain_community.callbacks import get_openai_callback
from langchain_core.embeddings import Embeddings
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_core.tools import tool
from pydantic import BaseModel, ValidationError

from uipath_langchain.chat import (
UiPathAzureChatOpenAI,
UiPathNormalizedChatModel,
)
from uipath_langchain.embeddings import (
UiPathAzureOpenAIEmbeddings,
UiPathOpenAIEmbeddings,
)


def test_cached_call(cached_llmgw_calls: Optional[SQLiteCache]):
model = UiPathAzureChatOpenAI(cache=cached_llmgw_calls)
messages = [
SystemMessage(content="How much is 2 + 2?"),
HumanMessage(content="Respond with JUST THE ANSWER."),
]
response = model.invoke(messages)
assert "4" in response.content, f"Expected '4' in response, got: {response.content}"


def test_cached_call_tokens(cached_llmgw_calls: Optional[SQLiteCache]):
model = UiPathAzureChatOpenAI(cache=cached_llmgw_calls)
messages = [
SystemMessage(content="How much is 2 + 2?"),
HumanMessage(content="Respond with JUST THE ANSWER."),
]
with get_openai_callback() as cb:
_ = model.invoke(messages)
total_tokens = cb.total_tokens
assert total_tokens >= 20, f"Expected more than 20 tokens, got: {total_tokens}"


def test_normalized_cached_call(cached_llmgw_calls: Optional[SQLiteCache]):
model = UiPathNormalizedChatModel(
model="anthropic.claude-3-5-sonnet-20240620-v1:0", cache=cached_llmgw_calls
)
messages = [
SystemMessage(content="How much is 2 + 2?"),
HumanMessage(content="Respond with JUST THE ANSWER."),
]
response = model.invoke(messages)
assert "4" in response.content, f"Expected '4' in response, got: {response.content}"


def test_normalized_cached_call_tokens(cached_llmgw_calls: Optional[SQLiteCache]):
model = UiPathNormalizedChatModel(
model="anthropic.claude-3-5-sonnet-20240620-v1:0", cache=cached_llmgw_calls
)
messages = [
SystemMessage(content="How much is 2 + 2?"),
HumanMessage(content="Respond with JUST THE ANSWER."),
]
with get_openai_callback() as cb:
_ = model.invoke(messages)
total_tokens = cb.total_tokens
assert total_tokens >= 20, f"Expected more than 20 tokens, got: {total_tokens}"


def test_tool_call(cached_llmgw_calls: Optional[SQLiteCache]):
@tool
def get_first_letter(input):
"""
Returns the first letter of the input
"""
return input[0]

model = UiPathAzureChatOpenAI(cache=cached_llmgw_calls).bind_tools(
[get_first_letter]
)
messages = [
SystemMessage(content="What is the first letter of the word 'apple'?"),
]
response = model.invoke(messages)
assert hasattr(response, "tool_calls"), (
"The response should have a 'tool_calls' attribute"
)
tool_call = response.tool_calls[0]
assert tool_call["name"] == "get_first_letter", (
f"Expected tool call to 'get_first_letter', got: {tool_call['name']}"
)
assert tool_call["args"].get("input") == "apple", (
f"Expected input to be 'apple', got: {tool_call['args'].get('input')}"
)


def test_structured_output_call(cached_llmgw_calls: Optional[SQLiteCache]):
class CitySize(BaseModel):
"""City and its size."""

city: str
size: int

model = UiPathAzureChatOpenAI(cache=cached_llmgw_calls).with_structured_output(
CitySize
)
messages = [
SystemMessage(
content="What is the capital of France and what is its area in square meters?"
),
]
response = model.invoke(messages)
try:
city_size = CitySize.model_validate(response)
assert city_size.city == "Paris", (
f"Expected city to be 'Paris', got: {city_size.city}"
)
assert city_size.size > 0, (
f"Expected size to be greater than 0, got: {city_size.size}"
)
except ValidationError as exc:
pytest.fail(f"The response was not in the correct format: {exc}")


def test_embedding_call(cached_embedder: Optional[Embeddings]):
if not cached_embedder:
cached_embedder = UiPathOpenAIEmbeddings(model="text-embedding-3-large")
data = [
"Test input pneumonoultramicroscopicsilicovolcanoconiosis",
"Another test input",
]
embeds = cached_embedder.embed_documents(data)
try:
arr = np.array(embeds)
except Exception as exc:
pytest.fail(f"Failed to convert embeddings to numpy array: {exc}")
assert arr.shape == (2, 3072), f"Expected shape (2, 3072), got: {arr.shape}"


def test_custom_embedding_call(cached_embedder: Optional[Embeddings]):
if not cached_embedder:
cached_embedder = UiPathAzureOpenAIEmbeddings(model="text-embedding-3-large")
data = [
"Test input pneumonoultramicroscopicsilicovolcanoconiosis",
"Another test input",
]
embeds = cached_embedder.embed_documents(data)
try:
arr = np.array(embeds)
except Exception as exc:
pytest.fail(f"Failed to convert embeddings to numpy array: {exc}")
assert arr.shape == (2, 3072), f"Expected shape (2, 3072), got: {arr.shape}"
6 changes: 6 additions & 0 deletions sdk/langchain/uipath_langchain/chat/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from .models import UiPathAzureChatOpenAI, UiPathNormalizedChatModel

__all__ = [
"UiPathNormalizedChatModel",
"UiPathAzureChatOpenAI",
]
Loading