diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 467aa1b7470..75cae4e11c1 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -44,8 +44,8 @@ jobs: with: python-version: ${{ matrix.python-version }} os: ${{ runner.os }} - # RedisVL still declares Python <3.15. - exclude-packages: ${{ matrix.python-version == '3.10' && 'agent-framework-github-copilot agent-framework-azure-cosmos-memory' || matrix.python-version == '3.15' && 'agent-framework-redis' || '' }} + # RedisVL still declares Python <3.15, and foundry-local-sdk 2.x supports 3.11-3.14. + exclude-packages: ${{ matrix.python-version == '3.10' && 'agent-framework-github-copilot agent-framework-azure-cosmos-memory agent-framework-foundry-local' || matrix.python-version == '3.15' && 'agent-framework-redis agent-framework-foundry-local' || '' }} env: # Configure a constant location for the uv cache UV_CACHE_DIR: /tmp/.uv-cache diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml index bbcdc3c84a8..416bfc1553b 100644 --- a/python/packages/core/pyproject.toml +++ b/python/packages/core/pyproject.toml @@ -53,7 +53,7 @@ all = [ "agent-framework-durabletask>=1.0.0b260922,<2", "agent-framework-foundry>=1.15.0,<2", "agent-framework-foundry-hosting>=1.0.0b261022,<2", - "agent-framework-foundry-local>=1.0.0b261015,<2", + "agent-framework-foundry-local>=1.0.0b261015,<2; python_version >= '3.11' and python_version < '3.15'", "agent-framework-gemini>=1.0.0b261022,<2", "agent-framework-github-copilot>=2.1.0,<3; python_version >= '3.11'", "agent-framework-hyperlight>=1.0.0b261022,<2; (sys_platform == 'linux' and platform_machine == 'x86_64') or (sys_platform == 'win32' and platform_machine == 'AMD64')", diff --git a/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py b/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py index a36647b8e35..187227d84a3 100644 --- a/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py +++ b/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py @@ -3,6 +3,7 @@ from __future__ import annotations import sys +import threading from collections.abc import Awaitable, Callable, Mapping, Sequence from typing import Any, ClassVar, Generic, Literal, cast, overload @@ -22,8 +23,7 @@ from agent_framework._settings import load_settings from agent_framework.observability import ChatTelemetryLayer from agent_framework_openai._chat_completion_client import RawOpenAIChatCompletionClient -from foundry_local import FoundryLocalManager -from foundry_local.models import DeviceType, FoundryModelInfo +from foundry_local_sdk import Configuration, DeviceType, FoundryLocalManager, IModel from openai import AsyncOpenAI from pydantic import BaseModel @@ -47,6 +47,82 @@ ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) +# The v2 SDK identifies the host application in native logs and telemetry. +_APP_NAME = "agent-framework" +# The built-in web service performs no authentication; the OpenAI SDK still requires a value. +_WEB_SERVICE_API_KEY = "none" + +# Guards first-use initialization: without it, two threads can both observe an +# uninitialized singleton and the SDK rejects the second `initialize()` call. +_MANAGER_LOCK = threading.Lock() + + +def _resolve_foundry_local_manager() -> FoundryLocalManager: + """Return the process-wide Foundry Local manager, initializing it on first use. + + The v2 SDK owns a single native manager per process and raises when a second one + is constructed, so every client shares one manager, catalog, and web service. + """ + manager = FoundryLocalManager.instance + if manager is not None: + return manager + with _MANAGER_LOCK: + # Re-check under the lock: another thread may have initialized while we waited. + manager = FoundryLocalManager.instance + if manager is None: + FoundryLocalManager.initialize(Configuration(app_name=_APP_NAME)) + manager = FoundryLocalManager.instance + if manager is None: # pragma: no cover - initialize() sets the singleton + raise RuntimeError("Foundry Local manager could not be initialized.") + return manager + + +def _resolve_model( + manager: FoundryLocalManager, + model_setting: str, + *, + device: DeviceType | None = None, +) -> IModel: + """Resolve a model alias (or a full model ID) to a catalog model. + + Args: + manager: The Foundry Local manager owning the catalog. + model_setting: A model alias such as ``phi-4-mini``, or a full model ID. + device: When set, select the variant optimized for that device. + + Returns: + The catalog model, with the requested variant selected when ``device`` is set. + + Raises: + ValueError: If no model matches, or no variant matches ``device``. + """ + model = manager.catalog.get_model(model_setting) + if model is None: + # An alias lookup misses for full model IDs; the variant lookup covers those. + model = manager.catalog.get_model_variant(model_setting) + not_found = ( + f"Model with ID or alias '{model_setting}:{device.value}' not found in Foundry Local." + if device + else (f"Model with ID or alias '{model_setting}' for your current device not found in Foundry Local.") + ) + if model is None: + raise ValueError(not_found) + if device is not None: + variant = next( + ( + candidate + for candidate in model.variants + if candidate.info is not None + and candidate.info.runtime is not None + and candidate.info.runtime.device_type == device + ), + None, + ) + if variant is None: + raise ValueError(not_found) + model.select_variant(variant) + return model + # region Foundry Local Chat Options TypedDict @@ -234,8 +310,12 @@ def __init__( Keyword Args: model: The Foundry Local model ID or alias to use. If not provided, it will be loaded from the FoundryLocalSettings. - bootstrap: Whether to start the Foundry Local service if not already running. - Default is True. + bootstrap: Whether to start the Foundry Local web service if it is not already + running. Default is True. The client talks to Foundry Local over the + web service's OpenAI-compatible endpoint, so it needs to be running. + Pass False only when something else has already started it. + This also downloads and registers the execution providers that + accelerated (GPU/NPU) model variants need, unless `device` is CPU. timeout: Optional timeout for requests to Foundry Local. This timeout is applied to any call to the Foundry Local service. prepare_model: Whether to download the model into the cache, and load the model into @@ -245,7 +325,7 @@ def __init__( device: The device type to use for model inference. The device is used to select the appropriate model variant. If not provided, the default device for your system will be used. - The values are in the foundry_local.models.DeviceType enum. + The values are in the foundry_local_sdk.DeviceType enum. additional_properties: Additional properties stored on the client instance. middleware: Optional sequence of ChatAndFunctionMiddlewareTypes to apply to requests. function_invocation_configuration: Optional configuration for function invocation support. @@ -272,14 +352,15 @@ def __init__( os.environ["FOUNDRY_LOCAL_MODEL"] = "phi-4-mini" client = FoundryLocalClient() - # A FoundryLocalManager is created and if set, the service is started. + # A process-wide FoundryLocalManager is created on first use and, when + # bootstrap is set, the web service is started. # The FoundryLocalManager is available via the `manager` property. # For instance to find out which models are available: - for model in client.manager.list_catalog_models(): - print(f"- {model.alias} for {model.task} - id={model.id}") + for model in client.manager.catalog.list_models(): + print(f"- {model.alias} - id={model.id}") # Other options include specifying the device type: - from foundry_local.models import DeviceType + from foundry_local_sdk import DeviceType client = FoundryLocalClient( model="phi-4-mini", @@ -292,10 +373,10 @@ def __init__( ) # Beware, in this case the first request to generate a completion # will take a long time as the model is loaded then. - # Alternatively, you could call the `download_model` and `load_model` methods - # on the `manager` property manually. - client.manager.download_model("phi-4-mini", device=DeviceType.CPU) - client.manager.load_model("phi-4-mini", device=DeviceType.CPU) + # Alternatively, you could download and load the model yourself. + model = client.manager.catalog.get_model("phi-4-mini") + model.download() + model.load() # You can also use the CLI: `foundry model load phi-4-mini --device Auto` @@ -326,25 +407,32 @@ class MyOptions(FoundryLocalChatOptions, total=False): ) model_setting: str = settings["model"] # type: ignore[assignment] - manager = FoundryLocalManager(bootstrap=bootstrap, timeout=timeout) - model_info: FoundryModelInfo | None = manager.get_model_info( - model_setting, - device=device, - ) - if model_info is None: - message = ( - f"Model with ID or alias '{model_setting}:{device.value}' not found in Foundry Local." - if device - else (f"Model with ID or alias '{model_setting}' for your current device not found in Foundry Local.") + manager = _resolve_foundry_local_manager() + if bootstrap and device is not DeviceType.CPU: + # A variant is only listed in the catalog once its execution provider has been + # downloaded and registered, so on a fresh installation asking for GPU or NPU + # (or letting the SDK auto-select) would otherwise report "not found" or + # silently fall back to CPU. This is a no-op when the providers are registered. + manager.download_and_register_eps() + # The v2 SDK runs inference in-process; the optional built-in web service is what + # exposes the OpenAI-compatible REST endpoint this client talks to. + if bootstrap and not manager.urls: + manager.start_web_service() + if not manager.urls: + raise ValueError( + "The Foundry Local web service is not running, so there is no OpenAI-compatible endpoint to call. " + "Pass bootstrap=True (the default) to start it, or start it on the manager first." ) - raise ValueError(message) + base_url = f"{manager.urls[0]}/v1" + + model_info = _resolve_model(manager, model_setting, device=device) if prepare_model: - manager.download_model(model_info.id, device=device) - manager.load_model(model_info.id, device=device) + model_info.download() + model_info.load() super().__init__( model=model_info.id, - async_client=AsyncOpenAI(base_url=manager.endpoint, api_key=manager.api_key), + async_client=AsyncOpenAI(base_url=base_url, api_key=_WEB_SERVICE_API_KEY, timeout=timeout), additional_properties=additional_properties, middleware=middleware, function_invocation_configuration=function_invocation_configuration, diff --git a/python/packages/foundry_local/pyproject.toml b/python/packages/foundry_local/pyproject.toml index 43b56795cfd..c5b01018d34 100644 --- a/python/packages/foundry_local/pyproject.toml +++ b/python/packages/foundry_local/pyproject.toml @@ -3,7 +3,8 @@ name = "agent-framework-foundry-local" description = "Foundry Local integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" -requires-python = ">=3.10" +# The Foundry Local SDK 2.x ships for Python 3.11-3.14 only. +requires-python = ">=3.11" version = "1.0.0b261015" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" @@ -15,18 +16,16 @@ classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", - "Programming Language :: Python :: 3.15", "Typing :: Typed", ] dependencies = [ "agent-framework-core>=1.13.0,<2", "agent-framework-openai>=1.6.0,<2", - "foundry-local-sdk>=0.5.1,<0.5.2", + "foundry-local-sdk>=2.0.0,<3", ] [tool.uv] @@ -67,7 +66,7 @@ exclude = ['tests'] [tool.mypy] plugins = ['pydantic.mypy'] strict = true -python_version = "3.10" +python_version = "3.11" ignore_missing_imports = true disallow_untyped_defs = true no_implicit_optional = true diff --git a/python/packages/foundry_local/tests/conftest.py b/python/packages/foundry_local/tests/conftest.py index cd4356d0b56..e3a1a694ff0 100644 --- a/python/packages/foundry_local/tests/conftest.py +++ b/python/packages/foundry_local/tests/conftest.py @@ -42,14 +42,24 @@ def foundry_local_unit_test_env(monkeypatch: Any, exclude_list: list[str], overr @fixture -def mock_foundry_local_manager() -> MagicMock: +def mock_foundry_local_model() -> MagicMock: + """Fixture that provides a mock catalog model.""" + mock_model = MagicMock() + mock_model.id = "test-model-id" + mock_model.alias = "test-model" + mock_model.variants = [] + + return mock_model + + +@fixture +def mock_foundry_local_manager(mock_foundry_local_model: MagicMock) -> MagicMock: """Fixture that provides a mock FoundryLocalManager.""" mock_manager = MagicMock() - mock_manager.endpoint = "http://localhost:5272/v1" - mock_manager.api_key = "test-api-key" - - mock_model_info = MagicMock() - mock_model_info.id = "test-model-id" - mock_manager.get_model_info.return_value = mock_model_info + # ``urls`` is populated by ``start_web_service()``; model a service that is already up. + mock_manager.urls = ["http://localhost:5272"] + mock_manager.catalog.get_model.return_value = mock_foundry_local_model + # An alias lookup is the primary path; leave the model-ID fallback empty by default. + mock_manager.catalog.get_model_variant.return_value = None return mock_manager diff --git a/python/packages/foundry_local/tests/test_foundry_local_client.py b/python/packages/foundry_local/tests/test_foundry_local_client.py index 2ba5e9d89c0..2d30dec0000 100644 --- a/python/packages/foundry_local/tests/test_foundry_local_client.py +++ b/python/packages/foundry_local/tests/test_foundry_local_client.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft. All rights reserved. import inspect +import threading +import time from unittest.mock import MagicMock, patch import pytest @@ -8,9 +10,13 @@ from agent_framework._settings import load_settings from agent_framework.exceptions import SettingNotFoundError from agent_framework.foundry import FoundryLocalClient +from foundry_local_sdk import DeviceType from agent_framework_foundry_local._feature_usage import FeatureIndex -from agent_framework_foundry_local._foundry_local_client import FoundryLocalSettings +from agent_framework_foundry_local._foundry_local_client import ( + FoundryLocalSettings, + _resolve_foundry_local_manager, +) # Settings Tests @@ -62,7 +68,7 @@ def test_foundry_local_settings_explicit_overrides_env(foundry_local_unit_test_e def test_foundry_local_client_init(mock_foundry_local_manager: MagicMock) -> None: """Test FoundryLocalClient initialization with mocked manager.""" with patch( - "agent_framework_foundry_local._foundry_local_client.FoundryLocalManager", + "agent_framework_foundry_local._foundry_local_client._resolve_foundry_local_manager", return_value=mock_foundry_local_manager, ): client = FoundryLocalClient(model="test-model-id") @@ -74,7 +80,7 @@ def test_foundry_local_client_init(mock_foundry_local_manager: MagicMock) -> Non def test_agent_accepts_foundry_local_client(mock_foundry_local_manager: MagicMock) -> None: with patch( - "agent_framework_foundry_local._foundry_local_client.FoundryLocalManager", + "agent_framework_foundry_local._foundry_local_client._resolve_foundry_local_manager", return_value=mock_foundry_local_manager, ): client = FoundryLocalClient(model="test-model-id") @@ -93,40 +99,80 @@ def test_foundry_local_client_get_response_uses_explicit_runtime_buckets() -> No def test_foundry_local_client_init_with_bootstrap_false(mock_foundry_local_manager: MagicMock) -> None: - """Test FoundryLocalClient initialization with bootstrap=False.""" + """Test that bootstrap=False leaves an already running web service alone.""" with patch( - "agent_framework_foundry_local._foundry_local_client.FoundryLocalManager", + "agent_framework_foundry_local._foundry_local_client._resolve_foundry_local_manager", return_value=mock_foundry_local_manager, - ) as mock_manager_class: + ): FoundryLocalClient(model="test-model-id", bootstrap=False) - mock_manager_class.assert_called_once_with( - bootstrap=False, - timeout=None, - ) + mock_foundry_local_manager.start_web_service.assert_not_called() + + +def test_foundry_local_client_starts_web_service_when_not_running(mock_foundry_local_manager: MagicMock) -> None: + """Test that bootstrap=True starts the web service when it is not already up.""" + mock_foundry_local_manager.urls = None + + def _start() -> None: + mock_foundry_local_manager.urls = ["http://localhost:5272"] + + mock_foundry_local_manager.start_web_service.side_effect = _start + + with patch( + "agent_framework_foundry_local._foundry_local_client._resolve_foundry_local_manager", + return_value=mock_foundry_local_manager, + ): + FoundryLocalClient(model="test-model-id") + + mock_foundry_local_manager.start_web_service.assert_called_once_with() + + +def test_foundry_local_client_requires_endpoint_when_bootstrap_false(mock_foundry_local_manager: MagicMock) -> None: + """Test that a client without a running web service fails with an actionable error.""" + mock_foundry_local_manager.urls = None + + with ( + patch( + "agent_framework_foundry_local._foundry_local_client._resolve_foundry_local_manager", + return_value=mock_foundry_local_manager, + ), + pytest.raises(ValueError, match="web service is not running"), + ): + FoundryLocalClient(model="test-model-id", bootstrap=False) + + +def test_foundry_local_client_uses_web_service_base_url(mock_foundry_local_manager: MagicMock) -> None: + """Test that the OpenAI client points at the web service's OpenAI-compatible route.""" + mock_foundry_local_manager.urls = ["http://localhost:5272"] + + with patch( + "agent_framework_foundry_local._foundry_local_client._resolve_foundry_local_manager", + return_value=mock_foundry_local_manager, + ): + client = FoundryLocalClient(model="test-model-id") + + # httpx normalizes the base URL with a trailing slash. + assert str(client.client.base_url).rstrip("/") == "http://localhost:5272/v1" def test_foundry_local_client_init_with_timeout(mock_foundry_local_manager: MagicMock) -> None: """Test FoundryLocalClient initialization with custom timeout.""" with patch( - "agent_framework_foundry_local._foundry_local_client.FoundryLocalManager", + "agent_framework_foundry_local._foundry_local_client._resolve_foundry_local_manager", return_value=mock_foundry_local_manager, - ) as mock_manager_class: - FoundryLocalClient(model="test-model-id", timeout=60.0) + ): + client = FoundryLocalClient(model="test-model-id", timeout=60.0) - mock_manager_class.assert_called_once_with( - bootstrap=True, - timeout=60.0, - ) + assert client.client.timeout == 60.0 def test_foundry_local_client_init_model_not_found(mock_foundry_local_manager: MagicMock) -> None: """Test FoundryLocalClient initialization when model is not found.""" - mock_foundry_local_manager.get_model_info.return_value = None + mock_foundry_local_manager.catalog.get_model.return_value = None with ( patch( - "agent_framework_foundry_local._foundry_local_client.FoundryLocalManager", + "agent_framework_foundry_local._foundry_local_client._resolve_foundry_local_manager", return_value=mock_foundry_local_manager, ), pytest.raises(ValueError, match="not found in Foundry Local"), @@ -134,14 +180,29 @@ def test_foundry_local_client_init_model_not_found(mock_foundry_local_manager: M FoundryLocalClient(model="unknown-model") -def test_foundry_local_client_uses_model_info_id(mock_foundry_local_manager: MagicMock) -> None: - """Test that client uses the model ID from model_info, not the alias.""" - mock_model_info = MagicMock() - mock_model_info.id = "resolved-model-id" - mock_foundry_local_manager.get_model_info.return_value = mock_model_info +def test_foundry_local_client_resolves_full_model_id(mock_foundry_local_manager: MagicMock) -> None: + """Test that a full model ID resolves through the variant lookup when the alias misses.""" + variant = MagicMock() + variant.id = "test-model-id-generic-cpu:1" + mock_foundry_local_manager.catalog.get_model.return_value = None + mock_foundry_local_manager.catalog.get_model_variant.return_value = variant + + with patch( + "agent_framework_foundry_local._foundry_local_client._resolve_foundry_local_manager", + return_value=mock_foundry_local_manager, + ): + client = FoundryLocalClient(model="test-model-id-generic-cpu:1") + + mock_foundry_local_manager.catalog.get_model_variant.assert_called_once_with("test-model-id-generic-cpu:1") + assert client.model == "test-model-id-generic-cpu:1" + + +def test_foundry_local_client_uses_model_id(mock_foundry_local_manager: MagicMock) -> None: + """Test that client uses the model ID from the catalog, not the alias.""" + mock_foundry_local_manager.catalog.get_model.return_value.id = "resolved-model-id" with patch( - "agent_framework_foundry_local._foundry_local_client.FoundryLocalManager", + "agent_framework_foundry_local._foundry_local_client._resolve_foundry_local_manager", return_value=mock_foundry_local_manager, ): client = FoundryLocalClient(model="model-alias") @@ -153,8 +214,10 @@ def test_foundry_local_client_init_from_env( foundry_local_unit_test_env: dict[str, str], mock_foundry_local_manager: MagicMock ) -> None: """Test FoundryLocalClient initialization using environment variables.""" + mock_foundry_local_manager.catalog.get_model.return_value.id = foundry_local_unit_test_env["FOUNDRY_LOCAL_MODEL"] + with patch( - "agent_framework_foundry_local._foundry_local_client.FoundryLocalManager", + "agent_framework_foundry_local._foundry_local_client._resolve_foundry_local_manager", return_value=mock_foundry_local_manager, ): client = FoundryLocalClient() @@ -162,39 +225,36 @@ def test_foundry_local_client_init_from_env( assert client.model == foundry_local_unit_test_env["FOUNDRY_LOCAL_MODEL"] -def test_foundry_local_client_init_with_device(mock_foundry_local_manager: MagicMock) -> None: +def test_foundry_local_client_init_with_device( + mock_foundry_local_manager: MagicMock, mock_foundry_local_model: MagicMock +) -> None: """Test FoundryLocalClient initialization with device parameter.""" - from foundry_local.models import DeviceType + cpu_variant = MagicMock() + cpu_variant.info.runtime.device_type = DeviceType.CPU + gpu_variant = MagicMock() + gpu_variant.info.runtime.device_type = DeviceType.GPU + mock_foundry_local_model.variants = [cpu_variant, gpu_variant] with patch( - "agent_framework_foundry_local._foundry_local_client.FoundryLocalManager", + "agent_framework_foundry_local._foundry_local_client._resolve_foundry_local_manager", return_value=mock_foundry_local_manager, ): - FoundryLocalClient(model="test-model-id", device=DeviceType.CPU) - - mock_foundry_local_manager.get_model_info.assert_called_once_with( - "test-model-id", - device=DeviceType.CPU, - ) - mock_foundry_local_manager.download_model.assert_called_once_with( - "test-model-id", - device=DeviceType.CPU, - ) - mock_foundry_local_manager.load_model.assert_called_once_with( - "test-model-id", - device=DeviceType.CPU, - ) + FoundryLocalClient(model="test-model-id", device=DeviceType.GPU) + mock_foundry_local_model.select_variant.assert_called_once_with(gpu_variant) -def test_foundry_local_client_init_model_not_found_with_device(mock_foundry_local_manager: MagicMock) -> None: - """Test FoundryLocalClient error message includes device when model not found with device specified.""" - from foundry_local.models import DeviceType - mock_foundry_local_manager.get_model_info.return_value = None +def test_foundry_local_client_init_model_not_found_with_device( + mock_foundry_local_manager: MagicMock, mock_foundry_local_model: MagicMock +) -> None: + """Test FoundryLocalClient error message includes device when no variant matches.""" + cpu_variant = MagicMock() + cpu_variant.info.runtime.device_type = DeviceType.CPU + mock_foundry_local_model.variants = [cpu_variant] with ( patch( - "agent_framework_foundry_local._foundry_local_client.FoundryLocalManager", + "agent_framework_foundry_local._foundry_local_client._resolve_foundry_local_manager", return_value=mock_foundry_local_manager, ), pytest.raises(ValueError, match="unknown-model:GPU.*not found"), @@ -202,31 +262,99 @@ def test_foundry_local_client_init_model_not_found_with_device(mock_foundry_loca FoundryLocalClient(model="unknown-model", device=DeviceType.GPU) -def test_foundry_local_client_init_with_prepare_model_false(mock_foundry_local_manager: MagicMock) -> None: +def test_foundry_local_client_init_with_prepare_model_false( + mock_foundry_local_manager: MagicMock, mock_foundry_local_model: MagicMock +) -> None: """Test FoundryLocalClient initialization with prepare_model=False skips download and load.""" with patch( - "agent_framework_foundry_local._foundry_local_client.FoundryLocalManager", + "agent_framework_foundry_local._foundry_local_client._resolve_foundry_local_manager", return_value=mock_foundry_local_manager, ): FoundryLocalClient(model="test-model-id", prepare_model=False) - mock_foundry_local_manager.download_model.assert_not_called() - mock_foundry_local_manager.load_model.assert_not_called() + mock_foundry_local_model.download.assert_not_called() + mock_foundry_local_model.load.assert_not_called() -def test_foundry_local_client_init_calls_download_and_load(mock_foundry_local_manager: MagicMock) -> None: - """Test FoundryLocalClient initialization calls download_model and load_model by default.""" +def test_foundry_local_client_init_calls_download_and_load( + mock_foundry_local_manager: MagicMock, mock_foundry_local_model: MagicMock +) -> None: + """Test FoundryLocalClient initialization downloads and loads the model by default.""" with patch( - "agent_framework_foundry_local._foundry_local_client.FoundryLocalManager", + "agent_framework_foundry_local._foundry_local_client._resolve_foundry_local_manager", return_value=mock_foundry_local_manager, ): FoundryLocalClient(model="test-model-id") - mock_foundry_local_manager.download_model.assert_called_once_with( - "test-model-id", - device=None, - ) - mock_foundry_local_manager.load_model.assert_called_once_with( - "test-model-id", - device=None, - ) + mock_foundry_local_model.download.assert_called_once_with() + mock_foundry_local_model.load.assert_called_once_with() + + +def test_foundry_local_client_registers_execution_providers_for_accelerated_device( + mock_foundry_local_manager: MagicMock, mock_foundry_local_model: MagicMock +) -> None: + """Accelerated variants only exist in the catalog once their provider is registered.""" + gpu_variant = MagicMock() + gpu_variant.info.runtime.device_type = DeviceType.GPU + mock_foundry_local_model.variants = [gpu_variant] + + with patch( + "agent_framework_foundry_local._foundry_local_client._resolve_foundry_local_manager", + return_value=mock_foundry_local_manager, + ): + FoundryLocalClient(model="test-model-id", device=DeviceType.GPU) + + mock_foundry_local_manager.download_and_register_eps.assert_called_once_with() + + +def test_foundry_local_client_skips_execution_providers_for_cpu( + mock_foundry_local_manager: MagicMock, mock_foundry_local_model: MagicMock +) -> None: + """An explicit CPU request should not download accelerated providers it will not use.""" + cpu_variant = MagicMock() + cpu_variant.info.runtime.device_type = DeviceType.CPU + mock_foundry_local_model.variants = [cpu_variant] + + with patch( + "agent_framework_foundry_local._foundry_local_client._resolve_foundry_local_manager", + return_value=mock_foundry_local_manager, + ): + FoundryLocalClient(model="test-model-id", device=DeviceType.CPU) + + mock_foundry_local_manager.download_and_register_eps.assert_not_called() + mock_foundry_local_model.select_variant.assert_called_once_with(cpu_variant) + + +def test_resolve_manager_initializes_once_under_concurrency() -> None: + """Two threads racing on first use must share one manager, not fail the second one.""" + manager = MagicMock() + initialize_calls: list[int] = [] + gate = threading.Barrier(2) + + def _initialize(config: object) -> None: + initialize_calls.append(1) + # The SDK rejects a second initialization, so a race must never reach here twice. + assert len(initialize_calls) == 1 + # Widen the window so both threads observe the uninitialized singleton. + time.sleep(0.05) + fake_manager_class.instance = manager + + fake_manager_class = MagicMock() + fake_manager_class.instance = None + fake_manager_class.initialize.side_effect = _initialize + + resolved: list[object] = [] + + def _worker() -> None: + gate.wait() + resolved.append(_resolve_foundry_local_manager()) + + with patch("agent_framework_foundry_local._foundry_local_client.FoundryLocalManager", fake_manager_class): + threads = [threading.Thread(target=_worker) for _ in range(2)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + assert len(initialize_calls) == 1 + assert resolved == [manager, manager] diff --git a/python/samples/02-agents/providers/foundry/foundry_local_agent.py b/python/samples/02-agents/providers/foundry/foundry_local_agent.py index 95d4e06d21e..587c6c424f7 100644 --- a/python/samples/02-agents/providers/foundry/foundry_local_agent.py +++ b/python/samples/02-agents/providers/foundry/foundry_local_agent.py @@ -60,10 +60,11 @@ async def main() -> None: client = FoundryLocalClient(model="phi-4-mini") print(f"Client Model ID: {client.model}\n") print("Other available models (tool calling supported only):") - for model in client.manager.list_catalog_models(): + for model in client.manager.catalog.list_models(): if model.supports_tool_calling: print( - f"- {model.alias} for {model.task} - id={model.id} - {(model.file_size_mb / 1000):.2f} GB - {model.license}" + f"- {model.alias} for {model.info.task} - id={model.id} - " + f"{(model.info.file_size_mb / 1000):.2f} GB - {model.info.license}" ) agent = Agent( client=client, diff --git a/python/uv.lock b/python/uv.lock index 33ebc2383f6..164327792c0 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -718,7 +718,7 @@ dependencies = [ requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, { name = "agent-framework-openai", editable = "packages/openai" }, - { name = "foundry-local-sdk", specifier = ">=0.5.1,<0.5.2" }, + { name = "foundry-local-sdk", specifier = ">=2.0.0,<3" }, ] [[package]] @@ -2205,17 +2205,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/79/1b8fa1bb3568781e84c9200f951c735f3f157429f44be0495da55894d620/filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25", size = 19970, upload-time = "2022-11-02T17:34:01.425Z" }, ] +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, +] + [[package]] name = "foundry-local-sdk" -version = "0.5.1" +version = "2.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "httpx" }, + { name = "cffi" }, + { name = "onnxruntime" }, + { name = "onnxruntime-genai-core" }, + { name = "openai" }, { name = "pydantic" }, - { name = "tqdm" }, + { name = "requests" }, + { name = "typing-extensions" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/6b/76a7fe8f9f4c52cc84eaa1cd1b66acddf993496d55d6ea587bf0d0854d1c/foundry_local_sdk-0.5.1-py3-none-any.whl", hash = "sha256:f3639a3666bc3a94410004a91671338910ac2e1b8094b1587cc4db0f4a7df07e", size = 14003, upload-time = "2025-11-21T05:39:58.099Z" }, + { url = "https://files.pythonhosted.org/packages/17/ee/9718d428de802ee5264e314c2eda1392cb99ccbe9bca7386eec278103b7f/foundry_local_sdk-2.2.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:7180ea612ab5e2af0f6c8475d413ea592b7c067871b19c9e24a44c0a21f1c6e7", size = 6394441, upload-time = "2026-10-21T16:50:29.11Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d1/e1678487f99cfc52b573feba04c12133df7446a4d128abf1a489c0092f7f/foundry_local_sdk-2.2.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b534b9d3700ab90c69986a817ee542da215fab62e0f13e2e27f05c53b3239154", size = 8993548, upload-time = "2026-10-21T16:50:31.362Z" }, + { url = "https://files.pythonhosted.org/packages/7c/73/670318151e07fdf131df926a270d2443b7255e3b5851e8bdb5dca98f88c9/foundry_local_sdk-2.2.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6ee4bd5407f5134bb243456603f4398a8732d765c7b3cc4b4d6fa2910ad2bda5", size = 8693997, upload-time = "2026-10-21T16:50:33.912Z" }, + { url = "https://files.pythonhosted.org/packages/86/7a/ce8b0b2f870590e57d841a95adab0e965d6b3643fce5b20ca82222443a9f/foundry_local_sdk-2.2.1-cp311-abi3-win_amd64.whl", hash = "sha256:2a88dd8ba0ca89ba04b2f03a182238e0632d894b313117975ba0f8a4d68767d3", size = 40815873, upload-time = "2026-10-21T16:50:36.599Z" }, + { url = "https://files.pythonhosted.org/packages/bc/12/a7d2aabf69089b3711b9de18b7d2ff228af26d401dcf3b7af343c03a3aec/foundry_local_sdk-2.2.1-cp311-abi3-win_arm64.whl", hash = "sha256:f28790aaac7abc4d14b9e6cfae422d3ed2679a8c62f2c7084c0e59297b9a6689", size = 40611400, upload-time = "2026-10-21T16:50:47.112Z" }, ] [[package]] @@ -3651,6 +3667,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/f6/2091e50b8b6c3e6901f6eab283d5efd66fb71c86ddb1b4d68766c3eeba0f/ollama-0.5.3-py3-none-any.whl", hash = "sha256:a8303b413d99a9043dbf77ebf11ced672396b59bec27e6d5db67c88f01b279d2", size = 13490, upload-time = "2025-08-07T21:44:09.353Z" }, ] +[[package]] +name = "onnxruntime" +version = "1.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flatbuffers" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging" }, + { name = "protobuf" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/e7/61b2768393646bd12e31eeb71958193f4e02c98c4980cf9289d19bbb4a8f/onnxruntime-1.31.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:cbf1a7f6470ddfe9dbc781966af8ce4a10e1858d75a93f93cc6b9367c9587870", size = 20871717, upload-time = "2026-10-09T04:18:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/e57025ab9c1eb83b6e686c92507fa6b7156d9d375e197a6c3a2afc05a1e2/onnxruntime-1.31.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:37c7dfe398550afdf9670a29315dbb88e49d8afc473ffaf1f410376efbb9c80a", size = 21413529, upload-time = "2026-10-09T04:18:06.493Z" }, + { url = "https://files.pythonhosted.org/packages/a6/72/6c57163b63b5343853d7f0619c4f424a6e53ee762d7263667ff004bfede1/onnxruntime-1.31.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d4092b78fc5bab77ce6522393098cdb2535423045ecdcff15cc0d022162d6b66", size = 23753636, upload-time = "2026-10-09T04:18:09.974Z" }, + { url = "https://files.pythonhosted.org/packages/37/de/6cab7e39917cc87728d2f00abe97c81fe86b29f9e1f758627864c28f0c21/onnxruntime-1.31.0-cp311-cp311-win_amd64.whl", hash = "sha256:317608967b03807ed4661113b08293fac02a1db6496a6863a07d9f19232936ad", size = 14885750, upload-time = "2026-10-09T04:18:13.004Z" }, + { url = "https://files.pythonhosted.org/packages/1d/11/f335a124a1aadda99e5a2b618264606504bd9e3763b1b2486e6441cd65e5/onnxruntime-1.31.0-cp311-cp311-win_arm64.whl", hash = "sha256:e85c1632c0a8cf488bd8f1039f5320877b864c8f9ebd4122fb8bb909f83b7096", size = 14735138, upload-time = "2026-10-09T04:18:15.895Z" }, + { url = "https://files.pythonhosted.org/packages/b3/bd/2ac094311163b803e3626c3937461d6900934bd56cca7601f6150ff860c3/onnxruntime-1.31.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:aaab9b3af536b06ca27ab5e35e3d429c97457ce76cf298af103f687e8b9975c0", size = 20882054, upload-time = "2026-10-09T04:18:18.811Z" }, + { url = "https://files.pythonhosted.org/packages/53/1a/561b43ca1536d9e81d1785bb8a1a260a9e314ef6d04976ba0411c652bda1/onnxruntime-1.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:35758d7606d578ec5b9d65f6e8a1f488013194c3f6097038a3223cb26d35ef9a", size = 21420804, upload-time = "2026-10-09T04:18:21.729Z" }, + { url = "https://files.pythonhosted.org/packages/6c/44/1e9e762b95b7da0a8424913a1ed7c38cdaf88624a3c41ddba24ebac88bc9/onnxruntime-1.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:5e129d6c56abd53e659cb70f00a108d6824086470ff99c2e47a82e5786563db3", size = 23760984, upload-time = "2026-10-09T04:18:24.61Z" }, + { url = "https://files.pythonhosted.org/packages/be/ed/b12cea136ccd7b03d924f46b8393faf7ceac21115c0c50e729faa248cf23/onnxruntime-1.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:09d56445c1753e66e0912de69d3f0184016ad9a191dcd6925bf5dd570d2bfbe5", size = 14888841, upload-time = "2026-10-09T04:18:27.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/ad/37bbc51dcb5cd105c5b2fe98f122b23e90171c2719516964edc65bb1d4cc/onnxruntime-1.31.0-cp312-cp312-win_arm64.whl", hash = "sha256:5c54a0eb7b2b4eef3eb9dcfaf82f5ce880db07288dc309574f6657e9da5cc754", size = 14740604, upload-time = "2026-10-09T04:18:30.399Z" }, + { url = "https://files.pythonhosted.org/packages/e0/2b/117f94d73a3bac4276c285c47e384e1b3ea67b191aa4c7592df9d3f4a136/onnxruntime-1.31.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:0ba02a44acb6203040354d9a1f160e3f37a43feac7bb05caa3e0ea545efed505", size = 20881803, upload-time = "2026-10-09T04:18:33.62Z" }, + { url = "https://files.pythonhosted.org/packages/8a/d0/3677fe93ec0fa3c637744aa4c3ae6ef89a93ee229cd3c5157820f267c7bd/onnxruntime-1.31.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:ad663106f6eeff3d454f24a786450459d07f30e74863851104fc1b8b3f368127", size = 21420629, upload-time = "2026-10-09T04:18:36.731Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ac/67ebbaab4b3083f2a6b27ee6c4aa400c7f8d6c72b5499aac7e4cd6ba74f5/onnxruntime-1.31.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:37fd78cee5160c7a43a1730ccb3682ffd880af9c9e80385d625c0c2f8b125809", size = 23760708, upload-time = "2026-10-09T04:18:40.883Z" }, + { url = "https://files.pythonhosted.org/packages/c4/86/05ed2056f43b27aaf12ebc592ebd9037a26bed315958cf882f43425fd469/onnxruntime-1.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:73e0165d58ece068c2a8a1c477c90b38e5a8adbbd399fdfdfd4bd79cbc28ff8d", size = 14888306, upload-time = "2026-10-09T04:18:43.722Z" }, + { url = "https://files.pythonhosted.org/packages/c9/93/d33bae7b1a78780c4946ce03989c59a67d42d7015ad62d2098975fc5a580/onnxruntime-1.31.0-cp313-cp313-win_arm64.whl", hash = "sha256:e51d10d2e2e1e5bbf9b126a0cd9853d3e6c4e21424518dd50160b91471be33dc", size = 14740892, upload-time = "2026-10-09T04:18:46.338Z" }, + { url = "https://files.pythonhosted.org/packages/12/05/cf44f7642269b285aada4b662c4662b14ac63f6e03e129d939c4a956a0f5/onnxruntime-1.31.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:e0e050bf9ec754950a6ba9830e4032f4004d972c6f38c5642fef26d44d894965", size = 21432644, upload-time = "2026-10-09T04:18:48.925Z" }, + { url = "https://files.pythonhosted.org/packages/b5/8e/673315b2dd2eb99b2f4774d7a5986fe00d933ebed17ee72c441f579226e6/onnxruntime-1.31.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:e93d7c5fad20afa697ac16f376fd0306ed180f9a376e86106cc0b7d84f53ef87", size = 23773868, upload-time = "2026-10-09T04:18:51.776Z" }, + { url = "https://files.pythonhosted.org/packages/9d/fb/b4c52e500c6f3d00dfc22fad4d7513524f3ea2100a24a077ee3b0daf552d/onnxruntime-1.31.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:278e0dc922ec69b05a28f59110d5421e2ec8b1d0dd46c6b10c063069a4051e72", size = 20883462, upload-time = "2026-10-09T04:18:54.978Z" }, + { url = "https://files.pythonhosted.org/packages/37/fb/8be04665b700cb6e874d944e9932bb3c3969d3f53e820f5c42bfd26565d0/onnxruntime-1.31.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:984c0a2c1ad6a41fbc101dc3949abe4a72254892d01a5e70d9b792711e0bfa54", size = 21421618, upload-time = "2026-10-09T04:18:58.1Z" }, + { url = "https://files.pythonhosted.org/packages/30/2e/5c6ec7e26a097e97ee70f2dee68b8ca4d9d26701f2f33c3f8ab585cb89fe/onnxruntime-1.31.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e4efa4a1a0bb0b5173c6a3292c181d518b8323f9d56e978635d0c09d38c94d1a", size = 23762993, upload-time = "2026-10-09T04:19:01.236Z" }, + { url = "https://files.pythonhosted.org/packages/6a/66/0bf4fdb9f58efa69cf4eddde24c72aebcc628d6ff1d67c9546145c6b9922/onnxruntime-1.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:83e3dbcf6abc6189c4bdf7d329c07ba1133c88172134c266d84b4409aa3b9dbf", size = 15268709, upload-time = "2026-10-09T04:19:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/af/99/75a36172c1ed1d74ac0e91c11d642548081e2c9c63f15ee796564619556f/onnxruntime-1.31.0-cp314-cp314-win_arm64.whl", hash = "sha256:d2d5ac22f896c810be2b2b171392bb908f80b6c9a7e2d592ddb7435c928044e1", size = 15153795, upload-time = "2026-10-09T04:19:06.609Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ec/23b7749edc7aad53bf4632de190399fda69a9195499426637ef1b02f06c6/onnxruntime-1.31.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d25cd65874b75fdf16149120a04d0cd4551f860a3c8e2ecec785a1903e41d8aa", size = 21432344, upload-time = "2026-10-09T04:19:09.646Z" }, + { url = "https://files.pythonhosted.org/packages/f2/76/155ab0b265e9ceade28a8dd3858fdfa509b039f78010042c875940e32e58/onnxruntime-1.31.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1ecc1450af28d2cf362990e188ccc81b51388f317f641ad973ab4301473200f2", size = 23772576, upload-time = "2026-10-09T04:19:12.731Z" }, +] + +[[package]] +name = "onnxruntime-genai-core" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/14/360581437f231bcf2f331bacfed4a571e043a7269edab99a563be1d6b0c5/onnxruntime_genai_core-0.18.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:08cb9874228ea7199befe95777617c0568c424727ca3c7db36d42475341dd30d", size = 3551018, upload-time = "2026-10-14T18:27:49.693Z" }, + { url = "https://files.pythonhosted.org/packages/58/1f/892f0044ec1a8043a29109dac843970cf5698945f33443f40c3f12f9d4fe/onnxruntime_genai_core-0.18.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:2cf80c3b08cb9bf9471f75a0c528dd62ae18c1874d747ca58b13cd49bf113076", size = 10356840, upload-time = "2026-10-14T18:27:52.149Z" }, + { url = "https://files.pythonhosted.org/packages/9c/be/d9c7b0b111e96be0ae25f42171c8606167c4c053903a160fb51bbf824a70/onnxruntime_genai_core-0.18.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:8a29c0a3e517cdc7cac8809ee49c2b288f549fed6cdd31c08b53df06fd50460c", size = 10668979, upload-time = "2026-10-14T18:27:54.307Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ad/e0317650f6ce5647fadec769f18c0e159a7ce2533817ae94cd4b6fc77c68/onnxruntime_genai_core-0.18.0-py3-none-win_amd64.whl", hash = "sha256:1b2e1ede54fedcbec497ceb046994a48dd41c15d310dd63a321bff6fe31b1bec", size = 3789063, upload-time = "2026-10-14T18:27:56.285Z" }, + { url = "https://files.pythonhosted.org/packages/56/32/3cbcc7a2e5b582b72133af8fe182f08153782103add4abf4bc17e4992cad/onnxruntime_genai_core-0.18.0-py3-none-win_arm64.whl", hash = "sha256:d4c76762203a70f90c5a55229fd5f76123631a0de76c6bf5c9090ab58142d8a7", size = 3618969, upload-time = "2026-10-14T18:27:57.713Z" }, +] + [[package]] name = "openai" version = "3.36.1" @@ -5582,18 +5648,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/3f/3e3f8fd0919249b0200c80fbc4f9a1e70be19f9883da71dfb7f8b9ab8aca/tomli-2.5.0-py3-none-any.whl", hash = "sha256:32a7b79ac57a2e83670ce329ccf675798bc5a2094783a63676866b70503f2e2b", size = 14765, upload-time = "2026-10-07T12:23:36.875Z" }, ] -[[package]] -name = "tqdm" -version = "4.70.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0d/ea/b2a5bd54b28a324dae8211928b2d730b6547500342c7e6c6dea08bd0a485/tqdm-4.70.1.tar.gz", hash = "sha256:cefd0eca11b2a37a3aee776544d4f4ae913f02688135b5556b8788dfa474afc4", size = 171846, upload-time = "2026-09-11T07:25:16.601Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/03/921a3d3c75785aca9ebfbfcabfbc3a1be12e2ab5265deb026d55a5a3f83e/tqdm-4.70.1-py3-none-any.whl", hash = "sha256:c293e525e6fef9c20e8728fd4612df02a0aa31bb5fe91ecd93e123b1b7bffa73", size = 80199, upload-time = "2026-09-11T07:25:14.599Z" }, -] - [[package]] name = "truststore" version = "0.10.5"