From 7526adb09863076155759b290c99ab6fd045e749 Mon Sep 17 00:00:00 2001 From: Andrej Simurka Date: Mon, 27 Oct 2025 15:52:57 +0100 Subject: [PATCH 1/6] LCORE-862: Added config section for Entra ID --- examples/azure-run.yaml | 5 +- examples/lightspeed-stack-azure-entraid.yaml | 30 ++++++++ src/configuration.py | 8 +++ src/models/config.py | 9 +++ tests/e2e/configs/run-azure.yaml | 5 +- .../lightspeed-stack-azure-entraid.yaml | 30 ++++++++ .../models/config/test_dump_configuration.py | 4 ++ tests/unit/test_configuration.py | 69 +++++++++++++++++++ 8 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 examples/lightspeed-stack-azure-entraid.yaml create mode 100644 tests/e2e/configuration/lightspeed-stack-azure-entraid.yaml diff --git a/examples/azure-run.yaml b/examples/azure-run.yaml index a50301add..5dbe4d870 100644 --- a/examples/azure-run.yaml +++ b/examples/azure-run.yaml @@ -72,10 +72,9 @@ providers: - provider_id: azure provider_type: remote::azure config: - api_key: ${env.AZURE_API_KEY} + api_key: ${env.AZURE_API_KEY:=} api_base: https://ols-test.openai.azure.com/ - api_version: 2024-02-15-preview - api_type: ${env.AZURE_API_TYPE:=} + api_version: 2025-01-01-preview post_training: - provider_id: huggingface provider_type: inline::huggingface-gpu diff --git a/examples/lightspeed-stack-azure-entraid.yaml b/examples/lightspeed-stack-azure-entraid.yaml new file mode 100644 index 000000000..fcbbc1218 --- /dev/null +++ b/examples/lightspeed-stack-azure-entraid.yaml @@ -0,0 +1,30 @@ +name: Lightspeed Core Service (LCS) +service: + host: 0.0.0.0 + port: 8080 + auth_enabled: false + workers: 1 + color_log: true + access_log: true +llama_stack: + # Uses a remote llama-stack service + # The instance would have already been started with a llama-stack-run.yaml file + use_as_library_client: false + # Alternative for "as library use" + # use_as_library_client: true + # library_client_config_path: + url: http://localhost:8321 + api_key: xyzzy +user_data_collection: + feedback_enabled: true + feedback_storage: "/tmp/data/feedback" + transcripts_enabled: true + transcripts_storage: "/tmp/data/transcripts" + +authentication: + module: "noop" + +azure_entra_id: + tenant_id: ${env.TENANT_ID} + client_id: ${env.CLIENT_ID} + client_secret: ${env.CLIENT_SECRET} diff --git a/src/configuration.py b/src/configuration.py index d2b2389df..a382a41a0 100644 --- a/src/configuration.py +++ b/src/configuration.py @@ -10,6 +10,7 @@ import yaml from models.config import ( AuthorizationConfiguration, + AzureEntraIdConfiguration, Configuration, Customization, LlamaStackConfiguration, @@ -180,5 +181,12 @@ def quota_limiters(self) -> list[QuotaLimiter]: ) return self._quota_limiters + @property + def azure_entra_id(self) -> Optional[AzureEntraIdConfiguration]: + """Return Azure Entra ID configuration, or None if not provided.""" + if self._configuration is None: + raise LogicError("logic error: configuration is not loaded") + return self._configuration.azure_entra_id + configuration: AppConfig = AppConfig() diff --git a/src/models/config.py b/src/models/config.py index c2542dad9..ab250ac50 100644 --- a/src/models/config.py +++ b/src/models/config.py @@ -593,6 +593,14 @@ class QuotaHandlersConfiguration(ConfigurationBase): enable_token_history: bool = False +class AzureEntraIdConfiguration(ConfigurationBase): + """Microsoft Entra ID authentication attributes for Azure.""" + + tenant_id: SecretStr + client_id: SecretStr + client_secret: SecretStr + + class Configuration(ConfigurationBase): """Global service configuration.""" @@ -615,6 +623,7 @@ class Configuration(ConfigurationBase): quota_handlers: QuotaHandlersConfiguration = Field( default_factory=QuotaHandlersConfiguration ) + azure_entra_id: Optional[AzureEntraIdConfiguration] = None def dump(self, filename: str = "configuration.json") -> None: """Dump actual configuration into JSON file.""" diff --git a/tests/e2e/configs/run-azure.yaml b/tests/e2e/configs/run-azure.yaml index fd8a8c79d..8f87b54d9 100644 --- a/tests/e2e/configs/run-azure.yaml +++ b/tests/e2e/configs/run-azure.yaml @@ -72,10 +72,9 @@ providers: - provider_id: azure provider_type: remote::azure config: - api_key: ${env.AZURE_API_KEY} + api_key: ${env.AZURE_API_KEY:=} api_base: https://ols-test.openai.azure.com/ - api_version: 2024-02-15-preview - api_type: ${env.AZURE_API_TYPE:=} + api_version: 2025-01-01-preview post_training: - provider_id: huggingface provider_type: inline::huggingface-gpu diff --git a/tests/e2e/configuration/lightspeed-stack-azure-entraid.yaml b/tests/e2e/configuration/lightspeed-stack-azure-entraid.yaml new file mode 100644 index 000000000..c9f4d1721 --- /dev/null +++ b/tests/e2e/configuration/lightspeed-stack-azure-entraid.yaml @@ -0,0 +1,30 @@ +name: Lightspeed Core Service (LCS) +service: + host: 0.0.0.0 + port: 8080 + auth_enabled: false + workers: 1 + color_log: true + access_log: true +llama_stack: + # Uses a remote llama-stack service + # The instance would have already been started with a llama-stack-run.yaml file + use_as_library_client: false + # Alternative for "as library use" + # use_as_library_client: true + # library_client_config_path: + url: http://llama-stack:8321 + api_key: xyzzy +user_data_collection: + feedback_enabled: true + feedback_storage: "/tmp/data/feedback" + transcripts_enabled: true + transcripts_storage: "/tmp/data/transcripts" + +authentication: + module: "noop" + +azure_entra_id: + tenant_id: ${env.TENANT_ID} + client_id: ${env.CLIENT_ID} + client_secret: ${env.CLIENT_SECRET} diff --git a/tests/unit/models/config/test_dump_configuration.py b/tests/unit/models/config/test_dump_configuration.py index e2c0d31fb..b70432990 100644 --- a/tests/unit/models/config/test_dump_configuration.py +++ b/tests/unit/models/config/test_dump_configuration.py @@ -94,6 +94,7 @@ def test_dump_configuration(tmp_path: Path) -> None: assert "database" in content assert "byok_rag" in content assert "quota_handlers" in content + assert "azure_entra_id" in content # check the whole deserialized JSON file content assert content == { @@ -183,6 +184,7 @@ def test_dump_configuration(tmp_path: Path) -> None: "scheduler": {"period": 1}, "enable_token_history": False, }, + "azure_entra_id": None, } @@ -392,6 +394,7 @@ def test_dump_configuration_with_quota_limiters(tmp_path: Path) -> None: assert "database" in content assert "byok_rag" in content assert "quota_handlers" in content + assert "azure_entra_id" in content # check the whole deserialized JSON file content assert content == { @@ -496,4 +499,5 @@ def test_dump_configuration_with_quota_limiters(tmp_path: Path) -> None: "scheduler": {"period": 10}, "enable_token_history": True, }, + "azure_entra_id": None, } diff --git a/tests/unit/test_configuration.py b/tests/unit/test_configuration.py index b2083a03b..d231e853e 100644 --- a/tests/unit/test_configuration.py +++ b/tests/unit/test_configuration.py @@ -2,6 +2,7 @@ from pathlib import Path from typing import Any, Generator +from pydantic import ValidationError import pytest from configuration import AppConfig, LogicError @@ -806,3 +807,71 @@ def test_configuration_with_quota_handlers(tmpdir: Path) -> None: # check the scheduler configuration assert cfg.quota_handlers_configuration.scheduler.period == 1 + + +def test_load_configuration_with_azure_entra_id(tmpdir: Path) -> None: + """Return Azure Entra ID configuration when provided in configuration.""" + cfg_filename = tmpdir / "config.yaml" + with open(cfg_filename, "w", encoding="utf-8") as fout: + fout.write( + """ +name: test service +service: + host: localhost + port: 8080 + auth_enabled: false + workers: 1 + color_log: true + access_log: true +llama_stack: + api_key: test-key + url: http://localhost:8321 + use_as_library_client: false +user_data_collection: + feedback_enabled: false +azure_entra_id: + tenant_id: tenant + client_id: client + client_secret: secret + """ + ) + + cfg = AppConfig() + cfg.load_configuration(str(cfg_filename)) + + azure_conf = cfg.azure_entra_id + assert azure_conf is not None + assert azure_conf.tenant_id.get_secret_value() == "tenant" + assert azure_conf.client_id.get_secret_value() == "client" + assert azure_conf.client_secret.get_secret_value() == "secret" + + +def test_load_configuration_with_incomplete_azure_entra_id_raises(tmpdir: Path) -> None: + """Raise error if Azure Entra ID block is incomplete in configuration.""" + cfg_filename = tmpdir / "config.yaml" + with open(cfg_filename, "w", encoding="utf-8") as fout: + fout.write( + """ +name: test service +service: + host: localhost + port: 8080 + auth_enabled: false + workers: 1 + color_log: true + access_log: true +llama_stack: + api_key: test-key + url: http://localhost:8321 + use_as_library_client: false +user_data_collection: + feedback_enabled: false +azure_entra_id: + tenant_id: tenant + client_id: client + """ + ) + + cfg = AppConfig() + with pytest.raises(ValidationError): + cfg.load_configuration(str(cfg_filename)) From d156397a93201c56e1f6b276a5ad39920d4d6404 Mon Sep 17 00:00:00 2001 From: Andrej Simurka Date: Wed, 5 Nov 2025 14:04:28 +0100 Subject: [PATCH 2/6] LCORE-863: Implemented Entra ID Auth Module --- pyproject.toml | 2 + src/authorization/azure_token_manager.py | 93 ++++++++++++ src/lightspeed_stack.py | 3 + .../authorization/test_azure_token_manager.py | 135 ++++++++++++++++++ uv.lock | 59 ++++++++ 5 files changed, 292 insertions(+) create mode 100644 src/authorization/azure_token_manager.py create mode 100644 tests/unit/authorization/test_azure_token_manager.py diff --git a/pyproject.toml b/pyproject.toml index 079b4f12a..da155d605 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -159,6 +159,8 @@ llslibdev = [ "opentelemetry-instrumentation>=0.55b0", "blobfile>=3.0.0", "psutil>=7.0.0", + "azure-core", + "azure-identity", ] build = [ diff --git a/src/authorization/azure_token_manager.py b/src/authorization/azure_token_manager.py new file mode 100644 index 000000000..85b547275 --- /dev/null +++ b/src/authorization/azure_token_manager.py @@ -0,0 +1,93 @@ +"""Authentication module for Azure Entra ID Credentials.""" + +import logging +import time +from typing import Optional + +from azure.core.credentials import AccessToken +from azure.core.exceptions import ClientAuthenticationError +from azure.identity import ClientSecretCredential +from utils.types import Singleton +from configuration import AzureEntraIdConfiguration + +logger = logging.getLogger(__name__) + +TOKEN_EXPIRATION_LEEWAY = 30 # seconds + + +class AzureEntraIDTokenManager(metaclass=Singleton): + """Microsoft Token cache for Azure OpenAI provider. + + Manages and caches Microsoft Entra ID access tokens for the Azure OpenAI provider. + Handles token storage, expiration checks, and refreshing tokens when an Entra ID + configuration is provided. Designed for temporary tokens passed via request headers. + """ + + def __init__(self) -> None: + """Construct Azure token manager.""" + self._access_token: Optional[str] = None + self._expires_on: int = 0 + self._entra_id_config = None + + def set_config(self, azure_config: AzureEntraIdConfiguration) -> None: + """Set the Azure Entra ID configuration.""" + self._entra_id_config = azure_config + + @property + def is_entra_id_configured(self) -> bool: + """Check whether an Entra ID configuration has been set.""" + return self._entra_id_config is not None + + @property + def is_token_expired(self) -> bool: + """Check if the current token has expired (observer only).""" + return self._expires_on == 0 or time.time() > self._expires_on + + @property + def access_token(self) -> str: + """Return the currently cached access token (no refresh logic).""" + if not self._access_token: + logger.debug("Access token requested but not yet available.") + return self._access_token or "" + + def refresh_token(self) -> None: + """Refresh and cache a new Azure access token if configuration is set.""" + if self._entra_id_config is None: + raise ValueError("Azure configuration is not set for token retrieval") + + logger.info("Refreshing Azure access token...") + token_obj = self._retrieve_access_token() + if token_obj: + self._update_access_token(token_obj.token, token_obj.expires_on) + logger.info("Azure access token successfully refreshed.") + else: + raise RuntimeError("Failed to retrieve Azure access token") + + def _update_access_token(self, token: str, expires_on: int) -> None: + """Update token and expiration time (private helper).""" + self._access_token = token + self._expires_on = expires_on - TOKEN_EXPIRATION_LEEWAY + logger.info( + "Access token updated; expires at %s", + time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(self._expires_on)), + ) + + def _retrieve_access_token(self) -> Optional[AccessToken]: + """Retrieve access token to call Azure OpenAI.""" + if not self._entra_id_config: + return None + tenant_id = self._entra_id_config.tenant_id.get_secret_value() + client_id = self._entra_id_config.client_id.get_secret_value() + client_secret = self._entra_id_config.client_secret.get_secret_value() + + try: + credential = ClientSecretCredential( + tenant_id=tenant_id, + client_id=client_id, + client_secret=client_secret, + ) + token = credential.get_token("https://cognitiveservices.azure.com/.default") + return token + except ClientAuthenticationError as e: + logger.error("Error retrieving access token: %s", e, exc_info=True) + return None diff --git a/src/lightspeed_stack.py b/src/lightspeed_stack.py index 5cdb908e8..2f2359b7c 100644 --- a/src/lightspeed_stack.py +++ b/src/lightspeed_stack.py @@ -12,6 +12,7 @@ from log import get_logger from configuration import configuration +from authorization.azure_token_manager import AzureEntraIDTokenManager from llama_stack_configuration import generate_configuration from runners.uvicorn import start_uvicorn from runners.quota_scheduler import start_quota_scheduler @@ -123,6 +124,8 @@ def main() -> None: # start the runners start_quota_scheduler(configuration.configuration) + if configuration.azure_entra_id: + AzureEntraIDTokenManager().set_config(configuration.azure_entra_id) # if every previous steps don't fail, start the service on specified port start_uvicorn(configuration.service_configuration) logger.info("Lightspeed Core Stack finished") diff --git a/tests/unit/authorization/test_azure_token_manager.py b/tests/unit/authorization/test_azure_token_manager.py new file mode 100644 index 000000000..388486160 --- /dev/null +++ b/tests/unit/authorization/test_azure_token_manager.py @@ -0,0 +1,135 @@ +"""Unit test for Authentication with Azure Entra ID Credentials.""" + +# pylint: disable=protected-access + +import time +import pytest +from pytest_mock import MockerFixture +from azure.core.credentials import AccessToken +from pydantic import SecretStr + +from authorization.azure_token_manager import ( + AzureEntraIDTokenManager, + TOKEN_EXPIRATION_LEEWAY, +) +from configuration import AzureEntraIdConfiguration + + +@pytest.fixture(name="dummy_config") +def dummy_config_fixture() -> AzureEntraIdConfiguration: + """Return a dummy AzureEntraIdConfiguration for testing.""" + return AzureEntraIdConfiguration( + tenant_id=SecretStr("tenant"), + client_id=SecretStr("client"), + client_secret=SecretStr("secret"), + ) + + +@pytest.fixture(autouse=True) +def reset_singleton(): + """Reset the singleton instance before each test.""" + AzureEntraIDTokenManager._instances = {} + + +@pytest.fixture(name="token_manager") +def token_manager_fixture() -> AzureEntraIDTokenManager: + """Return a fresh AzureEntraIDTokenManager instance.""" + return AzureEntraIDTokenManager() + + +class TestAzureEntraIDTokenManager: + """Unit tests for AzureEntraIDTokenManager.""" + + def test_singleton_behavior(self, token_manager): + """Verify the singleton returns the same instance.""" + manager2 = AzureEntraIDTokenManager() + assert token_manager is manager2 + + def test_initial_state(self, token_manager): + """Check the initial token manager state.""" + assert token_manager.access_token == "" + assert token_manager.is_token_expired + assert not token_manager.is_entra_id_configured + + def test_set_config(self, token_manager, dummy_config): + """Set the Azure configuration on the token manager.""" + token_manager.set_config(dummy_config) + assert token_manager.is_entra_id_configured + + def test_token_expiration_logic(self, token_manager): + """Verify token expiration logic works correctly.""" + token_manager._expires_on = int(time.time()) + 100 + assert not token_manager.is_token_expired + + token_manager._expires_on = 0 + assert token_manager.is_token_expired + + @pytest.mark.asyncio + async def test_refresh_token_raises_without_config(self, token_manager): + """Raise ValueError when refresh_token is called without config.""" + with pytest.raises(ValueError, match="Azure configuration is not set"): + token_manager.refresh_token() + + def test_update_access_token_sets_token_and_expiration(self, token_manager): + """Update the token and its expiration in the token manager.""" + expires_on = int(time.time()) + 3600 + token_manager._update_access_token("test-token", expires_on) + assert token_manager.access_token == "test-token" + assert token_manager._expires_on == expires_on - TOKEN_EXPIRATION_LEEWAY + + @pytest.mark.asyncio + async def test_refresh_token_success( + self, token_manager, dummy_config, mocker: MockerFixture + ): + """Refresh the token successfully using the Azure credential mock.""" + token_manager.set_config(dummy_config) + dummy_access_token = AccessToken("token_value", int(time.time()) + 3600) + + mock_credential_instance = mocker.Mock() + mock_credential_instance.get_token.return_value = dummy_access_token + + mocker.patch( + "authorization.azure_token_manager.ClientSecretCredential", + return_value=mock_credential_instance, + ) + + token_manager.refresh_token() + + assert token_manager.access_token == "token_value" + assert not token_manager.is_token_expired + mock_credential_instance.get_token.assert_called_once_with( + "https://cognitiveservices.azure.com/.default" + ) + + @pytest.mark.asyncio + async def test_refresh_token_failure_raises_runtime_error( + self, token_manager, dummy_config, mocker: MockerFixture, caplog + ): + """Raise RuntimeError when token retrieval fails.""" + token_manager.set_config(dummy_config) + mock_credential_instance = mocker.Mock() + mock_credential_instance.get_token.side_effect = Exception("fail") + mocker.patch( + "authorization.azure_token_manager.ClientSecretCredential", + return_value=mock_credential_instance, + ) + + with caplog.at_level("ERROR"): + with pytest.raises( + RuntimeError, match="Failed to retrieve Azure access token" + ): + token_manager.refresh_token() + assert "Error retrieving access token" in caplog.text + + def test_token_expired_property_dynamic(self, token_manager, mocker: MockerFixture): + """Simulate time passage to test token expiration property.""" + now = 1000000 + token_manager._expires_on = now + 10 + + mocker.patch("authorization.azure_token_manager.time.time", return_value=now) + assert not token_manager.is_token_expired + + mocker.patch( + "authorization.azure_token_manager.time.time", return_value=now + 20 + ) + assert token_manager.is_token_expired diff --git a/uv.lock b/uv.lock index afe6ab2f4..ce7244b2d 100644 --- a/uv.lock +++ b/uv.lock @@ -213,6 +213,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/a0/f59dd73e8582c59672cf1f4e5f3ec60d1ee312f8f2a56ae54af5293173c7/autoevals-0.0.130-py3-none-any.whl", hash = "sha256:ffb7b3a21070d2a4e593bb118180c04e43531e608bffd854624377bd857ceec0", size = 56034, upload-time = "2025-09-08T05:29:59.908Z" }, ] +[[package]] +name = "azure-core" +version = "1.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0a/c4/d4ff3bc3ddf155156460bff340bbe9533f99fac54ddea165f35a8619f162/azure_core-1.36.0.tar.gz", hash = "sha256:22e5605e6d0bf1d229726af56d9e92bc37b6e726b141a18be0b4d424131741b7", size = 351139, upload-time = "2025-10-15T00:33:49.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/3c/b90d5afc2e47c4a45f4bba00f9c3193b0417fad5ad3bb07869f9d12832aa/azure_core-1.36.0-py3-none-any.whl", hash = "sha256:fee9923a3a753e94a259563429f3644aaf05c486d45b1215d098115102d91d3b", size = 213302, upload-time = "2025-10-15T00:33:51.058Z" }, +] + +[[package]] +name = "azure-identity" +version = "1.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "cryptography" }, + { name = "msal" }, + { name = "msal-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/8d/1a6c41c28a37eab26dc85ab6c86992c700cd3f4a597d9ed174b0e9c69489/azure_identity-1.25.1.tar.gz", hash = "sha256:87ca8328883de6036443e1c37b40e8dc8fb74898240f61071e09d2e369361456", size = 279826, upload-time = "2025-10-06T20:30:02.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/7b/5652771e24fff12da9dde4c20ecf4682e606b104f26419d139758cc935a6/azure_identity-1.25.1-py3-none-any.whl", hash = "sha256:e9edd720af03dff020223cd269fa3a61e8f345ea75443858273bcb44844ab651", size = 191317, upload-time = "2025-10-06T20:30:04.251Z" }, +] + [[package]] name = "bandit" version = "1.8.6" @@ -1389,6 +1418,8 @@ dev = [ llslibdev = [ { name = "aiosqlite" }, { name = "autoevals" }, + { name = "azure-core" }, + { name = "azure-identity" }, { name = "blobfile" }, { name = "datasets" }, { name = "emoji" }, @@ -1470,6 +1501,8 @@ dev = [ llslibdev = [ { name = "aiosqlite", specifier = ">=0.21.0" }, { name = "autoevals", specifier = ">=0.0.129" }, + { name = "azure-core" }, + { name = "azure-identity" }, { name = "blobfile", specifier = ">=3.0.0" }, { name = "datasets", specifier = ">=3.6.0" }, { name = "emoji", specifier = ">=2.1.0" }, @@ -1780,6 +1813,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, ] +[[package]] +name = "msal" +version = "1.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/0e/c857c46d653e104019a84f22d4494f2119b4fe9f896c92b4b864b3b045cc/msal-1.34.0.tar.gz", hash = "sha256:76ba83b716ea5a6d75b0279c0ac353a0e05b820ca1f6682c0eb7f45190c43c2f", size = 153961, upload-time = "2025-09-22T23:05:48.989Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/dc/18d48843499e278538890dc709e9ee3dea8375f8be8e82682851df1b48b5/msal-1.34.0-py3-none-any.whl", hash = "sha256:f669b1644e4950115da7a176441b0e13ec2975c29528d8b9e81316023676d6e1", size = 116987, upload-time = "2025-09-22T23:05:47.294Z" }, +] + +[[package]] +name = "msal-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/99/5d239b6156eddf761a636bded1118414d161bd6b7b37a9335549ed159396/msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4", size = 23315, upload-time = "2025-03-14T23:51:03.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583, upload-time = "2025-03-14T23:51:03.016Z" }, +] + [[package]] name = "multidict" version = "6.7.0" From 1278073619309e1379359764b6256e8cf3358df2 Mon Sep 17 00:00:00 2001 From: Andrej Simurka Date: Wed, 5 Nov 2025 14:07:48 +0100 Subject: [PATCH 3/6] LCORE-867: Created doc for Azure Entra ID Authentication --- docs/providers.md | 43 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/docs/providers.md b/docs/providers.md index 6a4f32d6c..bf8d9d7c0 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -36,7 +36,7 @@ The tables below summarize each provider category, containing the following atri | meta-reference | inline | `accelerate`, `fairscale`, `torch`, `torchvision`, `transformers`, `zmq`, `lm-format-enforcer`, `sentence-transformers`, `torchao==0.8.0`, `fbgemm-gpu-genai==1.1.2` | ❌ | | sentence-transformers | inline | `torch torchvision torchao>=0.12.0 --extra-index-url https://download.pytorch.org/whl/cpu`, `sentence-transformers --no-deps` | ❌ | | anthropic | remote | `litellm` | ❌ | -| azure | remote | — | ✅ | +| azure | remote | `litellm` | ✅ | | bedrock | remote | `boto3` | ❌ | | cerebras | remote | `cerebras_cloud_sdk` | ❌ | | databricks | remote | — | ❌ | @@ -64,6 +64,47 @@ Red Hat providers: | RHAIIS (vllm) | 3.2.3 (on RHEL 9.20250429.0.4) | remote | `openai` | ✅ | | RHEL AI (vllm) | 1.5.2 | remote | `openai` | ✅ | +### Azure Provider - Entra ID Authentication Guide + +Lightspeed Core supports secure authentication using Microsoft Entra ID (formerly Azure Active Directory) for the Azure Inference Provider. This allows you to connect to Azure OpenAI without using API keys, by authenticating through your organization’s Azure identity. + +#### Lightspeed Core configuration requirements + +To enable Entra ID authentication, the `azure_entra_id` block must be included in your LCS configuration, and all three attributes — `tenant_id`, `client_id`, and `client_secret` — are required. The authentication will not work if any of them is missing: + +```yaml +azure_entra_id: + tenant_id: ${env.AZURE_TENANT_ID} + client_id: ${env.AZURE_CLIENT_ID} + client_secret: ${env.AZURE_CLIENT_SECRET} +``` +**Note:** We strongly recommend to load the secrets from environment variables or secrets. + +#### Llama Stack Configuration Requirements + +Because Lightspeed builds on top of Llama Stack, certain configuration fields are required to satisfy the base Llama Stack schema — even though they are not used when Entra ID authentication is enabled. Specifically, the config block for the Azure provider must include `api_key`, `api_base`, and `api_version`. + +While `api_key` is not used in Entra ID mode, it must still be set to a dummy value because Llama Stack validates its presence. The `api_base` and `api_version` fields remain required and are used in Entra ID authentication. + +```yaml +inference: + - provider_id: azure + provider_type: remote::azure + config: + api_key: ${AZURE_API_KEY:=} # Required but not used for Entra ID + api_base: ${AZURE_API_BASE} + api_version: 2025-01-01-preview +``` +**Note:** Llama Stack currently supports only static API key authentication through the LiteLLM SDK. Lightspeed extends this behavior by dynamically injecting Entra ID access tokens into each request, enabling full compatibility while maintaining schema compliance with the Llama Stack configuration. + +#### Access Token Lifecycle and Managment +When the service starts or an inference request is made: +1. The system reads your Entra ID configuration. +1. It checks whether a valid access token already exists: + - If the token does not exist or the current token has expired, the system automatically requests a new token from Microsoft Entra ID using your credentials. + - If a valid token is still active, it is reused — no new request is made. +1. The access token grants access to Azure OpenAI Services. +1. Tokens are automatically refreshed as needed before they expire. Access tokens are typically valid for 1 hour, and this process happens entirely in the background without any manual action. --- From 55809acfa574bb007f93b90584993564df24ca4b Mon Sep 17 00:00:00 2001 From: Andrej Simurka Date: Wed, 5 Nov 2025 14:09:46 +0100 Subject: [PATCH 4/6] LCORE-864: Integrated access token handling into inference --- src/app/endpoints/query.py | 26 +++++++++++- src/app/endpoints/streaming_query.py | 25 +++++++++++- src/client.py | 46 +++++++++++++++++++++ tests/unit/test_client.py | 60 +++++++++++++++++++++++++++- 4 files changed, 154 insertions(+), 3 deletions(-) diff --git a/src/app/endpoints/query.py b/src/app/endpoints/query.py index 22537c930..59aa748bb 100644 --- a/src/app/endpoints/query.py +++ b/src/app/endpoints/query.py @@ -62,6 +62,8 @@ from utils.transcripts import store_transcript from utils.types import TurnSummary from utils.token_counter import extract_and_update_token_metrics, TokenCounter +from authorization.azure_token_manager import AzureEntraIDTokenManager + logger = logging.getLogger("app.endpoints.handlers") router = APIRouter(tags=["query"]) @@ -287,13 +289,35 @@ async def query_endpoint_handler_base( # pylint: disable=R0914 try: check_tokens_available(configuration.quota_limiters, user_id) # try to get Llama Stack client - client = AsyncLlamaStackClientHolder().get_client() + client_holder = AsyncLlamaStackClientHolder() + client = client_holder.get_client() llama_stack_model_id, model_id, provider_id = select_model_and_provider_id( await client.models.list(), *evaluate_model_hints( user_conversation=user_conversation, query_request=query_request ), ) + + azure_token_manager = AzureEntraIDTokenManager() + if ( + provider_id == "azure" + and azure_token_manager.is_entra_id_configured + and azure_token_manager.is_token_expired + ): + azure_token_manager.refresh_token() + azure_config = next( + p.config + for p in await client.providers.list() + if p.provider_type == "remote::azure" + ) + + client = client_holder.get_client_with_updated_azure_headers( + access_token=azure_token_manager.access_token, + api_base=str(azure_config.get("api_base")), + api_version=str(azure_config.get("api_version")), + ) + client_holder.set_client(client) + summary, conversation_id, referenced_documents, token_usage = ( await retrieve_response_func( client, diff --git a/src/app/endpoints/streaming_query.py b/src/app/endpoints/streaming_query.py index d4ad3088a..7155700f4 100644 --- a/src/app/endpoints/streaming_query.py +++ b/src/app/endpoints/streaming_query.py @@ -39,6 +39,7 @@ from authentication import get_auth_dependency from authentication.interface import AuthTuple from authorization.middleware import authorize +from authorization.azure_token_manager import AzureEntraIDTokenManager from client import AsyncLlamaStackClientHolder from configuration import configuration from constants import DEFAULT_RAG_TOOL, MEDIA_TYPE_JSON, MEDIA_TYPE_TEXT @@ -757,13 +758,35 @@ async def streaming_query_endpoint_handler( # pylint: disable=too-many-locals,t try: # try to get Llama Stack client - client = AsyncLlamaStackClientHolder().get_client() + client_holder = AsyncLlamaStackClientHolder() + client = client_holder.get_client() llama_stack_model_id, model_id, provider_id = select_model_and_provider_id( await client.models.list(), *evaluate_model_hints( user_conversation=user_conversation, query_request=query_request ), ) + + azure_token_manager = AzureEntraIDTokenManager() + if ( + provider_id == "azure" + and azure_token_manager.is_entra_id_configured + and azure_token_manager.is_token_expired + ): + azure_token_manager.refresh_token() + azure_config = next( + p.config + for p in await client.providers.list() + if p.provider_type == "remote::azure" + ) + + client = client_holder.get_client_with_updated_azure_headers( + access_token=azure_token_manager.access_token, + api_base=str(azure_config.get("api_base")), + api_version=str(azure_config.get("api_version")), + ) + client_holder.set_client(client) + response, conversation_id = await retrieve_response( client, llama_stack_model_id, diff --git a/src/client.py b/src/client.py index cb9d3ad32..942770cef 100644 --- a/src/client.py +++ b/src/client.py @@ -1,6 +1,7 @@ """Llama Stack client retrieval class.""" import logging +import json from typing import Optional @@ -53,3 +54,48 @@ def get_client(self) -> AsyncLlamaStackClient: "AsyncLlamaStackClient has not been initialised. Ensure 'load(..)' has been called." ) return self._lsc + + def set_client(self, new_client: AsyncLlamaStackClient) -> None: + """ + Replace the currently stored AsyncLlamaStackClient instance. + + This method allows updating the client reference when + configuration or runtime attributes have changed. + """ + self._lsc = new_client + + def get_client_with_updated_azure_headers( + self, + access_token: str, + api_base: str, + api_version: str, + ) -> AsyncLlamaStackClient: + """Return a new client with updated Azure headers, preserving other headers.""" + if not self._lsc: + raise RuntimeError( + "AsyncLlamaStackClient has not been initialised. Ensure 'load(..)' has been called." + ) + + current_headers = self._lsc.default_headers if self._lsc else {} + provider_data_json = current_headers.get("X-LlamaStack-Provider-Data") + + try: + provider_data = json.loads(provider_data_json) if provider_data_json else {} + except (json.JSONDecodeError, TypeError): + provider_data = {} + + # Update only Azure-specific fields + provider_data.update( + { + "azure_api_key": access_token, + "azure_api_base": api_base, + "azure_api_version": api_version, + "azure_api_type": None, # deprecated attribute + } + ) + + updated_headers = { + **current_headers, + "X-LlamaStack-Provider-Data": json.dumps(provider_data), + } + return self._lsc.copy(set_default_headers=updated_headers) # type: ignore diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 5405092fe..155a556c9 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -1,7 +1,9 @@ """Unit tests for functions defined in src/client.py.""" -import pytest +# pylint: disable=protected-access +import json +import pytest from client import AsyncLlamaStackClientHolder from models.config import LlamaStackConfiguration @@ -71,3 +73,59 @@ async def test_get_async_llama_stack_wrong_configuration() -> None: ): client = AsyncLlamaStackClientHolder() await client.load(cfg) + + +@pytest.mark.asyncio +async def test_get_client_with_updated_azure_headers_preserves_existing_data() -> None: + """Test that update preserves unrelated headers and overwrites Azure headers.""" + cfg = LlamaStackConfiguration( + url="http://localhost:8321", + api_key=None, + use_as_library_client=False, + library_client_config_path=None, + ) + holder = AsyncLlamaStackClientHolder() + await holder.load(cfg) + + original_client = holder.get_client() + + # Pre-populate client with custom headers and provider data + original_client._custom_headers["X-Custom-Header"] = "custom_value" + original_provider_data = { + "existing_field": "keep_this", + "azure_api_key": "old_token", + "azure_api_base": "https://old.example.com", + "azure_api_version": "v0", + } + original_client._custom_headers["X-LlamaStack-Provider-Data"] = json.dumps( + original_provider_data + ) + + access_token = "new_token" + api_base = "https://new.example.com" + api_version = "v1" + + new_client = holder.get_client_with_updated_azure_headers( + access_token=access_token, + api_base=api_base, + api_version=api_version, + ) + + assert new_client is not original_client + + # Verify non-provider headers are preserved + assert new_client.default_headers["X-Custom-Header"] == "custom_value" + + # Verify provider data headers are updated correctly + provider_data_json = new_client.default_headers.get("X-LlamaStack-Provider-Data") + assert provider_data_json is not None + provider_data = json.loads(provider_data_json) + + # Existing unrelated fields are preserved + assert provider_data["existing_field"] == "keep_this" + + # Azure fields are overwritten + assert provider_data["azure_api_key"] == access_token + assert provider_data["azure_api_base"] == api_base + assert provider_data["azure_api_version"] == api_version + assert provider_data.get("azure_api_type") is None From 6b1803953e765a109e8f490a14f875ac7f450e43 Mon Sep 17 00:00:00 2001 From: Andrej Simurka Date: Wed, 5 Nov 2025 14:06:05 +0100 Subject: [PATCH 5/6] LCORE-865: Updated Azure models toolcalling capabilities --- README.md | 5 +- src/authorization/azure_token_manager.py | 3 +- .../authorization/test_azure_token_manager.py | 59 ++++++++++++------- 3 files changed, 42 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 12ab777f1..3c3c1cdb8 100644 --- a/README.md +++ b/README.md @@ -125,9 +125,8 @@ Lightspeed Core Stack (LCS) supports the large language models from the provider | OpenAI | gpt-5, gpt-4o, gpt4-turbo, gpt-4.1, o1, o3, o4 | Yes | remote::openai | [1](examples/openai-faiss-run.yaml) [2](examples/openai-pgvector-run.yaml) | | OpenAI | gpt-3.5-turbo, gpt-4 | No | remote::openai | | | RHAIIS (vLLM)| meta-llama/Llama-3.1-8B-Instruct | Yes | remote::vllm | [1](tests/e2e/configs/run-rhaiis.yaml) | -| RHEL AI (vLLM)| meta-llama/Llama-3.1-8B-Instruct | Yes | remote::vllm | [1](tests/e2e/configs/run-rhelai.yaml) | -| Azure | gpt-5, gpt-5-mini, gpt-5-nano, gpt-5-chat, gpt-4.1, gpt-4.1-mini, gpt-4.1-nano, o3-mini, o4-mini | Yes | remote::azure | [1](examples/azure-run.yaml) | -| Azure | o1, o1-mini | No | remote::azure | | +| Azure | gpt-5, gpt-5-mini, gpt-5-nano, gpt-4o-mini, o3-mini, o4-mini, o1| Yes | remote::azure | [1](examples/azure-run.yaml) | +| Azure | gpt-5-chat, gpt-4.1, gpt-4.1-mini, gpt-4.1-nano, o1-mini | No or limited | remote::azure | | The "provider_type" is used in the llama stack configuration file when refering to the provider. diff --git a/src/authorization/azure_token_manager.py b/src/authorization/azure_token_manager.py index 85b547275..5e26343a8 100644 --- a/src/authorization/azure_token_manager.py +++ b/src/authorization/azure_token_manager.py @@ -7,8 +7,9 @@ from azure.core.credentials import AccessToken from azure.core.exceptions import ClientAuthenticationError from azure.identity import ClientSecretCredential -from utils.types import Singleton + from configuration import AzureEntraIdConfiguration +from utils.types import Singleton logger = logging.getLogger(__name__) diff --git a/tests/unit/authorization/test_azure_token_manager.py b/tests/unit/authorization/test_azure_token_manager.py index 388486160..5677d459d 100644 --- a/tests/unit/authorization/test_azure_token_manager.py +++ b/tests/unit/authorization/test_azure_token_manager.py @@ -3,10 +3,12 @@ # pylint: disable=protected-access import time + import pytest from pytest_mock import MockerFixture -from azure.core.credentials import AccessToken from pydantic import SecretStr +from azure.core.credentials import AccessToken +from azure.core.exceptions import ClientAuthenticationError from authorization.azure_token_manager import ( AzureEntraIDTokenManager, @@ -26,7 +28,7 @@ def dummy_config_fixture() -> AzureEntraIdConfiguration: @pytest.fixture(autouse=True) -def reset_singleton(): +def reset_singleton() -> None: """Reset the singleton instance before each test.""" AzureEntraIDTokenManager._instances = {} @@ -40,23 +42,29 @@ def token_manager_fixture() -> AzureEntraIDTokenManager: class TestAzureEntraIDTokenManager: """Unit tests for AzureEntraIDTokenManager.""" - def test_singleton_behavior(self, token_manager): + def test_singleton_behavior(self, token_manager: AzureEntraIDTokenManager) -> None: """Verify the singleton returns the same instance.""" manager2 = AzureEntraIDTokenManager() assert token_manager is manager2 - def test_initial_state(self, token_manager): + def test_initial_state(self, token_manager: AzureEntraIDTokenManager) -> None: """Check the initial token manager state.""" assert token_manager.access_token == "" assert token_manager.is_token_expired assert not token_manager.is_entra_id_configured - def test_set_config(self, token_manager, dummy_config): + def test_set_config( + self, + token_manager: AzureEntraIDTokenManager, + dummy_config: AzureEntraIdConfiguration, + ) -> None: """Set the Azure configuration on the token manager.""" token_manager.set_config(dummy_config) assert token_manager.is_entra_id_configured - def test_token_expiration_logic(self, token_manager): + def test_token_expiration_logic( + self, token_manager: AzureEntraIDTokenManager + ) -> None: """Verify token expiration logic works correctly.""" token_manager._expires_on = int(time.time()) + 100 assert not token_manager.is_token_expired @@ -65,12 +73,16 @@ def test_token_expiration_logic(self, token_manager): assert token_manager.is_token_expired @pytest.mark.asyncio - async def test_refresh_token_raises_without_config(self, token_manager): + async def test_refresh_token_raises_without_config( + self, token_manager: AzureEntraIDTokenManager + ) -> None: """Raise ValueError when refresh_token is called without config.""" with pytest.raises(ValueError, match="Azure configuration is not set"): token_manager.refresh_token() - def test_update_access_token_sets_token_and_expiration(self, token_manager): + def test_update_access_token_sets_token_and_expiration( + self, token_manager: AzureEntraIDTokenManager + ) -> None: """Update the token and its expiration in the token manager.""" expires_on = int(time.time()) + 3600 token_manager._update_access_token("test-token", expires_on) @@ -79,8 +91,11 @@ def test_update_access_token_sets_token_and_expiration(self, token_manager): @pytest.mark.asyncio async def test_refresh_token_success( - self, token_manager, dummy_config, mocker: MockerFixture - ): + self, + token_manager: AzureEntraIDTokenManager, + dummy_config: AzureEntraIdConfiguration, + mocker: MockerFixture, + ) -> None: """Refresh the token successfully using the Azure credential mock.""" token_manager.set_config(dummy_config) dummy_access_token = AccessToken("token_value", int(time.time()) + 3600) @@ -103,25 +118,27 @@ async def test_refresh_token_success( @pytest.mark.asyncio async def test_refresh_token_failure_raises_runtime_error( - self, token_manager, dummy_config, mocker: MockerFixture, caplog - ): + self, + token_manager: AzureEntraIDTokenManager, + dummy_config: AzureEntraIdConfiguration, + mocker: MockerFixture, + ) -> None: """Raise RuntimeError when token retrieval fails.""" token_manager.set_config(dummy_config) mock_credential_instance = mocker.Mock() - mock_credential_instance.get_token.side_effect = Exception("fail") + mock_credential_instance.get_token.side_effect = ClientAuthenticationError( + "fail" + ) mocker.patch( "authorization.azure_token_manager.ClientSecretCredential", return_value=mock_credential_instance, ) + with pytest.raises(RuntimeError, match="Failed to retrieve Azure access token"): + token_manager.refresh_token() - with caplog.at_level("ERROR"): - with pytest.raises( - RuntimeError, match="Failed to retrieve Azure access token" - ): - token_manager.refresh_token() - assert "Error retrieving access token" in caplog.text - - def test_token_expired_property_dynamic(self, token_manager, mocker: MockerFixture): + def test_token_expired_property_dynamic( + self, token_manager: AzureEntraIDTokenManager, mocker: MockerFixture + ) -> None: """Simulate time passage to test token expiration property.""" now = 1000000 token_manager._expires_on = now + 10 From f9c4347099ee9d2f129736295ddacacd8cae1939 Mon Sep 17 00:00:00 2001 From: Andrej Simurka Date: Wed, 5 Nov 2025 15:40:28 +0100 Subject: [PATCH 6/6] Fixed dependencies --- pyproject.toml | 4 ++-- uv.lock | 12 ++++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index da155d605..f4c448b55 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,8 @@ dependencies = [ # Used by authorization resolvers "jsonpath-ng>=1.6.1", "psycopg2-binary>=2.9.10", + "azure-core", + "azure-identity", ] @@ -159,8 +161,6 @@ llslibdev = [ "opentelemetry-instrumentation>=0.55b0", "blobfile>=3.0.0", "psutil>=7.0.0", - "azure-core", - "azure-identity", ] build = [ diff --git a/uv.lock b/uv.lock index ce7244b2d..867d07c51 100644 --- a/uv.lock +++ b/uv.lock @@ -946,6 +946,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, { url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" }, + { url = "https://files.pythonhosted.org/packages/27/45/80935968b53cfd3f33cf99ea5f08227f2646e044568c9b1555b58ffd61c2/greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0", size = 1564846, upload-time = "2025-11-04T12:42:15.191Z" }, + { url = "https://files.pythonhosted.org/packages/69/02/b7c30e5e04752cb4db6202a3858b149c0710e5453b71a3b2aec5d78a1aab/greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d", size = 1633814, upload-time = "2025-11-04T12:42:17.175Z" }, { url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" }, { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, @@ -955,6 +957,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, { url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" }, + { url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" }, + { url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" }, { url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" }, ] @@ -1371,6 +1375,8 @@ source = { editable = "." } dependencies = [ { name = "aiohttp" }, { name = "authlib" }, + { name = "azure-core" }, + { name = "azure-identity" }, { name = "cachetools" }, { name = "email-validator" }, { name = "fastapi" }, @@ -1418,8 +1424,6 @@ dev = [ llslibdev = [ { name = "aiosqlite" }, { name = "autoevals" }, - { name = "azure-core" }, - { name = "azure-identity" }, { name = "blobfile" }, { name = "datasets" }, { name = "emoji" }, @@ -1454,6 +1458,8 @@ llslibdev = [ requires-dist = [ { name = "aiohttp", specifier = ">=3.12.14" }, { name = "authlib", specifier = ">=1.6.0" }, + { name = "azure-core" }, + { name = "azure-identity" }, { name = "cachetools", specifier = ">=6.1.0" }, { name = "email-validator", specifier = ">=2.2.0" }, { name = "fastapi", specifier = ">=0.115.12" }, @@ -1501,8 +1507,6 @@ dev = [ llslibdev = [ { name = "aiosqlite", specifier = ">=0.21.0" }, { name = "autoevals", specifier = ">=0.0.129" }, - { name = "azure-core" }, - { name = "azure-identity" }, { name = "blobfile", specifier = ">=3.0.0" }, { name = "datasets", specifier = ">=3.6.0" }, { name = "emoji", specifier = ">=2.1.0" },