diff --git a/README.md b/README.md index 147200c1..942213c7 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ The service includes comprehensive user data collection capabilities for various * [Llama Stack project and configuration](#llama-stack-project-and-configuration) * [Check connection to Llama Stack](#check-connection-to-llama-stack) * [Llama Stack as client library](#llama-stack-as-client-library) + * [Llama Stack version check](#llama-stack-version-check) * [User data collection](#user-data-collection) * [System prompt](#system-prompt) * [Safety Shields](#safety-shields) @@ -243,6 +244,12 @@ user_data_collection: transcripts_storage: "/tmp/data/transcripts" ``` +## Llama Stack version check + +During Lightspeed Core Stack service startup, the Llama Stack version is retrieved. The version is tested against two constants `MINIMAL_SUPPORTED_LLAMA_STACK_VERSION` and `MAXIMAL_SUPPORTED_LLAMA_STACK_VERSION` which are defined in `src/constants.py`. If the actual Llama Stack version is outside the range defined by these two constants, the service won't start and administrator will be informed about this problem. + + + ## User data collection The Lightspeed Core Stack includes comprehensive user data collection capabilities to gather various types of user interaction data for analysis and improvement. This includes feedback, conversation transcripts, and other user interaction data. diff --git a/pyproject.toml b/pyproject.toml index c4c99c83..28b64298 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ dependencies = [ "email-validator>=2.2.0", "openai==1.99.9", "sqlalchemy>=2.0.42", + "semver<4.0.0", ] @@ -91,6 +92,7 @@ dev = [ "build>=1.2.2.post1", "twine>=6.1.0", "openapi-to-md>=0.1.0b2", + "pytest-subtests>=0.14.2", ] llslibdev = [ # To check llama-stack API provider dependecies: diff --git a/src/constants.py b/src/constants.py index 595c6924..4a2a4b86 100644 --- a/src/constants.py +++ b/src/constants.py @@ -1,5 +1,9 @@ """Constants used in business logic.""" +# Minimal and maximal supported Llama Stack version +MINIMAL_SUPPORTED_LLAMA_STACK_VERSION = "0.2.17" +MAXIMAL_SUPPORTED_LLAMA_STACK_VERSION = "0.2.17" + UNABLE_TO_PROCESS_RESPONSE = "Unable to process this request" # Supported attachment types diff --git a/src/lightspeed_stack.py b/src/lightspeed_stack.py index cf47c2f9..192b3c7a 100644 --- a/src/lightspeed_stack.py +++ b/src/lightspeed_stack.py @@ -12,6 +12,7 @@ from runners.uvicorn import start_uvicorn from configuration import configuration from client import AsyncLlamaStackClientHolder +from utils.llama_stack_version import check_llama_stack_version FORMAT = "%(message)s" logging.basicConfig( @@ -66,6 +67,8 @@ def main() -> None: asyncio.run( AsyncLlamaStackClientHolder().load(configuration.configuration.llama_stack) ) + client = AsyncLlamaStackClientHolder().get_client() + asyncio.run(check_llama_stack_version(client)) if args.dump_configuration: configuration.configuration.dump() diff --git a/src/utils/llama_stack_version.py b/src/utils/llama_stack_version.py new file mode 100644 index 00000000..691b9064 --- /dev/null +++ b/src/utils/llama_stack_version.py @@ -0,0 +1,51 @@ +"""Check if the Llama Stack version is supported by the LCS.""" + +import logging + +from semver import Version + +from llama_stack_client._client import AsyncLlamaStackClient + + +from constants import ( + MINIMAL_SUPPORTED_LLAMA_STACK_VERSION, + MAXIMAL_SUPPORTED_LLAMA_STACK_VERSION, +) + +logger = logging.getLogger("utils.llama_stack_version") + + +class InvalidLlamaStackVersionException(Exception): + """Llama Stack version is not valid.""" + + +async def check_llama_stack_version( + client: AsyncLlamaStackClient, +) -> None: + """Check if the Llama Stack version is supported by the LCS.""" + version_info = await client.inspect.version() + compare_versions( + version_info.version, + MINIMAL_SUPPORTED_LLAMA_STACK_VERSION, + MAXIMAL_SUPPORTED_LLAMA_STACK_VERSION, + ) + + +def compare_versions(version_info: str, minimal: str, maximal: str) -> None: + """Compare current Llama Stack version with minimal and maximal allowed versions.""" + current_version = Version.parse(version_info) + minimal_version = Version.parse(minimal) + maximal_version = Version.parse(maximal) + logger.debug("Current version: %s", current_version) + logger.debug("Minimal version: %s", minimal_version) + logger.debug("Maximal version: %s", maximal_version) + + if current_version < minimal_version: + raise InvalidLlamaStackVersionException( + f"Llama Stack version >= {minimal_version} is required, but {current_version} is used" + ) + if current_version > maximal_version: + raise InvalidLlamaStackVersionException( + f"Llama Stack version <= {maximal_version} is required, but {current_version} is used" + ) + logger.info("Correct Llama Stack version : %s", current_version) diff --git a/tests/unit/utils/test_llama_stack_version.py b/tests/unit/utils/test_llama_stack_version.py new file mode 100644 index 00000000..a2ca9280 --- /dev/null +++ b/tests/unit/utils/test_llama_stack_version.py @@ -0,0 +1,101 @@ +"""Unit tests for utility function to check Llama Stack version.""" + +import pytest +from semver import Version + +from llama_stack_client.types import VersionInfo + +from utils.llama_stack_version import ( + check_llama_stack_version, + InvalidLlamaStackVersionException, +) + +from constants import ( + MINIMAL_SUPPORTED_LLAMA_STACK_VERSION, + MAXIMAL_SUPPORTED_LLAMA_STACK_VERSION, +) + + +@pytest.mark.asyncio +async def test_check_llama_stack_version_minimal_supported_version(mocker): + """Test the check_llama_stack_version function.""" + + # mock the Llama Stack client + mock_client = mocker.AsyncMock() + mock_client.inspect.version.return_value = VersionInfo( + version=MINIMAL_SUPPORTED_LLAMA_STACK_VERSION + ) + + # test if the version is checked + await check_llama_stack_version(mock_client) + + +@pytest.mark.asyncio +async def test_check_llama_stack_version_maximal_supported_version(mocker): + """Test the check_llama_stack_version function.""" + + # mock the Llama Stack client + mock_client = mocker.AsyncMock() + mock_client.inspect.version.return_value = VersionInfo( + version=MAXIMAL_SUPPORTED_LLAMA_STACK_VERSION + ) + + # test if the version is checked + await check_llama_stack_version(mock_client) + + +@pytest.mark.asyncio +async def test_check_llama_stack_version_too_small_version(mocker): + """Test the check_llama_stack_version function.""" + + # mock the Llama Stack client + mock_client = mocker.AsyncMock() + + # that is surely out of range + mock_client.inspect.version.return_value = VersionInfo(version="0.0.0") + + expected_exception_msg = ( + f"Llama Stack version >= {MINIMAL_SUPPORTED_LLAMA_STACK_VERSION} " + + "is required, but 0.0.0 is used" + ) + # test if the version is checked + with pytest.raises(InvalidLlamaStackVersionException, match=expected_exception_msg): + await check_llama_stack_version(mock_client) + + +async def _check_version_must_fail(mock_client, bigger_version): + mock_client.inspect.version.return_value = VersionInfo(version=str(bigger_version)) + + expected_exception_msg = ( + f"Llama Stack version <= {MAXIMAL_SUPPORTED_LLAMA_STACK_VERSION} is required, " + + f"but {bigger_version} is used" + ) + # test if the version is checked + with pytest.raises(InvalidLlamaStackVersionException, match=expected_exception_msg): + await check_llama_stack_version(mock_client) + + +@pytest.mark.asyncio +async def test_check_llama_stack_version_too_big_version(mocker, subtests): + """Test the check_llama_stack_version function.""" + + # mock the Llama Stack client + mock_client = mocker.AsyncMock() + + max_version = Version.parse(MAXIMAL_SUPPORTED_LLAMA_STACK_VERSION) + + with subtests.test(msg="Increased patch number"): + bigger_version = max_version.bump_patch() + await _check_version_must_fail(mock_client, bigger_version) + + with subtests.test(msg="Increased minor number"): + bigger_version = max_version.bump_minor() + await _check_version_must_fail(mock_client, bigger_version) + + with subtests.test(msg="Increased major number"): + bigger_version = max_version.bump_major() + await _check_version_must_fail(mock_client, bigger_version) + + with subtests.test(msg="Increased all numbers"): + bigger_version = max_version.bump_major().bump_minor().bump_patch() + await _check_version_must_fail(mock_client, bigger_version) diff --git a/uv.lock b/uv.lock index 6f106206..4e906708 100644 --- a/uv.lock +++ b/uv.lock @@ -1255,6 +1255,7 @@ dependencies = [ { name = "openai" }, { name = "prometheus-client" }, { name = "rich" }, + { name = "semver" }, { name = "sqlalchemy" }, { name = "starlette" }, { name = "uvicorn" }, @@ -1279,6 +1280,7 @@ dev = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-mock" }, + { name = "pytest-subtests" }, { name = "ruff" }, { name = "twine" }, { name = "types-cachetools" }, @@ -1330,6 +1332,7 @@ requires-dist = [ { name = "openai", specifier = "==1.99.9" }, { name = "prometheus-client", specifier = ">=0.22.1" }, { name = "rich", specifier = ">=14.0.0" }, + { name = "semver", specifier = "<4.0.0" }, { name = "sqlalchemy", specifier = ">=2.0.42" }, { name = "starlette", specifier = ">=0.47.1" }, { name = "uvicorn", specifier = ">=0.34.3" }, @@ -1354,6 +1357,7 @@ dev = [ { name = "pytest-asyncio", specifier = ">=1.0.0" }, { name = "pytest-cov", specifier = ">=5.0.0" }, { name = "pytest-mock", specifier = ">=3.14.0" }, + { name = "pytest-subtests", specifier = ">=0.14.2" }, { name = "ruff", specifier = ">=0.11.13" }, { name = "twine", specifier = ">=6.1.0" }, { name = "types-cachetools", specifier = ">=6.1.0.20250717" }, @@ -2683,6 +2687,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/05/77b60e520511c53d1c1ca75f1930c7dd8e971d0c4379b7f4b3f9644685ba/pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0", size = 9923, upload-time = "2025-05-26T13:58:43.487Z" }, ] +[[package]] +name = "pytest-subtests" +version = "0.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/30/6ec8dfc678ddfd1c294212bbd7088c52d3f7fbf3f05e6d8a440c13b9741a/pytest_subtests-0.14.2.tar.gz", hash = "sha256:7154a8665fd528ee70a76d00216a44d139dc3c9c83521a0f779f7b0ad4f800de", size = 18083, upload-time = "2025-06-13T10:50:01.636Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/d4/9bf12e59fb882b0cf4f993871e1adbee094802224c429b00861acee1a169/pytest_subtests-0.14.2-py3-none-any.whl", hash = "sha256:8da0787c994ab372a13a0ad7d390533ad2e4385cac167b3ac501258c885d0b66", size = 9115, upload-time = "2025-06-13T10:50:00.543Z" }, +] + [[package]] name = "pythainlp" version = "5.1.2" @@ -3120,6 +3137,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/24/b4293291fa1dd830f353d2cb163295742fa87f179fcc8a20a306a81978b7/SecretStorage-3.3.3-py3-none-any.whl", hash = "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99", size = 15221, upload-time = "2022-08-13T16:22:44.457Z" }, ] +[[package]] +name = "semver" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/d1/d3159231aec234a59dd7d601e9dd9fe96f3afff15efd33c1070019b26132/semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602", size = 269730, upload-time = "2025-01-24T13:19:27.617Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746", size = 17912, upload-time = "2025-01-24T13:19:24.949Z" }, +] + [[package]] name = "setuptools" version = "80.9.0"