From 2a8a2eb14769ee60b9ac64fbe8fee76f3e1c7994 Mon Sep 17 00:00:00 2001 From: lisry-1 <249596828+lisry-1@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:07:14 +0800 Subject: [PATCH 1/2] Python: fix(foundry-local): migrate to the Foundry Local SDK 2.x API The integration pinned `foundry-local-sdk>=0.5.1,<0.5.2`, which excludes every SDK release currently published (1.2.4, 2.1.0, 2.2.1) and forces applications to either downgrade the SDK or drop Foundry Local. SDK 2.x replaced the v1 Python surface, so the adapter is migrated to it: - The manager is now a process-wide singleton created from a `Configuration`. `_resolve_foundry_local_manager()` reuses it so several clients and models can share one manager, catalog, and web service instead of raising on the second construction. - Model resolution goes through the catalog. `get_model()` covers aliases and `get_model_variant()` covers full model IDs, which the alias lookup misses. - `device` now selects a variant by `variant.info.runtime.device_type` instead of being passed to `get_model_info()`/`download_model()`/`load_model()`. - `prepare_model` maps to `model.download()` and `model.load()`. - The web service is the OpenAI-compatible transport, so it is started when `bootstrap` is set and its bound URL becomes the client base URL (`{url}/v1`). When neither bootstrap nor an already-running service yields a URL, initialization fails with an actionable error instead of building an OpenAI client against a placeholder. - `timeout` is applied to the OpenAI client, which is where request timeouts actually take effect now that inference is reached over HTTP. The dependency range becomes `foundry-local-sdk>=2.0.0,<3`. SDK 2.x ships a `cp311-abi3` wheel, so Foundry Local now requires Python 3.11+ in practice. Tests are updated to the new SDK surface and cover the new behavior: web service startup, the missing-endpoint error, base URL construction, timeout propagation, model-ID resolution, and device variant selection. --- .../_foundry_local_client.py | 125 ++++++++++--- python/packages/foundry_local/pyproject.toml | 2 +- .../packages/foundry_local/tests/conftest.py | 24 ++- .../tests/test_foundry_local_client.py | 177 ++++++++++++------ .../providers/foundry/foundry_local_agent.py | 5 +- python/uv.lock | 88 +++++++-- 6 files changed, 305 insertions(+), 116 deletions(-) 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..38636a0420c 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 @@ -22,8 +22,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 +46,74 @@ 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" + + +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 + 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 +301,10 @@ 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. 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 +314,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 +341,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 +362,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 +396,26 @@ 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() + # 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..75753107c5b 100644 --- a/python/packages/foundry_local/pyproject.toml +++ b/python/packages/foundry_local/pyproject.toml @@ -26,7 +26,7 @@ classifiers = [ 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] 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..224c350a138 100644 --- a/python/packages/foundry_local/tests/test_foundry_local_client.py +++ b/python/packages/foundry_local/tests/test_foundry_local_client.py @@ -8,6 +8,7 @@ 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 @@ -62,7 +63,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 +75,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 +94,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 +175,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 +209,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 +220,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 +257,29 @@ 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() 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" From 26c2c557d44593a764b773788a337f62c7a7cffd Mon Sep 17 00:00:00 2001 From: lisry-1 <249596828+lisry-1@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:44:07 +0800 Subject: [PATCH 2/2] Python: fix(foundry-local): guard manager init and register execution providers Follow-up to the SDK 2.x migration, addressing review feedback. The singleton check-and-initialize sequence was racy: two threads creating their first clients concurrently could both observe an uninitialized manager, and the SDK rejects the second `initialize()` call, so one of them would fail instead of sharing the manager. The sequence now runs under a lock and re-checks the singleton inside it. The regression test drives two threads at the SDK boundary; without the lock the second `initialize()` call is reached and the test fails, so the race is covered rather than assumed. Catalog resolution for accelerated devices also needed preparation that was missing. A variant is only listed 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) could report "not found" or silently fall back to CPU. `bootstrap` now calls `download_and_register_eps()` before the catalog is read, and explicitly requesting CPU skips it so those callers do not download providers they will not use. Finally, the distribution metadata advertised interpreters the new dependency set cannot support: `foundry-local-sdk` 2.x declares `Requires-Python` `>=3.11,<3.15`, while this package still declared `>=3.10` and listed 3.10 and 3.15 classifiers. The requirement, the classifiers, and the mypy target move to 3.11, the `all` extra in core gains the matching environment marker, and the test matrix excludes the integration on 3.10 and 3.15. `uv lock --check` confirms the lockfile still satisfies the workspace. --- .github/workflows/python-tests.yml | 4 +- python/packages/core/pyproject.toml | 2 +- .../_foundry_local_client.py | 25 +++++- python/packages/foundry_local/pyproject.toml | 7 +- .../tests/test_foundry_local_client.py | 77 ++++++++++++++++++- 5 files changed, 103 insertions(+), 12 deletions(-) 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 38636a0420c..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 @@ -51,6 +52,10 @@ # 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. @@ -61,10 +66,14 @@ def _resolve_foundry_local_manager() -> FoundryLocalManager: manager = FoundryLocalManager.instance if manager is not None: return manager - 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.") + 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 @@ -305,6 +314,8 @@ def __init__( 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 @@ -397,6 +408,12 @@ class MyOptions(FoundryLocalChatOptions, total=False): model_setting: str = settings["model"] # type: ignore[assignment] 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: diff --git a/python/packages/foundry_local/pyproject.toml b/python/packages/foundry_local/pyproject.toml index 75753107c5b..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,12 +16,10 @@ 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 = [ @@ -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/test_foundry_local_client.py b/python/packages/foundry_local/tests/test_foundry_local_client.py index 224c350a138..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 @@ -11,7 +13,10 @@ 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 @@ -283,3 +288,73 @@ def test_foundry_local_client_init_calls_download_and_load( 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]