From b442f9fd594d15fd49b34ae9299bbce50e8286fb Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Mon, 12 Oct 2026 15:29:58 -0700 Subject: [PATCH 01/17] Python: migrate Foundry Local integration to SDK 2.1 Use the in-process SDK singleton and catalog model APIs with the existing OpenAI transport. Require Python 3.11 and document the breaking migration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d8ebdb3c-28b8-4fa1-830d-3fa7d6333d9e --- .github/workflows/python-tests.yml | 2 +- python/packages/core/pyproject.toml | 2 +- python/packages/foundry_local/AGENTS.md | 6 +- python/packages/foundry_local/README.md | 44 ++- .../_foundry_local_client.py | 149 ++++--- python/packages/foundry_local/pyproject.toml | 7 +- .../packages/foundry_local/tests/conftest.py | 30 +- .../tests/test_foundry_local_client.py | 368 +++++++++++------- .../providers/foundry/foundry_local_agent.py | 34 +- 9 files changed, 419 insertions(+), 223 deletions(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 93b8ff06b97..79ae5a5d23a 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -45,7 +45,7 @@ jobs: python-version: ${{ matrix.python-version }} os: ${{ runner.os }} # DuckDB has no Python 3.15 wheels yet; source builds dominate setup time. - exclude-packages: ${{ matrix.python-version == '3.10' && 'agent-framework-github-copilot agent-framework-azure-cosmos-memory' || matrix.python-version == '3.15' && 'agent-framework-sql-server agent-framework-duckdb agent-framework-redis' || '' }} + 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-sql-server agent-framework-duckdb agent-framework-redis' || '' }} 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 262255dbffe..59ed6f4c1b5 100644 --- a/python/packages/core/pyproject.toml +++ b/python/packages/core/pyproject.toml @@ -51,7 +51,7 @@ all = [ "agent-framework-durabletask>=1.0.0b260922,<2", "agent-framework-foundry>=1.14.1,<2", "agent-framework-foundry-hosting>=1.0.0b261008,<2", - "agent-framework-foundry-local>=1.0.0b260730,<2", + "agent-framework-foundry-local>=1.0.0b260730,<2; python_version >= '3.11'", "agent-framework-gemini>=1.0.0b261008,<2", "agent-framework-github-copilot>=2.0.2,<3; python_version >= '3.11'", "agent-framework-hyperlight>=1.0.0b261008,<2; python_version < '3.15' and ((sys_platform == 'linux' and platform_machine == 'x86_64') or (sys_platform == 'win32' and platform_machine == 'AMD64'))", diff --git a/python/packages/foundry_local/AGENTS.md b/python/packages/foundry_local/AGENTS.md index 9f208b8e40e..e270385c0ef 100644 --- a/python/packages/foundry_local/AGENTS.md +++ b/python/packages/foundry_local/AGENTS.md @@ -2,11 +2,15 @@ Integration with Microsoft Foundry Local for local model inference. +Uses `foundry_local_sdk` (SDK 2.1+, below 3.0) on Python 3.11+. Clients share +`FoundryLocalManager.instance` and use its built-in web service with the existing +OpenAI transport. Model lifecycle operations live on catalog model objects. + ## Main Classes - **`FoundryLocalClient`** - Chat client for Foundry Local models - **`FoundryLocalChatOptions`** - Options TypedDict for Foundry Local parameters -- **`FoundryLocalSettings`** - Pydantic settings for configuration +- **`FoundryLocalSettings`** - Settings TypedDict for model configuration ## Usage diff --git a/python/packages/foundry_local/README.md b/python/packages/foundry_local/README.md index 269219fac2d..0f862bb721f 100644 --- a/python/packages/foundry_local/README.md +++ b/python/packages/foundry_local/README.md @@ -1,6 +1,7 @@ # Get Started with Microsoft Agent Framework Foundry Local -Please install this package as the extra for `agent-framework`: +Requires Python 3.11 or newer and Foundry Local SDK 2.1 or newer (below 3.0). +Install the integration: ```bash pip install agent-framework-foundry-local --pre @@ -8,6 +9,47 @@ pip install agent-framework-foundry-local --pre and see the [README](https://github.com/microsoft/agent-framework/tree/main/python/README.md) for more information. +## Usage + +```python +from agent_framework import Agent +from agent_framework.foundry import FoundryLocalClient + +client = FoundryLocalClient(model="phi-4-mini") +agent = Agent(client=client, instructions="You are a helpful assistant.") +response = await agent.run("Why is the sky blue?") +print(response.text) +``` + +The client initializes the Foundry Local SDK singleton, prepares the selected model, +and starts its built-in OpenAI-compatible HTTP service. Streaming, middleware, and +function invocation continue to use Agent Framework's OpenAI transport. + +All clients in a process share the SDK manager and web service. To customize cache +directories or other SDK settings, pass `configuration=Configuration(app_name="my-app", ...)` +when creating the first client, or initialize `FoundryLocalManager` yourself before +creating clients. Register accelerated execution providers through the SDK manager +before selecting GPU/NPU variants. A `device` value of `"CPU"`, `"GPU"`, or `"NPU"` +selects a matching variant; an exact model ID is never replaced by a sibling variant. + +`bootstrap=False` requires an already initialized SDK manager with a running web +service in the same process. `prepare_model=False` skips native download/load during +construction. `timeout` applies to HTTP inference requests, not native preparation. + +Clients do not close or unload the shared manager. After all clients finish, close +their HTTP clients and call `client.manager.close()` once at application shutdown. +If construction fails after creating a new manager, that manager is closed; an +existing application's manager is left untouched. + +## Migration from SDK 0.5.1 + +This is a breaking SDK migration: Python 3.10 and SDK 0.5.1/1.x are no longer supported. +Update SDK imports from `foundry_local` to `foundry_local_sdk`. Use +`client.manager.catalog.list_models()` instead of `list_catalog_models()`, inspect +metadata through `model.info`, and call `download()`/`load()` on catalog model objects +instead of `download_model()`/`load_model()` on the manager. The SDK now runs in-process; +a separately started CLI service does not satisfy `bootstrap=False`. + ## Foundry Local Sample See the [Foundry Local provider sample](../../samples/02-agents/providers/foundry/foundry_local_agent.py) for a runnable example. 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..d32252c3344 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,9 +22,8 @@ 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 openai import AsyncOpenAI +from foundry_local_sdk import Configuration, DeviceType, FoundryLocalManager +from openai import DEFAULT_TIMEOUT, AsyncOpenAI from pydantic import BaseModel from ._feature_usage import FeatureIndex @@ -222,7 +221,8 @@ def __init__( bootstrap: bool = True, timeout: float | None = None, prepare_model: bool = True, - device: DeviceType | None = None, + device: DeviceType | Literal["CPU", "GPU", "NPU"] | None = None, + configuration: Configuration | None = None, additional_properties: dict[str, Any] | None = None, middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, @@ -234,10 +234,11 @@ 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. - timeout: Optional timeout for requests to Foundry Local. - This timeout is applied to any call to the Foundry Local service. + bootstrap: Whether to initialize the SDK singleton and start its built-in web + service if needed. Default is True. When False, the manager and web + service must already be running in this process. + timeout: Optional timeout for HTTP inference requests. Uses the OpenAI + client's default when omitted; does not apply to native model preparation. prepare_model: Whether to download the model into the cache, and load the model into the inferencing service upon initialization. Default is True. If false, the first call to generate a completion will load the model, @@ -245,7 +246,10 @@ 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. + Accepts "CPU", "GPU", "NPU", or foundry_local_sdk.DeviceType. + configuration: SDK configuration used when initializing a new manager. + Defaults to Configuration(app_name="agent-framework"). To share a manager, + initialize it before creating clients and omit this parameter. 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. @@ -261,7 +265,10 @@ def __init__( client = FoundryLocalClient(model="phi-4-mini") - agent = client.as_agent( + from agent_framework import Agent + + agent = Agent( + client=client, name="LocalAgent", instructions="You are a helpful agent.", tools=get_weather, @@ -272,14 +279,13 @@ def __init__( os.environ["FOUNDRY_LOCAL_MODEL"] = "phi-4-mini" client = FoundryLocalClient() - # A FoundryLocalManager is created and if set, the service is started. - # The FoundryLocalManager is available via the `manager` property. + # The SDK singleton is shared by all clients in this process. # 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} for {model.info.task} - 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,28 +298,28 @@ 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) - - # You can also use the CLI: - `foundry model load phi-4-mini --device Auto` + # Alternatively, prepare the exact resolved variant manually. + model = client.manager.catalog.get_model_variant(client.model) + if model is not None: + model.download() + model.load() # Using custom ChatOptions with type safety: from typing import TypedDict from agent_framework.foundry import FoundryLocalChatOptions + class MyOptions(FoundryLocalChatOptions, total=False): my_custom_option: str + client: FoundryLocalClient[MyOptions] = FoundryLocalClient(model="phi-4-mini") response = await client.get_response("Hello", options={"my_custom_option": "value"}) Raises: - ValueError: If the specified model ID or alias is not found. - Sometimes a model might be available but if you have specified a device - type that is not supported by the model, it will not be found. + ValueError: If the model or requested device is unavailable, bootstrap is + disabled without a running manager/service, or configuration is supplied + for an already initialized manager. """ settings = load_settings( @@ -324,29 +330,74 @@ class MyOptions(FoundryLocalChatOptions, total=False): env_file_path=env_file_path, env_file_encoding=env_file_encoding, ) - model_setting: str = settings["model"] # type: ignore[assignment] + model_setting = cast(str, settings.get("model")) + device_type = DeviceType(device) if device is not None else None + + manager = FoundryLocalManager.instance + owns_manager = manager is None + if manager is None: + if not bootstrap: + raise ValueError( + "bootstrap=False requires an initialized Foundry Local manager and running web service." + ) + FoundryLocalManager.initialize(configuration or Configuration(app_name="agent-framework")) + manager = FoundryLocalManager.instance + if manager is None: + raise RuntimeError("Foundry Local manager initialization did not create a singleton.") + elif configuration is not None: + raise ValueError( + "Foundry Local is already initialized; configure the shared manager before creating clients." + ) - 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.") + initialized = False + try: + if not manager.urls: + if not bootstrap: + raise ValueError("bootstrap=False requires a running Foundry Local web service.") + manager.start_web_service() + if not manager.urls: + raise ValueError("Foundry Local web service did not provide a bound URL.") + + is_variant_id = ":" in model_setting + model_handle = ( + manager.catalog.get_model_variant(model_setting) + if is_variant_id + else manager.catalog.get_model(model_setting) ) - raise ValueError(message) - if prepare_model: - manager.download_model(model_info.id, device=device) - manager.load_model(model_info.id, device=device) - - super().__init__( - model=model_info.id, - async_client=AsyncOpenAI(base_url=manager.endpoint, api_key=manager.api_key), - additional_properties=additional_properties, - middleware=middleware, - function_invocation_configuration=function_invocation_configuration, - ) - self.manager = manager + if model_handle is not None and device_type is not None: + model_handle = next( + ( + variant + for variant in ([model_handle] if is_variant_id else model_handle.variants) + if (runtime := variant.info.runtime) is not None and runtime.device_type == device_type + ), + None, + ) + elif model_handle is not None and not is_variant_id: + # Pin the alias's chosen variant before download changes the cached preference. + model_handle = manager.catalog.get_model_variant(model_handle.id) + if model_handle is None: + target = f"{model_setting}:{device_type.value}" if device_type is not None else model_setting + raise ValueError(f"Model with ID or alias '{target}' not found in Foundry Local.") + + model_id = model_handle.id + if prepare_model: + model_handle.download() + model_handle.load() + + super().__init__( + model=model_id, + async_client=AsyncOpenAI( + base_url=f"{manager.urls[0].rstrip('/')}/v1", + api_key="foundry-local", + timeout=timeout if timeout is not None else DEFAULT_TIMEOUT, + ), + additional_properties=additional_properties, + middleware=middleware, + function_invocation_configuration=function_invocation_configuration, + ) + self.manager = manager + initialized = True + finally: + if owns_manager and not initialized: + manager.close() diff --git a/python/packages/foundry_local/pyproject.toml b/python/packages/foundry_local/pyproject.toml index 2ae599ca524..d8877d47828 100644 --- a/python/packages/foundry_local/pyproject.toml +++ b/python/packages/foundry_local/pyproject.toml @@ -3,7 +3,7 @@ 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" +requires-python = ">=3.11" version = "1.0.0b260730" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" @@ -15,7 +15,6 @@ 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", @@ -25,7 +24,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.1.0,<3", ] [tool.uv] @@ -66,7 +65,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..b03ef29576e 100644 --- a/python/packages/foundry_local/tests/conftest.py +++ b/python/packages/foundry_local/tests/conftest.py @@ -1,7 +1,10 @@ # Copyright (c) Microsoft. All rights reserved. +from collections.abc import Iterator +from types import SimpleNamespace from typing import Any -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch +from foundry_local_sdk import Catalog, DeviceType, FoundryLocalManager, IModel from pytest import fixture @@ -42,14 +45,19 @@ def foundry_local_unit_test_env(monkeypatch: Any, exclude_list: list[str], overr @fixture -def mock_foundry_local_manager() -> MagicMock: +def mock_foundry_local_manager() -> Iterator[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 - - return mock_manager + mock_manager = MagicMock(spec=FoundryLocalManager) + mock_manager.urls = ["http://localhost:5272"] + mock_manager.catalog = MagicMock(spec=Catalog) + + mock_model = MagicMock(spec=IModel) + mock_model.id = "test-model-id" + mock_model.info = SimpleNamespace(runtime=SimpleNamespace(device_type=DeviceType.CPU)) + mock_model.variants = [mock_model] + mock_manager.catalog.get_model.return_value = mock_model + mock_manager.catalog.get_model_variant.return_value = mock_model + + with patch("agent_framework_foundry_local._foundry_local_client.FoundryLocalManager") as manager_class: + manager_class.instance = mock_manager + yield 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..8d91d0e1f4f 100644 --- a/python/packages/foundry_local/tests/test_foundry_local_client.py +++ b/python/packages/foundry_local/tests/test_foundry_local_client.py @@ -1,232 +1,320 @@ # Copyright (c) Microsoft. All rights reserved. import inspect +import json +from types import SimpleNamespace +from typing import Literal from unittest.mock import MagicMock, patch +import httpx import pytest from agent_framework import Agent, SupportsChatGetResponse from agent_framework._settings import load_settings from agent_framework.exceptions import SettingNotFoundError from agent_framework.foundry import FoundryLocalClient +from foundry_local_sdk import Configuration, DeviceType, IModel +from openai import AsyncOpenAI from agent_framework_foundry_local._feature_usage import FeatureIndex from agent_framework_foundry_local._foundry_local_client import FoundryLocalSettings -# Settings Tests - def test_foundry_local_owns_foundry_local_feature() -> None: assert FoundryLocalClient._FEATURE_USAGE_INDEX is FeatureIndex.FOUNDRY_LOCAL def test_foundry_local_settings_init_from_env(foundry_local_unit_test_env: dict[str, str]) -> None: - """Test FoundryLocalSettings initialization from environment variables.""" settings = load_settings(FoundryLocalSettings, env_prefix="FOUNDRY_LOCAL_") - assert settings["model"] == foundry_local_unit_test_env["FOUNDRY_LOCAL_MODEL"] def test_foundry_local_settings_init_with_explicit_values() -> None: - """Test FoundryLocalSettings initialization with explicit values.""" - settings = load_settings( - FoundryLocalSettings, - env_prefix="FOUNDRY_LOCAL_", - model="custom-model-id", - ) - + settings = load_settings(FoundryLocalSettings, env_prefix="FOUNDRY_LOCAL_", model="custom-model-id") assert settings["model"] == "custom-model-id" @pytest.mark.parametrize("exclude_list", [["FOUNDRY_LOCAL_MODEL"]], indirect=True) def test_foundry_local_settings_missing_model(foundry_local_unit_test_env: dict[str, str]) -> None: - """Test FoundryLocalSettings when model is missing raises error.""" with pytest.raises(SettingNotFoundError, match="Required setting 'model'"): - load_settings( - FoundryLocalSettings, - env_prefix="FOUNDRY_LOCAL_", - required_fields=["model"], - ) + load_settings(FoundryLocalSettings, env_prefix="FOUNDRY_LOCAL_", required_fields=["model"]) def test_foundry_local_settings_explicit_overrides_env(foundry_local_unit_test_env: dict[str, str]) -> None: - """Test that explicit values override environment variables.""" settings = load_settings(FoundryLocalSettings, env_prefix="FOUNDRY_LOCAL_", model="override-model-id") - assert settings["model"] == "override-model-id" assert settings["model"] != foundry_local_unit_test_env["FOUNDRY_LOCAL_MODEL"] -# Client Initialization Tests - - 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", - return_value=mock_foundry_local_manager, - ): - client = FoundryLocalClient(model="test-model-id") + client = FoundryLocalClient(model="test-model-id") - assert client.model == "test-model-id" - assert client.manager is mock_foundry_local_manager - assert isinstance(client, SupportsChatGetResponse) + assert client.model == "test-model-id" + assert client.manager is mock_foundry_local_manager + assert isinstance(client, SupportsChatGetResponse) + assert str(client.client.base_url) == "http://localhost:5272/v1/" + assert client.client.api_key == "foundry-local" def test_agent_accepts_foundry_local_client(mock_foundry_local_manager: MagicMock) -> None: - with patch( - "agent_framework_foundry_local._foundry_local_client.FoundryLocalManager", - return_value=mock_foundry_local_manager, - ): - client = FoundryLocalClient(model="test-model-id") - + client = FoundryLocalClient(model="test-model-id") agent = Agent(client=client, instructions="test agent") assert agent.client is client def test_foundry_local_client_get_response_uses_explicit_runtime_buckets() -> None: - """Foundry Local should expose explicit runtime buckets instead of raw kwargs.""" signature = inspect.signature(FoundryLocalClient.get_response) - assert "client_kwargs" in signature.parameters assert "function_invocation_kwargs" in signature.parameters assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) -def test_foundry_local_client_init_with_bootstrap_false(mock_foundry_local_manager: MagicMock) -> None: - """Test FoundryLocalClient initialization with bootstrap=False.""" - with patch( - "agent_framework_foundry_local._foundry_local_client.FoundryLocalManager", - return_value=mock_foundry_local_manager, - ) as mock_manager_class: - FoundryLocalClient(model="test-model-id", bootstrap=False) +def test_foundry_local_client_initializes_singleton(mock_foundry_local_manager: MagicMock) -> None: + with patch("agent_framework_foundry_local._foundry_local_client.FoundryLocalManager") as manager_class: + manager_class.instance = None + manager_class.initialize.side_effect = lambda _: setattr(manager_class, "instance", mock_foundry_local_manager) - mock_manager_class.assert_called_once_with( - bootstrap=False, - timeout=None, - ) + client = FoundryLocalClient(model="test-model-id") + manager_class.initialize.assert_called_once() + assert manager_class.initialize.call_args.args[0].app_name == "agent-framework" + assert client.manager is mock_foundry_local_manager -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", - return_value=mock_foundry_local_manager, - ) as mock_manager_class: - FoundryLocalClient(model="test-model-id", timeout=60.0) - - mock_manager_class.assert_called_once_with( - bootstrap=True, - timeout=60.0, - ) +def test_foundry_local_client_accepts_configuration(mock_foundry_local_manager: MagicMock) -> None: + configuration = Configuration(app_name="my-app", model_cache_dir="my-models") + with patch("agent_framework_foundry_local._foundry_local_client.FoundryLocalManager") as manager_class: + manager_class.instance = None + manager_class.initialize.side_effect = lambda _: setattr(manager_class, "instance", mock_foundry_local_manager) + + FoundryLocalClient(model="test-model-id", configuration=configuration) + + manager_class.initialize.assert_called_once_with(configuration) + + +def test_foundry_local_client_reuses_singleton(mock_foundry_local_manager: MagicMock) -> None: + with patch("agent_framework_foundry_local._foundry_local_client.FoundryLocalManager") as manager_class: + manager_class.instance = mock_foundry_local_manager + FoundryLocalClient(model="test-model-id") + FoundryLocalClient(model="test-model-id") + manager_class.initialize.assert_not_called() + mock_foundry_local_manager.start_web_service.assert_not_called() + + +def test_foundry_local_client_rejects_configuration_for_existing_manager(mock_foundry_local_manager: MagicMock) -> None: + with pytest.raises(ValueError, match="already initialized"): + FoundryLocalClient(model="test-model-id", configuration=Configuration(app_name="another-app")) -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 +def test_foundry_local_client_bootstrap_false_requires_manager(mock_foundry_local_manager: MagicMock) -> None: with ( - patch( - "agent_framework_foundry_local._foundry_local_client.FoundryLocalManager", - return_value=mock_foundry_local_manager, - ), - pytest.raises(ValueError, match="not found in Foundry Local"), + patch("agent_framework_foundry_local._foundry_local_client.FoundryLocalManager") as manager_class, + pytest.raises(ValueError, match="bootstrap=False"), ): + manager_class.instance = None + FoundryLocalClient(model="test-model-id", bootstrap=False) + manager_class.initialize.assert_not_called() + + +def test_foundry_local_client_bootstrap_false_requires_service(mock_foundry_local_manager: MagicMock) -> None: + mock_foundry_local_manager.urls = None + with pytest.raises(ValueError, match="bootstrap=False"): + FoundryLocalClient(model="test-model-id", bootstrap=False) + mock_foundry_local_manager.start_web_service.assert_not_called() + + +def test_foundry_local_client_bootstrap_false_reuses_service(mock_foundry_local_manager: MagicMock) -> None: + client = FoundryLocalClient(model="test-model-id", bootstrap=False) + assert client.manager is mock_foundry_local_manager + mock_foundry_local_manager.start_web_service.assert_not_called() + + +def test_foundry_local_client_starts_web_service(mock_foundry_local_manager: MagicMock) -> None: + mock_foundry_local_manager.urls = None + mock_foundry_local_manager.start_web_service.side_effect = lambda: setattr( + mock_foundry_local_manager, "urls", ["http://127.0.0.1:4321/"] + ) + client = FoundryLocalClient(model="test-model-id") + mock_foundry_local_manager.start_web_service.assert_called_once_with() + assert str(client.client.base_url) == "http://127.0.0.1:4321/v1/" + + +def test_foundry_local_client_requires_bound_url(mock_foundry_local_manager: MagicMock) -> None: + mock_foundry_local_manager.urls = [] + with pytest.raises(ValueError, match="bound URL"): + FoundryLocalClient(model="test-model-id") + + +def test_foundry_local_client_init_with_timeout(mock_foundry_local_manager: MagicMock) -> None: + client = FoundryLocalClient(model="test-model-id", timeout=60.0) + assert client.client.timeout == 60.0 + + +def test_foundry_local_client_default_timeout(mock_foundry_local_manager: MagicMock) -> None: + client = FoundryLocalClient(model="test-model-id") + assert client.client.timeout is not None + + +def test_foundry_local_client_init_model_not_found(mock_foundry_local_manager: MagicMock) -> None: + mock_foundry_local_manager.catalog.get_model.return_value = None + with pytest.raises(ValueError, match="not found in Foundry Local"): 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_uses_resolved_variant_id(mock_foundry_local_manager: MagicMock) -> None: + mock_model = mock_foundry_local_manager.catalog.get_model_variant.return_value + mock_model.id = "resolved-model-id:1" - with patch( - "agent_framework_foundry_local._foundry_local_client.FoundryLocalManager", - return_value=mock_foundry_local_manager, - ): - client = FoundryLocalClient(model="model-alias") + client = FoundryLocalClient(model="model-alias") + + assert client.model == "resolved-model-id:1" + mock_foundry_local_manager.catalog.get_model_variant.assert_called_once_with("resolved-model-id:1") + mock_model.download.assert_called_once_with() + mock_model.load.assert_called_once_with() + + +def test_foundry_local_client_exact_id_does_not_resolve_alias(mock_foundry_local_manager: MagicMock) -> None: + mock_model = mock_foundry_local_manager.catalog.get_model_variant.return_value + mock_model.id = "exact-variant:1" - assert client.model == "resolved-model-id" + client = FoundryLocalClient(model="exact-variant:1") + + assert client.model == "exact-variant:1" + mock_foundry_local_manager.catalog.get_model_variant.assert_called_once_with("exact-variant:1") + mock_foundry_local_manager.catalog.get_model.assert_not_called() 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.""" - with patch( - "agent_framework_foundry_local._foundry_local_client.FoundryLocalManager", - return_value=mock_foundry_local_manager, - ): - client = FoundryLocalClient() + client = FoundryLocalClient() + assert client.model == foundry_local_unit_test_env["FOUNDRY_LOCAL_MODEL"] - assert client.model == foundry_local_unit_test_env["FOUNDRY_LOCAL_MODEL"] +@pytest.mark.parametrize("device", [DeviceType.GPU, "GPU"]) +def test_foundry_local_client_selects_device_variant( + mock_foundry_local_manager: MagicMock, device: DeviceType | Literal["GPU"] +) -> None: + mock_alias = mock_foundry_local_manager.catalog.get_model.return_value + mock_gpu = MagicMock(spec=IModel) + mock_gpu.id = "gpu-variant:1" + mock_gpu.info = SimpleNamespace(runtime=SimpleNamespace(device_type=DeviceType.GPU)) + mock_alias.variants = [mock_foundry_local_manager.catalog.get_model_variant.return_value, mock_gpu] -def test_foundry_local_client_init_with_device(mock_foundry_local_manager: MagicMock) -> None: - """Test FoundryLocalClient initialization with device parameter.""" - from foundry_local.models import DeviceType + client = FoundryLocalClient(model="model-alias", device=device) - with patch( - "agent_framework_foundry_local._foundry_local_client.FoundryLocalManager", - return_value=mock_foundry_local_manager, - ): - FoundryLocalClient(model="test-model-id", device=DeviceType.CPU) + assert client.model == "gpu-variant:1" + mock_gpu.download.assert_called_once_with() + mock_gpu.load.assert_called_once_with() + mock_alias.select_variant.assert_not_called() - 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, - ) +def test_foundry_local_client_exact_id_rejects_wrong_device(mock_foundry_local_manager: MagicMock) -> None: + with pytest.raises(ValueError, match="exact-variant:1:GPU.*not found"): + FoundryLocalClient(model="exact-variant:1", device=DeviceType.GPU) + mock_foundry_local_manager.catalog.get_model.return_value.download.assert_not_called() -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_missing_device_variant(mock_foundry_local_manager: MagicMock) -> None: + with pytest.raises(ValueError, match="model-alias:GPU.*not found"): + FoundryLocalClient(model="model-alias", device=DeviceType.GPU) - with ( - patch( - "agent_framework_foundry_local._foundry_local_client.FoundryLocalManager", - return_value=mock_foundry_local_manager, - ), - pytest.raises(ValueError, match="unknown-model:GPU.*not found"), - ): - FoundryLocalClient(model="unknown-model", device=DeviceType.GPU) + +def test_foundry_local_client_unknown_runtime_does_not_match_device(mock_foundry_local_manager: MagicMock) -> None: + mock_model = mock_foundry_local_manager.catalog.get_model_variant.return_value + mock_model.info.runtime = None + with pytest.raises(ValueError, match="model-alias:CPU.*not found"): + FoundryLocalClient(model="model-alias", device=DeviceType.CPU) + + +def test_foundry_local_client_invalid_device(mock_foundry_local_manager: MagicMock) -> None: + with pytest.raises(ValueError, match="DeviceType"): + FoundryLocalClient(model="model-alias", device="invalid") # pyright: ignore[reportArgumentType] + mock_foundry_local_manager.catalog.get_model.assert_not_called() def test_foundry_local_client_init_with_prepare_model_false(mock_foundry_local_manager: MagicMock) -> None: - """Test FoundryLocalClient initialization with prepare_model=False skips download and load.""" - with patch( - "agent_framework_foundry_local._foundry_local_client.FoundryLocalManager", - return_value=mock_foundry_local_manager, - ): - FoundryLocalClient(model="test-model-id", prepare_model=False) + FoundryLocalClient(model="test-model-id", prepare_model=False) + mock_model = mock_foundry_local_manager.catalog.get_model_variant.return_value + mock_model.download.assert_not_called() + mock_model.load.assert_not_called() - mock_foundry_local_manager.download_model.assert_not_called() - mock_foundry_local_manager.load_model.assert_not_called() +def test_foundry_local_client_does_not_hide_sdk_failure(mock_foundry_local_manager: MagicMock) -> None: + mock_foundry_local_manager.catalog.get_model.side_effect = RuntimeError("catalog unavailable") + with pytest.raises(RuntimeError, match="catalog unavailable"): + FoundryLocalClient(model="model-alias") + mock_foundry_local_manager.close.assert_not_called() + + +def test_foundry_local_client_closes_new_manager_on_failure(mock_foundry_local_manager: MagicMock) -> None: + mock_foundry_local_manager.catalog.get_model.return_value = None + with patch("agent_framework_foundry_local._foundry_local_client.FoundryLocalManager") as manager_class: + manager_class.instance = None + manager_class.initialize.side_effect = lambda _: setattr(manager_class, "instance", mock_foundry_local_manager) + + with pytest.raises(ValueError, match="not found in Foundry Local"): + FoundryLocalClient(model="unknown-model") + + mock_foundry_local_manager.close.assert_called_once_with() + + +def test_foundry_local_client_keeps_new_manager_on_success(mock_foundry_local_manager: MagicMock) -> None: + with patch("agent_framework_foundry_local._foundry_local_client.FoundryLocalManager") as manager_class: + manager_class.instance = None + manager_class.initialize.side_effect = lambda _: setattr(manager_class, "instance", mock_foundry_local_manager) -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.""" - with patch( - "agent_framework_foundry_local._foundry_local_client.FoundryLocalManager", - 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.close.assert_not_called() + + +def test_foundry_local_client_does_not_hide_web_service_failure(mock_foundry_local_manager: MagicMock) -> None: + mock_foundry_local_manager.urls = None + mock_foundry_local_manager.start_web_service.side_effect = RuntimeError("service unavailable") + with pytest.raises(RuntimeError, match="service unavailable"): + FoundryLocalClient(model="test-model-id") + mock_foundry_local_manager.close.assert_not_called() + + +@pytest.mark.parametrize("stream", [False, True]) +async def test_foundry_local_client_uses_openai_transport(mock_foundry_local_manager: MagicMock, stream: bool) -> None: + requests: list[httpx.Request] = [] + + def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + completion = { + "id": "completion-1", + "created": 0, + "model": "test-model-id", + "object": "chat.completion", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "Hello"}, "finish_reason": "stop"}], + } + if not stream: + return httpx.Response(200, json=completion) + completion["object"] = "chat.completion.chunk" + completion["choices"] = [ + {"index": 0, "delta": {"role": "assistant", "content": "Hello"}, "finish_reason": "stop"} + ] + return httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + text=f"data: {json.dumps(completion)}\n\ndata: [DONE]\n\n", ) - mock_foundry_local_manager.load_model.assert_called_once_with( - "test-model-id", - device=None, + + async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http_client: + async_client = AsyncOpenAI( + base_url="http://localhost:5272/v1", api_key="foundry-local", http_client=http_client ) + with patch("agent_framework_foundry_local._foundry_local_client.AsyncOpenAI", return_value=async_client): + client = FoundryLocalClient(model="test-model-id", prepare_model=False) + if stream: + result = "".join([update.text async for update in client.get_response("Hi", stream=True)]) + else: + result = (await client.get_response("Hi")).text + + assert result == "Hello" + assert len(requests) == 1 + assert requests[0].url.path == "/v1/chat/completions" + payload = json.loads(requests[0].content) + assert payload["model"] == "test-model-id" + assert payload["stream"] is stream 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..6583720e723 100644 --- a/python/samples/02-agents/providers/foundry/foundry_local_agent.py +++ b/python/samples/02-agents/providers/foundry/foundry_local_agent.py @@ -58,21 +58,25 @@ async def main() -> None: print("=== Basic Foundry Local Client Agent Example ===") 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(): - if model.supports_tool_calling: - print( - f"- {model.alias} for {model.task} - id={model.id} - {(model.file_size_mb / 1000):.2f} GB - {model.license}" - ) - agent = Agent( - client=client, - name="LocalAgent", - instructions="You are a helpful agent.", - tools=get_weather, - ) - await non_streaming_example(agent) - await streaming_example(agent) + try: + print(f"Client Model ID: {client.model}\n") + print("Other available models (tool calling supported only):") + for model in client.manager.catalog.list_models(): + if model.supports_tool_calling: + info = model.info + size = f"{info.file_size_mb / 1000:.2f} GB" if info.file_size_mb is not None else "size unknown" + print(f"- {model.alias} for {info.task} - id={model.id} - {size} - {info.license}") + agent = Agent( + client=client, + name="LocalAgent", + instructions="You are a helpful agent.", + tools=get_weather, + ) + await non_streaming_example(agent) + await streaming_example(agent) + finally: + await client.client.close() + client.manager.close() if __name__ == "__main__": From 5a7afff708e6000ba0715eb48b0d14fc9e4cf737 Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Mon, 12 Oct 2026 15:33:53 -0700 Subject: [PATCH 02/17] Python: lock current Foundry Local SDK runtime dependencies Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d8ebdb3c-28b8-4fa1-830d-3fa7d6333d9e --- python/uv.lock | 90 ++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 72 insertions(+), 18 deletions(-) diff --git a/python/uv.lock b/python/uv.lock index 13726f1d35c..10fad0375e2 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -501,7 +501,7 @@ requires-dist = [ { name = "agent-framework-durabletask", marker = "extra == 'all'", specifier = ">=1.0.0b260922,<2" }, { name = "agent-framework-foundry", marker = "extra == 'all'", editable = "packages/foundry" }, { name = "agent-framework-foundry-hosting", marker = "extra == 'all'", editable = "packages/foundry_hosting" }, - { name = "agent-framework-foundry-local", marker = "extra == 'all'", editable = "packages/foundry_local" }, + { name = "agent-framework-foundry-local", marker = "python_full_version >= '3.11' and extra == 'all'", editable = "packages/foundry_local" }, { name = "agent-framework-gemini", marker = "extra == 'all'", editable = "packages/gemini" }, { name = "agent-framework-github-copilot", marker = "python_full_version >= '3.11' and extra == 'all'", editable = "packages/github_copilot" }, { name = "agent-framework-hyperlight", marker = "(python_full_version < '3.15' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'all') or (python_full_version < '3.15' and platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'all')", editable = "packages/hyperlight" }, @@ -690,7 +690,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.1.0,<3" }, ] [[package]] @@ -2174,17 +2174,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.1.0" 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/26/53f7ad8b1015861b4b485796e5f81d2423d0c9cb1b7ffa5063e9da515d7e/foundry_local_sdk-2.1.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b1d24f24945be74b651b5a5e9c3c85e17d08e15f50b27b22a6ecd5bc23889824", size = 6602643, upload-time = "2026-09-29T23:04:24.263Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/644d2c00941850c67c351284c13c9190d6faa41b6c632ee5c71966739252/foundry_local_sdk-2.1.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:93d8c398be098d4fc5e25bedf48248bc5a273b658aec6c0bee92d3fc4f84e9fe", size = 8435620, upload-time = "2026-09-29T23:04:26.371Z" }, + { url = "https://files.pythonhosted.org/packages/10/3a/dfe75715c97f0c5372c991653c36adf11a3baed63d7b263d59af5651dae4/foundry_local_sdk-2.1.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2044f4cca65f8ce562094860d5ca849fe7c41711aa70000ea4966871e0d43d49", size = 8635403, upload-time = "2026-09-29T23:04:28.574Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fd/5a09876e7c65447c590bf35e9b9b013df1f5849b2a85706639732d1d183f/foundry_local_sdk-2.1.0-cp311-abi3-win_amd64.whl", hash = "sha256:e71c8db275e157ff84ce2db5471ff0c418a42e26d49f2dd88bcb291a8d532022", size = 37527856, upload-time = "2026-09-29T23:04:31.797Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a3/d19bb89667583e3705d46aba0b6dc97e523e9fdb3cbfc1db638b416fb70f/foundry_local_sdk-2.1.0-cp311-abi3-win_arm64.whl", hash = "sha256:822950dc45e9b48f76f9cbfb6aa86f23bba591b2d76bbfa300500228df959f73", size = 37028640, upload-time = "2026-09-29T23:04:35.833Z" }, ] [[package]] @@ -3559,6 +3575,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.30.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.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging" }, + { name = "protobuf" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/b6/e2fa6dfc8c3cd19639d452c3120fb0e02f5176b4e24ecabb24b5362be725/onnxruntime-1.30.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:f36c87ec504f4f328e968ee322e4ba9682abe66280b3425625afcbed5333fed7", size = 21533037, upload-time = "2026-09-10T16:31:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/4f/1a/4d197d2676a866f6239cd2d06a95b6db40d47dc29d5bec9fb1493a13dd94/onnxruntime-1.30.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bac3aee7b40acd805659fd81a2acd6096312d5b0f014c97ddfe75fb4c1644627", size = 21332697, upload-time = "2026-09-10T16:31:08.118Z" }, + { url = "https://files.pythonhosted.org/packages/4f/2e/4c96278a99140d0307ccda6fd51e6e9d1ca7c9f6fdd1fa96ff375acfc713/onnxruntime-1.30.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:fd54b314ea385bcecac69ab431f020ba503e3878dad4ebb645fec5a24b041242", size = 23561046, upload-time = "2026-09-10T16:31:11.494Z" }, + { url = "https://files.pythonhosted.org/packages/66/68/b8aaade8c1883f569228f4b57201cce922bb7d4af6f977d619520a733430/onnxruntime-1.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:0edd0145a6e3fce8a1276491dc784d615e3c58bcb952c9b4e5c876d5c6a12ad7", size = 14309136, upload-time = "2026-09-10T16:31:14.181Z" }, + { url = "https://files.pythonhosted.org/packages/96/f1/fbda6fb08ac4db6c5c6f7c3e128e4d8fb398b52fe2fc3282c45cadedd18b/onnxruntime-1.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:16506cfa5d218850f2b46e0edce1b952121f2f84f8e0074dafe15f4787f0c8fb", size = 14167408, upload-time = "2026-09-10T16:31:16.505Z" }, + { url = "https://files.pythonhosted.org/packages/31/6f/48169f2e62b405bff5053cbd1d73fb5ce41ef7ecd13bb3bfcc191e689b8a/onnxruntime-1.30.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:001ed726c9bd5e2bc92faade7d37d889e9606a350b7d5529f0227df2e3bb57fd", size = 21544867, upload-time = "2026-09-10T16:31:19.876Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/cbc5b8f91963689fdd622f463508c01d0aa95d3f944747b1e0b1eb2160b8/onnxruntime-1.30.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:6c32a000d5139a38ba9349030b0032e3331acb559d596b22738d9d2b343a2b83", size = 21345202, upload-time = "2026-09-10T16:31:23.361Z" }, + { url = "https://files.pythonhosted.org/packages/34/35/e7f862dbacbc99fadd9b14a614e49c99bf0f35fd9927a82f096e3de33531/onnxruntime-1.30.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:fa688e7891a6aa206636fe7372e27ee75fd17713289f6b4fc7b190e0a7de9328", size = 23585654, upload-time = "2026-09-10T16:31:26.65Z" }, + { url = "https://files.pythonhosted.org/packages/a6/13/0f1699f6de549c9324bc9112a2a85b14c517904cd11b562a654643b755a1/onnxruntime-1.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:f3501472571f1b1eee50e017851e7929f5ea37312d2d8c2494a19e8fc58b4a38", size = 14311470, upload-time = "2026-09-10T16:31:31.273Z" }, + { url = "https://files.pythonhosted.org/packages/a2/17/02b13e5f51461f0453b18ab854e2d0bc1b6ec353241b05a2ee6b79e35d87/onnxruntime-1.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:dc4c706f1935ebb62356e6a095b047859badd854482c40560888e95c328ed262", size = 14175072, upload-time = "2026-09-10T16:31:34.541Z" }, + { url = "https://files.pythonhosted.org/packages/f0/75/508454c5d01f31641dabc597fe559594c931a520a2673031179319d0afd8/onnxruntime-1.30.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:05e4fc41711d1f4abd19a9124b5be7a65a506cb2670a7f14b16162ef13c58134", size = 21544990, upload-time = "2026-09-10T16:31:37.685Z" }, + { url = "https://files.pythonhosted.org/packages/89/06/e603c71f43f4fe3fd156a053af79cbed6e27a2c649f0988a67d97fedd39f/onnxruntime-1.30.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:5327cf6aa15a02bad805fac8bd6882a62571e8b72f6f2938a8f37e6bd1966ce9", size = 21344996, upload-time = "2026-09-10T16:31:40.915Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a1/ede48ab5dc54907a2999362777f541e132639fb06628ded1932058aa8a36/onnxruntime-1.30.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:86f940afc801ea9681a4da8af84fbe95e1d9ea7d80903952cc1bfad54faad38f", size = 23585560, upload-time = "2026-09-10T16:31:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/3c/dd/c57c529dbc6dd55eca24b12cfbeab1b6a690de72083824eca689085f55b0/onnxruntime-1.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:4b63041bd623a9a9ac5e353948436c6fa7f43edd12d6b4a4ebc340bca959ba93", size = 14311378, upload-time = "2026-09-10T16:31:48.034Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/ef00b45b911e7f2115273a24ed1b43e73c8c6c9ec17f9bfa13f8492581f8/onnxruntime-1.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:c389b6887fc95e0fcb80e89b156bc2cb18e662c29df55e9326fe64140e7d7b4f", size = 14175134, upload-time = "2026-09-10T16:31:50.799Z" }, + { url = "https://files.pythonhosted.org/packages/92/0a/284fd6fe701c9a8aff39dbc119c37582ca40e9cff1ea05425a7cc8606a02/onnxruntime-1.30.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:e5224ba2b00284cb1c48b3edcd303c109245d66df9ec1b861858fd6de672a38e", size = 21349453, upload-time = "2026-09-10T16:31:53.898Z" }, + { url = "https://files.pythonhosted.org/packages/d6/72/4f4466f8fa1ec267a9ef5e2f3bd175c203d3da0facbdf4049dc93abbe91c/onnxruntime-1.30.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:3f9e002417f1e3bbb31ed43dafa4f22ca1b2b68832244fdee192fcaa1ae19bca", size = 23579837, upload-time = "2026-09-10T16:31:56.82Z" }, + { url = "https://files.pythonhosted.org/packages/6a/03/05c9a9234688757d2876ddecf80bba908561ee11debf125bc1a427ae6f48/onnxruntime-1.30.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:8b6169c16a48429890d2f4a0c774ebf54dfe9066a998514aad0518a16d398547", size = 21545313, upload-time = "2026-09-10T16:31:59.654Z" }, + { url = "https://files.pythonhosted.org/packages/c6/bc/1069e58b24779ba9d2fd479db5ecb3a15a6f49b585107c898819c0789558/onnxruntime-1.30.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:d2184fddb6798136e7c478244391ca82443f5c757f59f15bb9e5ad2da5e03175", size = 21346830, upload-time = "2026-09-10T16:32:02.275Z" }, + { url = "https://files.pythonhosted.org/packages/f1/38/8138eed225c5bc6ddfc05879ecac7dacc63c34b9b6f99be72839c1f6dc49/onnxruntime-1.30.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:8b611d24db2954545ce6bd9acd4670183cb368e4642450de7a9ab6474eb374ec", size = 23586333, upload-time = "2026-09-10T16:32:04.996Z" }, + { url = "https://files.pythonhosted.org/packages/4d/0a/748b86000fbf9518b5dfc5bdc1b924eeaca976203740fd9d30649e568ee6/onnxruntime-1.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bdd1752a8502ac1a7ccc6e878d16db6943df574d54ed7f01058a6806ae05be4", size = 14675319, upload-time = "2026-09-10T16:32:07.5Z" }, + { url = "https://files.pythonhosted.org/packages/2f/db/db8e4c0cf6f70f1311060560630fd21763648e01dfbba5195a2592c052ef/onnxruntime-1.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:83d543843cbd352cfa9996a6c7b92f8a480f114a18f95ff2a1acaf6921e85b6d", size = 14569360, upload-time = "2026-09-10T16:32:09.896Z" }, + { url = "https://files.pythonhosted.org/packages/24/0a/ec0a9d656e39b43887c378a5388b20c3b1e1ee43bded84580b0936466694/onnxruntime-1.30.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:265de607ba6f9814264e1d5d413fa7069d70f48e3049b5e460b3b04bdfcef294", size = 21348433, upload-time = "2026-09-10T16:32:12.772Z" }, + { url = "https://files.pythonhosted.org/packages/91/f0/40f74b7c00077e1e25627067ed98a70df1fef5c0e21b82849190d312554e/onnxruntime-1.30.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:67ad7f03433b6462c627d0f555dece80e6a26bc71e8542ced35cebd32142d1b7", size = 23579340, upload-time = "2026-09-10T16:32:15.532Z" }, +] + +[[package]] +name = "onnxruntime-genai-core" +version = "0.17.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/36/b77fce1cd96379f6eff16ba9c8ec2232fba51bd03c88a3cfc6f03d2afa7a/onnxruntime_genai_core-0.17.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0b4a59016f30e1d3b016d5886b28ec94e61d5ec1b387ab13c5db55ee392b0768", size = 4241813, upload-time = "2026-09-29T05:51:23.563Z" }, + { url = "https://files.pythonhosted.org/packages/a4/2a/0bfac7f2742868bdeb96faa46107c0ab68aba016bf5b40b4015365e695fa/onnxruntime_genai_core-0.17.1-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:905591a6b8bb3f002933d5fc4352b7c3d3d9a70c43173bba84453e64aa4ef12d", size = 11440816, upload-time = "2026-09-29T05:51:25.477Z" }, + { url = "https://files.pythonhosted.org/packages/40/e4/380b185ae8b2791595be1df5d48b8a7039acf9f5aa02c4a1759710e52ee6/onnxruntime_genai_core-0.17.1-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:07b6a719d7251bf60ceb404b4d645c7a636fe0cd1e476de63f7d1b68418d3e59", size = 11752690, upload-time = "2026-09-29T05:51:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/fd/b2/aff81f8abc40caffe5cd0e9161ab5f620ed84f4eb758aed3574fb5927bc1/onnxruntime_genai_core-0.17.1-py3-none-win_amd64.whl", hash = "sha256:c930c8974def0cb6159390be3fdd1f1c9a0ab13f29d11666dee2f4d255f9f6c6", size = 3727344, upload-time = "2026-09-29T05:51:29.682Z" }, + { url = "https://files.pythonhosted.org/packages/a1/85/8759cb300c012968da1d08f48344327c8f4fa0fcb0623589baf55a5700a4/onnxruntime_genai_core-0.17.1-py3-none-win_arm64.whl", hash = "sha256:32baddf2637c8b392e22cef821c3fc7a7bb39045bc2978880c294edbcfe215fb", size = 3558257, upload-time = "2026-09-29T05:51:30.991Z" }, +] + [[package]] name = "openai" version = "3.23.0" @@ -5469,18 +5535,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.4" From 1bc5bbff98a896f7ba120691655e0be30de09755 Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Mon, 12 Oct 2026 15:38:39 -0700 Subject: [PATCH 03/17] Python: use the OpenAI-selected HTTP transport in regression tests Follow the existing OpenAI test pattern so transport coverage works with both OpenAI 2.x and locked OpenAI 3.x/httpx2. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d8ebdb3c-28b8-4fa1-830d-3fa7d6333d9e --- .../tests/test_foundry_local_client.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) 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 8d91d0e1f4f..6e3c85c2b21 100644 --- a/python/packages/foundry_local/tests/test_foundry_local_client.py +++ b/python/packages/foundry_local/tests/test_foundry_local_client.py @@ -2,22 +2,24 @@ import inspect import json +from importlib import import_module from types import SimpleNamespace -from typing import Literal +from typing import Any, Literal, cast from unittest.mock import MagicMock, patch -import httpx import pytest from agent_framework import Agent, SupportsChatGetResponse from agent_framework._settings import load_settings from agent_framework.exceptions import SettingNotFoundError from agent_framework.foundry import FoundryLocalClient from foundry_local_sdk import Configuration, DeviceType, IModel -from openai import AsyncOpenAI +from openai import AsyncOpenAI, DefaultAsyncHttpxClient from agent_framework_foundry_local._feature_usage import FeatureIndex from agent_framework_foundry_local._foundry_local_client import FoundryLocalSettings +_OPENAI_HTTPX = cast(Any, import_module(DefaultAsyncHttpxClient.__mro__[1].__module__.partition(".")[0])) + def test_foundry_local_owns_foundry_local_feature() -> None: assert FoundryLocalClient._FEATURE_USAGE_INDEX is FeatureIndex.FOUNDRY_LOCAL @@ -278,9 +280,9 @@ def test_foundry_local_client_does_not_hide_web_service_failure(mock_foundry_loc @pytest.mark.parametrize("stream", [False, True]) async def test_foundry_local_client_uses_openai_transport(mock_foundry_local_manager: MagicMock, stream: bool) -> None: - requests: list[httpx.Request] = [] + requests: list[Any] = [] - def respond(request: httpx.Request) -> httpx.Response: + def respond(request: Any) -> Any: requests.append(request) completion = { "id": "completion-1", @@ -290,18 +292,18 @@ def respond(request: httpx.Request) -> httpx.Response: "choices": [{"index": 0, "message": {"role": "assistant", "content": "Hello"}, "finish_reason": "stop"}], } if not stream: - return httpx.Response(200, json=completion) + return _OPENAI_HTTPX.Response(200, json=completion) completion["object"] = "chat.completion.chunk" completion["choices"] = [ {"index": 0, "delta": {"role": "assistant", "content": "Hello"}, "finish_reason": "stop"} ] - return httpx.Response( + return _OPENAI_HTTPX.Response( 200, headers={"content-type": "text/event-stream"}, text=f"data: {json.dumps(completion)}\n\ndata: [DONE]\n\n", ) - async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http_client: + async with DefaultAsyncHttpxClient(transport=_OPENAI_HTTPX.MockTransport(respond)) as http_client: async_client = AsyncOpenAI( base_url="http://localhost:5272/v1", api_key="foundry-local", http_client=http_client ) From 7b9e8042486a7a4352e10e5138ce32015a6a33e5 Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Mon, 12 Oct 2026 15:43:47 -0700 Subject: [PATCH 04/17] Python: reflect the SDK native runtime Python support bounds SDK 2.1 pins ONNX Runtime 1.30.0, which has no Python 3.15 wheels. Exclude Foundry Local from the 3.15 workspace job and align package and extra metadata with Python 3.11-3.14. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d8ebdb3c-28b8-4fa1-830d-3fa7d6333d9e --- .github/workflows/python-tests.yml | 4 ++-- python/packages/core/pyproject.toml | 2 +- python/packages/foundry_local/AGENTS.md | 2 +- python/packages/foundry_local/README.md | 4 +++- python/packages/foundry_local/pyproject.toml | 2 +- 5 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 79ae5a5d23a..b39597a2da4 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 }} - # DuckDB has no Python 3.15 wheels yet; source builds dominate setup time. - 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-sql-server agent-framework-duckdb agent-framework-redis' || '' }} + # DuckDB and Foundry Local's native runtime have no Python 3.15 wheels yet. + 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-sql-server agent-framework-duckdb 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 59ed6f4c1b5..7a30f6de701 100644 --- a/python/packages/core/pyproject.toml +++ b/python/packages/core/pyproject.toml @@ -51,7 +51,7 @@ all = [ "agent-framework-durabletask>=1.0.0b260922,<2", "agent-framework-foundry>=1.14.1,<2", "agent-framework-foundry-hosting>=1.0.0b261008,<2", - "agent-framework-foundry-local>=1.0.0b260730,<2; python_version >= '3.11'", + "agent-framework-foundry-local>=1.0.0b260730,<2; python_version >= '3.11' and python_version < '3.15'", "agent-framework-gemini>=1.0.0b261008,<2", "agent-framework-github-copilot>=2.0.2,<3; python_version >= '3.11'", "agent-framework-hyperlight>=1.0.0b261008,<2; python_version < '3.15' and ((sys_platform == 'linux' and platform_machine == 'x86_64') or (sys_platform == 'win32' and platform_machine == 'AMD64'))", diff --git a/python/packages/foundry_local/AGENTS.md b/python/packages/foundry_local/AGENTS.md index e270385c0ef..30c6406eb53 100644 --- a/python/packages/foundry_local/AGENTS.md +++ b/python/packages/foundry_local/AGENTS.md @@ -2,7 +2,7 @@ Integration with Microsoft Foundry Local for local model inference. -Uses `foundry_local_sdk` (SDK 2.1+, below 3.0) on Python 3.11+. Clients share +Uses `foundry_local_sdk` (SDK 2.1+, below 3.0) on Python 3.11-3.14. Clients share `FoundryLocalManager.instance` and use its built-in web service with the existing OpenAI transport. Model lifecycle operations live on catalog model objects. diff --git a/python/packages/foundry_local/README.md b/python/packages/foundry_local/README.md index 0f862bb721f..adea5922a02 100644 --- a/python/packages/foundry_local/README.md +++ b/python/packages/foundry_local/README.md @@ -1,6 +1,6 @@ # Get Started with Microsoft Agent Framework Foundry Local -Requires Python 3.11 or newer and Foundry Local SDK 2.1 or newer (below 3.0). +Requires Python 3.11-3.14 and Foundry Local SDK 2.1 or newer (below 3.0). Install the integration: ```bash @@ -44,6 +44,8 @@ existing application's manager is left untouched. ## Migration from SDK 0.5.1 This is a breaking SDK migration: Python 3.10 and SDK 0.5.1/1.x are no longer supported. +Python 3.15 is excluded because SDK 2.1 pins ONNX Runtime 1.30.0, which does not yet +publish Python 3.15 wheels. Update SDK imports from `foundry_local` to `foundry_local_sdk`. Use `client.manager.catalog.list_models()` instead of `list_catalog_models()`, inspect metadata through `model.info`, and call `download()`/`load()` on catalog model objects diff --git a/python/packages/foundry_local/pyproject.toml b/python/packages/foundry_local/pyproject.toml index d8877d47828..b25f803ce51 100644 --- a/python/packages/foundry_local/pyproject.toml +++ b/python/packages/foundry_local/pyproject.toml @@ -3,7 +3,7 @@ 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.11" +requires-python = ">=3.11,<3.15" version = "1.0.0b260730" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" From d7e232e0425ccc4e5a40cefaf878b8a6631ead05 Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Mon, 12 Oct 2026 16:46:57 -0700 Subject: [PATCH 05/17] Serialize Foundry Local client setup and fix test typing Hold the adapter setup lock through singleton initialization, web-service startup, model preparation, and failure rollback. Add concurrent success/failure regressions and keep invalid-input and transport tests compatible with all CI type checkers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d8ebdb3c-28b8-4fa1-830d-3fa7d6333d9e --- python/packages/foundry_local/README.md | 6 +- .../_foundry_local_client.py | 129 +++++++++--------- .../packages/foundry_local/tests/conftest.py | 35 +++-- .../tests/test_foundry_local_client.py | 90 +++++++++++- 4 files changed, 176 insertions(+), 84 deletions(-) diff --git a/python/packages/foundry_local/README.md b/python/packages/foundry_local/README.md index adea5922a02..aa1c4579c19 100644 --- a/python/packages/foundry_local/README.md +++ b/python/packages/foundry_local/README.md @@ -25,8 +25,10 @@ The client initializes the Foundry Local SDK singleton, prepares the selected mo and starts its built-in OpenAI-compatible HTTP service. Streaming, middleware, and function invocation continue to use Agent Framework's OpenAI transport. -All clients in a process share the SDK manager and web service. To customize cache -directories or other SDK settings, pass `configuration=Configuration(app_name="my-app", ...)` +All clients in a process share the SDK manager and web service. Concurrent client +construction is serialized through model preparation and failed-setup cleanup. +To customize cache directories or other SDK settings, pass +`configuration=Configuration(app_name="my-app", ...)` when creating the first client, or initialize `FoundryLocalManager` yourself before creating clients. Register accelerated execution providers through the SDK manager before selecting GPU/NPU variants. A `device` value of `"CPU"`, `"GPU"`, or `"NPU"` 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 d32252c3344..cdfd90078a6 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 @@ -4,6 +4,7 @@ import sys from collections.abc import Awaitable, Callable, Mapping, Sequence +from threading import Lock from typing import Any, ClassVar, Generic, Literal, cast, overload from agent_framework import ( @@ -45,6 +46,7 @@ ] ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) +_MANAGER_SETUP_LOCK = Lock() # region Foundry Local Chat Options TypedDict @@ -333,71 +335,72 @@ class MyOptions(FoundryLocalChatOptions, total=False): model_setting = cast(str, settings.get("model")) device_type = DeviceType(device) if device is not None else None - manager = FoundryLocalManager.instance - owns_manager = manager is None - if manager is None: - if not bootstrap: - raise ValueError( - "bootstrap=False requires an initialized Foundry Local manager and running web service." - ) - FoundryLocalManager.initialize(configuration or Configuration(app_name="agent-framework")) + with _MANAGER_SETUP_LOCK: manager = FoundryLocalManager.instance + owns_manager = manager is None if manager is None: - raise RuntimeError("Foundry Local manager initialization did not create a singleton.") - elif configuration is not None: - raise ValueError( - "Foundry Local is already initialized; configure the shared manager before creating clients." - ) - - initialized = False - try: - if not manager.urls: if not bootstrap: - raise ValueError("bootstrap=False requires a running Foundry Local web service.") - manager.start_web_service() - if not manager.urls: - raise ValueError("Foundry Local web service did not provide a bound URL.") - - is_variant_id = ":" in model_setting - model_handle = ( - manager.catalog.get_model_variant(model_setting) - if is_variant_id - else manager.catalog.get_model(model_setting) - ) - if model_handle is not None and device_type is not None: - model_handle = next( - ( - variant - for variant in ([model_handle] if is_variant_id else model_handle.variants) - if (runtime := variant.info.runtime) is not None and runtime.device_type == device_type + raise ValueError( + "bootstrap=False requires an initialized Foundry Local manager and running web service." + ) + FoundryLocalManager.initialize(configuration or Configuration(app_name="agent-framework")) + manager = FoundryLocalManager.instance + if manager is None: + raise RuntimeError("Foundry Local manager initialization did not create a singleton.") + elif configuration is not None: + raise ValueError( + "Foundry Local is already initialized; configure the shared manager before creating clients." + ) + + initialized = False + try: + if not manager.urls: + if not bootstrap: + raise ValueError("bootstrap=False requires a running Foundry Local web service.") + manager.start_web_service() + if not manager.urls: + raise ValueError("Foundry Local web service did not provide a bound URL.") + + is_variant_id = ":" in model_setting + model_handle = ( + manager.catalog.get_model_variant(model_setting) + if is_variant_id + else manager.catalog.get_model(model_setting) + ) + if model_handle is not None and device_type is not None: + model_handle = next( + ( + variant + for variant in ([model_handle] if is_variant_id else model_handle.variants) + if (runtime := variant.info.runtime) is not None and runtime.device_type == device_type + ), + None, + ) + elif model_handle is not None and not is_variant_id: + # Pin the alias's chosen variant before download changes the cached preference. + model_handle = manager.catalog.get_model_variant(model_handle.id) + if model_handle is None: + target = f"{model_setting}:{device_type.value}" if device_type is not None else model_setting + raise ValueError(f"Model with ID or alias '{target}' not found in Foundry Local.") + + model_id = model_handle.id + if prepare_model: + model_handle.download() + model_handle.load() + + super().__init__( + model=model_id, + async_client=AsyncOpenAI( + base_url=f"{manager.urls[0].rstrip('/')}/v1", + api_key="foundry-local", + timeout=timeout if timeout is not None else DEFAULT_TIMEOUT, ), - None, + additional_properties=additional_properties, + middleware=middleware, + function_invocation_configuration=function_invocation_configuration, ) - elif model_handle is not None and not is_variant_id: - # Pin the alias's chosen variant before download changes the cached preference. - model_handle = manager.catalog.get_model_variant(model_handle.id) - if model_handle is None: - target = f"{model_setting}:{device_type.value}" if device_type is not None else model_setting - raise ValueError(f"Model with ID or alias '{target}' not found in Foundry Local.") - - model_id = model_handle.id - if prepare_model: - model_handle.download() - model_handle.load() - - super().__init__( - model=model_id, - async_client=AsyncOpenAI( - base_url=f"{manager.urls[0].rstrip('/')}/v1", - api_key="foundry-local", - timeout=timeout if timeout is not None else DEFAULT_TIMEOUT, - ), - additional_properties=additional_properties, - middleware=middleware, - function_invocation_configuration=function_invocation_configuration, - ) - self.manager = manager - initialized = True - finally: - if owns_manager and not initialized: - manager.close() + self.manager = manager + initialized = True + finally: + if owns_manager and not initialized: + manager.close() diff --git a/python/packages/foundry_local/tests/conftest.py b/python/packages/foundry_local/tests/conftest.py index b03ef29576e..437b53a7f45 100644 --- a/python/packages/foundry_local/tests/conftest.py +++ b/python/packages/foundry_local/tests/conftest.py @@ -1,5 +1,5 @@ # Copyright (c) Microsoft. All rights reserved. -from collections.abc import Iterator +from collections.abc import Callable, Iterator from types import SimpleNamespace from typing import Any from unittest.mock import MagicMock, patch @@ -45,18 +45,29 @@ def foundry_local_unit_test_env(monkeypatch: Any, exclude_list: list[str], overr @fixture -def mock_foundry_local_manager() -> Iterator[MagicMock]: +def mock_foundry_local_manager_factory() -> Callable[[], MagicMock]: + """Create independent mock managers with their own catalog models.""" + + def create_manager() -> MagicMock: + mock_manager = MagicMock(spec=FoundryLocalManager) + mock_manager.urls = ["http://localhost:5272"] + mock_manager.catalog = MagicMock(spec=Catalog) + + mock_model = MagicMock(spec=IModel) + mock_model.id = "test-model-id" + mock_model.info = SimpleNamespace(runtime=SimpleNamespace(device_type=DeviceType.CPU)) + mock_model.variants = [mock_model] + mock_manager.catalog.get_model.return_value = mock_model + mock_manager.catalog.get_model_variant.return_value = mock_model + return mock_manager + + return create_manager + + +@fixture +def mock_foundry_local_manager(mock_foundry_local_manager_factory: Callable[[], MagicMock]) -> Iterator[MagicMock]: """Fixture that provides a mock FoundryLocalManager.""" - mock_manager = MagicMock(spec=FoundryLocalManager) - mock_manager.urls = ["http://localhost:5272"] - mock_manager.catalog = MagicMock(spec=Catalog) - - mock_model = MagicMock(spec=IModel) - mock_model.id = "test-model-id" - mock_model.info = SimpleNamespace(runtime=SimpleNamespace(device_type=DeviceType.CPU)) - mock_model.variants = [mock_model] - mock_manager.catalog.get_model.return_value = mock_model - mock_manager.catalog.get_model_variant.return_value = mock_model + mock_manager = mock_foundry_local_manager_factory() with patch("agent_framework_foundry_local._foundry_local_client.FoundryLocalManager") as manager_class: manager_class.instance = 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 6e3c85c2b21..5ed1c470e6f 100644 --- a/python/packages/foundry_local/tests/test_foundry_local_client.py +++ b/python/packages/foundry_local/tests/test_foundry_local_client.py @@ -2,13 +2,17 @@ import inspect import json +from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor from importlib import import_module +from queue import Queue +from threading import Event, Lock from types import SimpleNamespace from typing import Any, Literal, cast from unittest.mock import MagicMock, patch import pytest -from agent_framework import Agent, SupportsChatGetResponse +from agent_framework import Agent, Message, SupportsChatGetResponse from agent_framework._settings import load_settings from agent_framework.exceptions import SettingNotFoundError from agent_framework.foundry import FoundryLocalClient @@ -102,6 +106,77 @@ def test_foundry_local_client_reuses_singleton(mock_foundry_local_manager: Magic mock_foundry_local_manager.start_web_service.assert_not_called() +@pytest.mark.parametrize("first_fails", [False, True]) +def test_foundry_local_client_serializes_concurrent_setup( + mock_foundry_local_manager_factory: Callable[[], MagicMock], first_fails: bool +) -> None: + first_manager = mock_foundry_local_manager_factory() + first_manager.urls = None + replacement_manager = mock_foundry_local_manager_factory() + preparing = Event() + release_preparation = Event() + lock_attempts: Queue[None] = Queue() + setup_lock = Lock() + observed_lock = MagicMock() + + def enter_lock() -> None: + lock_attempts.put(None) + setup_lock.acquire() + + observed_lock.__enter__.side_effect = enter_lock + observed_lock.__exit__.side_effect = lambda *_: setup_lock.release() + + def prepare_first_model() -> None: + assert setup_lock.locked(), "Model preparation must remain inside the shared setup lock." + preparing.set() + assert release_preparation.wait(5), "Timed out waiting to release model preparation." + if first_fails: + raise RuntimeError("model preparation failed") + + first_manager.catalog.get_model_variant.return_value.download.side_effect = prepare_first_model + first_manager.start_web_service.side_effect = lambda: setattr(first_manager, "urls", ["http://localhost:5272"]) + + with ( + patch("agent_framework_foundry_local._foundry_local_client.FoundryLocalManager") as manager_class, + patch("agent_framework_foundry_local._foundry_local_client._MANAGER_SETUP_LOCK", observed_lock, create=True), + ThreadPoolExecutor(max_workers=2) as executor, + ): + manager_class.instance = None + managers = iter([first_manager, replacement_manager]) + manager_class.initialize.side_effect = lambda _: setattr(manager_class, "instance", next(managers)) + + def close_first_manager() -> None: + assert setup_lock.locked(), "Failed setup must finish cleanup before another client can use the manager." + manager_class.instance = None + + first_manager.close.side_effect = close_first_manager + first = executor.submit(FoundryLocalClient, model="test-model-id") + try: + assert preparing.wait(5), "First client did not reach model preparation." + lock_attempts.get(timeout=5) + second = executor.submit(FoundryLocalClient, model="test-model-id", prepare_model=False) + lock_attempts.get(timeout=5) + assert not second.done() + manager_class.initialize.assert_called_once() + first_manager.start_web_service.assert_called_once_with() + finally: + release_preparation.set() + + if first_fails: + with pytest.raises(RuntimeError, match="model preparation failed"): + first.result(timeout=5) + assert second.result(timeout=5).manager is replacement_manager + assert manager_class.initialize.call_count == 2 + first_manager.close.assert_called_once_with() + replacement_manager.close.assert_not_called() + else: + assert first.result(timeout=5).manager is first_manager + assert second.result(timeout=5).manager is first_manager + manager_class.initialize.assert_called_once() + first_manager.close.assert_not_called() + first_manager.start_web_service.assert_called_once_with() + + def test_foundry_local_client_rejects_configuration_for_existing_manager(mock_foundry_local_manager: MagicMock) -> None: with pytest.raises(ValueError, match="already initialized"): FoundryLocalClient(model="test-model-id", configuration=Configuration(app_name="another-app")) @@ -212,25 +287,25 @@ def test_foundry_local_client_selects_device_variant( def test_foundry_local_client_exact_id_rejects_wrong_device(mock_foundry_local_manager: MagicMock) -> None: with pytest.raises(ValueError, match="exact-variant:1:GPU.*not found"): - FoundryLocalClient(model="exact-variant:1", device=DeviceType.GPU) + FoundryLocalClient(model="exact-variant:1", device=DeviceType("GPU")) mock_foundry_local_manager.catalog.get_model.return_value.download.assert_not_called() def test_foundry_local_client_missing_device_variant(mock_foundry_local_manager: MagicMock) -> None: with pytest.raises(ValueError, match="model-alias:GPU.*not found"): - FoundryLocalClient(model="model-alias", device=DeviceType.GPU) + FoundryLocalClient(model="model-alias", device=DeviceType("GPU")) def test_foundry_local_client_unknown_runtime_does_not_match_device(mock_foundry_local_manager: MagicMock) -> None: mock_model = mock_foundry_local_manager.catalog.get_model_variant.return_value mock_model.info.runtime = None with pytest.raises(ValueError, match="model-alias:CPU.*not found"): - FoundryLocalClient(model="model-alias", device=DeviceType.CPU) + FoundryLocalClient(model="model-alias", device=DeviceType("CPU")) def test_foundry_local_client_invalid_device(mock_foundry_local_manager: MagicMock) -> None: with pytest.raises(ValueError, match="DeviceType"): - FoundryLocalClient(model="model-alias", device="invalid") # pyright: ignore[reportArgumentType] + FoundryLocalClient(model="model-alias", device=cast(DeviceType, "invalid")) mock_foundry_local_manager.catalog.get_model.assert_not_called() @@ -309,10 +384,11 @@ def respond(request: Any) -> Any: ) with patch("agent_framework_foundry_local._foundry_local_client.AsyncOpenAI", return_value=async_client): client = FoundryLocalClient(model="test-model-id", prepare_model=False) + messages = [Message(role="user", contents=["Hi"])] if stream: - result = "".join([update.text async for update in client.get_response("Hi", stream=True)]) + result = "".join([update.text async for update in client.get_response(messages, stream=True)]) else: - result = (await client.get_response("Hi")).text + result = (await client.get_response(messages)).text assert result == "Hello" assert len(requests) == 1 From 74bc673df28d27fcbeaab078432049c00382b97b Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Mon, 12 Oct 2026 17:02:16 -0700 Subject: [PATCH 06/17] Type the concurrent Foundry Local test factory explicitly Avoid passing an unspecialized generic constructor to Executor.submit, and isolate the concurrency regressions from real HTTP client initialization. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d8ebdb3c-28b8-4fa1-830d-3fa7d6333d9e --- .../foundry_local/tests/test_foundry_local_client.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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 5ed1c470e6f..3ef504d0b8b 100644 --- a/python/packages/foundry_local/tests/test_foundry_local_client.py +++ b/python/packages/foundry_local/tests/test_foundry_local_client.py @@ -15,7 +15,7 @@ from agent_framework import Agent, Message, SupportsChatGetResponse from agent_framework._settings import load_settings from agent_framework.exceptions import SettingNotFoundError -from agent_framework.foundry import FoundryLocalClient +from agent_framework.foundry import FoundryLocalChatOptions, FoundryLocalClient from foundry_local_sdk import Configuration, DeviceType, IModel from openai import AsyncOpenAI, DefaultAsyncHttpxClient @@ -136,9 +136,15 @@ def prepare_first_model() -> None: first_manager.catalog.get_model_variant.return_value.download.side_effect = prepare_first_model first_manager.start_web_service.side_effect = lambda: setattr(first_manager, "urls", ["http://localhost:5272"]) + def create_client(prepare_model: bool = True) -> FoundryLocalClient[FoundryLocalChatOptions[None]]: + return FoundryLocalClient(model="test-model-id", prepare_model=prepare_model) + with ( patch("agent_framework_foundry_local._foundry_local_client.FoundryLocalManager") as manager_class, patch("agent_framework_foundry_local._foundry_local_client._MANAGER_SETUP_LOCK", observed_lock, create=True), + patch( + "agent_framework_foundry_local._foundry_local_client.AsyncOpenAI", return_value=MagicMock(spec=AsyncOpenAI) + ), ThreadPoolExecutor(max_workers=2) as executor, ): manager_class.instance = None @@ -150,11 +156,11 @@ def close_first_manager() -> None: manager_class.instance = None first_manager.close.side_effect = close_first_manager - first = executor.submit(FoundryLocalClient, model="test-model-id") + first = executor.submit(create_client) try: assert preparing.wait(5), "First client did not reach model preparation." lock_attempts.get(timeout=5) - second = executor.submit(FoundryLocalClient, model="test-model-id", prepare_model=False) + second = executor.submit(create_client, prepare_model=False) lock_attempts.get(timeout=5) assert not second.done() manager_class.initialize.assert_called_once() From 4d07661471639bc6d98514a07de2f4724f04f6cf Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Tue, 13 Oct 2026 11:38:12 -0700 Subject: [PATCH 07/17] Expose and validate Foundry Local native chat controls Normalize native token limits and response schemas, preserve required tool invocation before structured replies, expose download controls, and roll back services started during failed client setup. Expand transport and agent regressions and document public-catalog-only scope. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d8ebdb3c-28b8-4fa1-830d-3fa7d6333d9e --- python/packages/foundry_local/AGENTS.md | 19 +- python/packages/foundry_local/README.md | 73 ++- .../_foundry_local_client.py | 109 +++- .../tests/test_foundry_local_client.py | 599 +++++++++++++++++- 4 files changed, 771 insertions(+), 29 deletions(-) diff --git a/python/packages/foundry_local/AGENTS.md b/python/packages/foundry_local/AGENTS.md index 30c6406eb53..7c5326c5af3 100644 --- a/python/packages/foundry_local/AGENTS.md +++ b/python/packages/foundry_local/AGENTS.md @@ -5,6 +5,22 @@ Integration with Microsoft Foundry Local for local model inference. Uses `foundry_local_sdk` (SDK 2.1+, below 3.0) on Python 3.11-3.14. Clients share `FoundryLocalManager.instance` and use its built-in web service with the existing OpenAI transport. Model lifecycle operations live on catalog model objects. +The adapter resolves public-catalog variants only; BYOM/local-catalog selection +is not exposed through SDK 2.1's HTTP service. + +`FoundryLocalClient` exposes the SDK `Configuration` for initial manager setup, +download progress/cancellation controls, and exact device/variant selection. +Skipping preparation requires the selected variant to be loaded before inference. +Clients own their HTTP connections, not the shared native manager. +Failed setup stops a web service it started on an existing manager without closing +that manager or stopping a previously running service. + +`FoundryLocalChatOptions` adds `top_k`, `do_sample`, and `early_stopping`. +Request preparation maps these to string-valued native decoder metadata and +unwraps OpenAI's JSON-schema envelope for the HTTP service. Explicit decoder +controls override conflicting metadata without mutating caller options. +Required tool calls use tool grammar before structured-output grammar; the standard +function loop applies the structured format to the final reply. ## Main Classes @@ -15,10 +31,11 @@ OpenAI transport. Model lifecycle operations live on catalog model objects. ## Usage ```python +from agent_framework import Message from agent_framework.foundry import FoundryLocalClient client = FoundryLocalClient(model="your-local-model") -response = await client.get_response("Hello") +response = await client.get_response([Message(role="user", contents=["Hello"])]) ``` ## Import Path diff --git a/python/packages/foundry_local/README.md b/python/packages/foundry_local/README.md index aa1c4579c19..0e5ef1b150e 100644 --- a/python/packages/foundry_local/README.md +++ b/python/packages/foundry_local/README.md @@ -36,12 +36,81 @@ selects a matching variant; an exact model ID is never replaced by a sibling var `bootstrap=False` requires an already initialized SDK manager with a running web service in the same process. `prepare_model=False` skips native download/load during -construction. `timeout` applies to HTTP inference requests, not native preparation. +construction; the exact selected variant must already be loaded or be prepared +manually before inference. The HTTP service does not load it automatically. +`timeout` applies to HTTP inference requests, not native preparation. + +`download_progress_callback` receives download percentages from 0 to 100, and +`download_cancel_event` accepts a `threading.Event` to cancel the download. +Both apply only when `prepare_model=True`. They do not cancel model loading or +inference; asynchronous request cancellation uses the normal Agent Framework API. Clients do not close or unload the shared manager. After all clients finish, close their HTTP clients and call `client.manager.close()` once at application shutdown. If construction fails after creating a new manager, that manager is closed; an -existing application's manager is left untouched. +existing application's manager is not closed. If failed construction started +its web service, that service is stopped; a previously running service is left running. + +## Chat Options + +`FoundryLocalChatOptions` includes the standard generation settings and native +`top_k`, `do_sample`, and `early_stopping` controls. The client encodes these three +controls as the HTTP service's string-valued decoder metadata, preserving zero +and `False` values. Explicit controls take precedence over conflicting values in +`metadata` or `extra_body["metadata"]`. Other native settings can be supplied +through these dictionaries. + +Pydantic response models and JSON schemas use the native HTTP service's bare-schema +format automatically. JSON-object mode constrains output to an object; it does +not guarantee particular fields or values. Supported settings and structured +output capabilities depend on the model and runtime. Backend errors remain +visible as `ChatClientException`. + +```python +from agent_framework import Message +from agent_framework.foundry import FoundryLocalChatOptions, FoundryLocalClient +from pydantic import BaseModel + + +class Answer(BaseModel): + answer: int + + +client = FoundryLocalClient(model="phi-4-mini") +options: FoundryLocalChatOptions[Answer] = { + "response_format": Answer, + "max_tokens": 128, + "temperature": 0.0, + "top_k": 1, + "do_sample": False, +} +response = await client.get_response( + [Message(role="user", contents=["What is 2 + 2?"])], + options=options, +) +assert isinstance(response.value, Answer) +print(response.value.answer) +``` + +Agent sessions, middleware, streaming, and automatic tool execution retain their +standard Agent Framework APIs. A per-request `model` option overrides inference +targeting only: that public-catalog variant must also be loaded in the shared manager. +When a tool is required (including a named tool), the request uses the native tool +grammar first, then applies the requested structured format to the final reply +after automatic tool execution. With automatic execution disabled, the caller +must handle the returned function call before requesting a structured final reply. + +## Scope + +This adapter targets the SDK's public model catalog and HTTP chat service. +BYOM/local-catalog model selection is not exposed: SDK 2.1's HTTP service cannot +resolve models registered in its local catalog. Native-session-only features, +audio transcription, and embeddings are outside this chat integration. + +Image content follows the OpenAI transport's serialization, but a successful HTTP +request or the presence of vision assets does not establish that a particular +model/runtime consumes those images. Verify multimodal behavior for the selected +configuration before relying on it. ## Migration from SDK 0.5.1 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 cdfd90078a6..592ff421a71 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 @@ -4,7 +4,7 @@ import sys from collections.abc import Awaitable, Callable, Mapping, Sequence -from threading import Lock +from threading import Event, Lock from typing import Any, ClassVar, Generic, Literal, cast, overload from agent_framework import ( @@ -83,22 +83,33 @@ class FoundryLocalChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModel # Options not supported in Foundry Local: user: Not used locally. store: Not applicable for local inference. - metadata: Not applicable for local inference. - # Foundry Local-specific options: + top_k: Number of highest-probability tokens to consider; 0 disables the limit. + do_sample: Whether to sample instead of using greedy decoding. + early_stopping: Whether to stop beam search early. + metadata: Native decoder settings encoded as string-valued metadata. extra_body: Additional request body parameters to pass to the model. Can be used for model-specific options not covered by standard API. Note: The actual options supported depend on the specific model being used. Some models (like Phi-4) may not support all OpenAI API features. - Options not supported by the model will typically be ignored. + Unsupported options may fail the request rather than being ignored. """ # Foundry Local-specific options extra_body: dict[str, Any] """Additional request body parameters for model-specific options.""" + top_k: int + """Number of highest-probability tokens to consider; 0 disables the limit.""" + + do_sample: bool + """Whether to sample instead of using greedy decoding.""" + + early_stopping: bool + """Whether to stop beam search early.""" + # ChatOptions fields not applicable for local inference user: None # type: ignore[misc] """Not used for local model inference.""" @@ -150,7 +161,7 @@ def get_response( messages: Sequence[Message], *, stream: Literal[False] = ..., - options: ChatOptions[ResponseModelT], + options: ChatOptions[ResponseModelT] | FoundryLocalChatOptions[ResponseModelT], compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, @@ -164,7 +175,7 @@ def get_response( messages: Sequence[Message], *, stream: Literal[False] = ..., - options: FoundryLocalChatOptionsT | ChatOptions[None] | None = None, + options: FoundryLocalChatOptionsT | FoundryLocalChatOptions[Any] | ChatOptions[None] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, @@ -178,7 +189,7 @@ def get_response( messages: Sequence[Message], *, stream: Literal[True], - options: FoundryLocalChatOptionsT | ChatOptions[Any] | None = None, + options: FoundryLocalChatOptionsT | FoundryLocalChatOptions[Any] | ChatOptions[Any] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, @@ -191,7 +202,7 @@ def get_response( messages: Sequence[Message], *, stream: bool = False, - options: FoundryLocalChatOptionsT | ChatOptions[Any] | None = None, + options: FoundryLocalChatOptionsT | FoundryLocalChatOptions[Any] | ChatOptions[Any] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, @@ -216,6 +227,47 @@ def get_response( client_kwargs=effective_client_kwargs, ) + def _prepare_options(self, messages: Sequence[Message], options: Mapping[str, Any]) -> dict[str, Any]: + prepared = super()._prepare_options(messages, options) + native_metadata: dict[str, str] = {} + for key in ("top_k", "do_sample", "early_stopping"): + value = prepared.pop(key, None) + if value is not None: + native_metadata[key] = str(value).lower() if isinstance(value, bool) else str(value) + extra_body: Mapping[str, Any] | None = prepared.get("extra_body") + if extra_body is not None and not isinstance(extra_body, Mapping): + raise ValueError("extra_body must be a mapping.") + metadata: Mapping[str, Any] | None = prepared.get("metadata") + extra_metadata: Mapping[str, Any] | None = extra_body.get("metadata") if extra_body is not None else None + for name, value in (("metadata", metadata), ("extra_body metadata", extra_metadata)): + if value is not None and not isinstance(value, Mapping): + raise ValueError(f"{name} must be a mapping.") + if native_metadata or (metadata is not None and extra_metadata is not None): + merged_metadata: dict[str, Any] = { + **(metadata or {}), + **(extra_metadata or {}), + **native_metadata, + } + prepared["metadata"] = merged_metadata + if extra_body is not None and "metadata" in extra_body: + prepared["extra_body"] = {**extra_body, "metadata": merged_metadata} + # The HTTP service uses max_tokens and a bare schema, not OpenAI's newer token key or named schema envelope. + if "max_completion_tokens" in prepared: + prepared["max_tokens"] = prepared.pop("max_completion_tokens") + response_format: Mapping[str, Any] | None = prepared.get("response_format") + if isinstance(response_format, Mapping) and response_format.get("type") == "json_schema": + schema: Mapping[str, Any] | None = response_format.get("json_schema") + if isinstance(schema, Mapping): + prepared["response_format"] = { + **response_format, + "json_schema": schema.get("schema", schema), + } + tool_choice: str | Mapping[str, Any] | None = prepared.get("tool_choice") + if tool_choice == "required" or (isinstance(tool_choice, Mapping) and tool_choice.get("type") == "function"): + # Native schema grammar overrides forced tools; retain it only for the final reply. + prepared.pop("response_format", None) + return prepared + def __init__( self, model: str | None = None, @@ -223,6 +275,8 @@ def __init__( bootstrap: bool = True, timeout: float | None = None, prepare_model: bool = True, + download_progress_callback: Callable[[float], None] | None = None, + download_cancel_event: Event | None = None, device: DeviceType | Literal["CPU", "GPU", "NPU"] | None = None, configuration: Configuration | None = None, additional_properties: dict[str, Any] | None = None, @@ -243,8 +297,12 @@ def __init__( client's default when omitted; does not apply to native model preparation. prepare_model: Whether to download the model into the cache, and load the model into the inferencing service upon initialization. Default is True. - If false, the first call to generate a completion will load the model, - and might take a long time. + If False, the exact model variant must already be loaded, or be downloaded + and loaded manually before inference. + download_progress_callback: Optional callback receiving native download progress + as a percentage from 0 to 100. Used only when prepare_model is True. + download_cancel_event: Optional event that cancels native model download when set. + Used only when prepare_model is True; does not cancel model loading or inference. 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. @@ -298,25 +356,21 @@ def __init__( model="phi-4-mini", prepare_model=False, ) - # Beware, in this case the first request to generate a completion - # will take a long time as the model is loaded then. - # Alternatively, prepare the exact resolved variant manually. + # Prepare the exact resolved variant manually before inference. model = client.manager.catalog.get_model_variant(client.model) if model is not None: model.download() model.load() - # Using custom ChatOptions with type safety: - from typing import TypedDict + # Using native ChatOptions with type safety: + from agent_framework import Message from agent_framework.foundry import FoundryLocalChatOptions - - class MyOptions(FoundryLocalChatOptions, total=False): - my_custom_option: str - - - client: FoundryLocalClient[MyOptions] = FoundryLocalClient(model="phi-4-mini") - response = await client.get_response("Hello", options={"my_custom_option": "value"}) + options: FoundryLocalChatOptions[None] = {"top_k": 40, "max_tokens": 100} + response = await client.get_response( + [Message(role="user", contents=["Hello"])], + options=options, + ) Raises: ValueError: If the model or requested device is unavailable, bootstrap is @@ -353,11 +407,13 @@ class MyOptions(FoundryLocalChatOptions, total=False): ) initialized = False + started_web_service = False try: if not manager.urls: if not bootstrap: raise ValueError("bootstrap=False requires a running Foundry Local web service.") manager.start_web_service() + started_web_service = bool(manager.urls) if not manager.urls: raise ValueError("Foundry Local web service did not provide a bound URL.") @@ -385,7 +441,12 @@ class MyOptions(FoundryLocalChatOptions, total=False): model_id = model_handle.id if prepare_model: - model_handle.download() + if download_progress_callback is None and download_cancel_event is None: + model_handle.download() + else: + model_handle.download( + progress_callback=download_progress_callback, cancel_event=download_cancel_event + ) model_handle.load() super().__init__( @@ -404,3 +465,5 @@ class MyOptions(FoundryLocalChatOptions, total=False): finally: if owns_manager and not initialized: manager.close() + elif started_web_service and not initialized: + manager.stop_web_service() 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 3ef504d0b8b..dc6f184105d 100644 --- a/python/packages/foundry_local/tests/test_foundry_local_client.py +++ b/python/packages/foundry_local/tests/test_foundry_local_client.py @@ -1,9 +1,12 @@ # Copyright (c) Microsoft. All rights reserved. +import asyncio import inspect import json -from collections.abc import Callable +from collections.abc import AsyncGenerator, Awaitable, Callable, Sequence from concurrent.futures import ThreadPoolExecutor +from contextlib import asynccontextmanager +from copy import deepcopy from importlib import import_module from queue import Queue from threading import Event, Lock @@ -12,12 +15,24 @@ from unittest.mock import MagicMock, patch import pytest -from agent_framework import Agent, Message, SupportsChatGetResponse +from agent_framework import ( + Agent, + ChatAndFunctionMiddlewareTypes, + ChatContext, + ChatOptions, + Content, + FunctionInvocationConfiguration, + Message, + SupportsChatGetResponse, + chat_middleware, + tool, +) from agent_framework._settings import load_settings -from agent_framework.exceptions import SettingNotFoundError +from agent_framework.exceptions import ChatClientException, SettingNotFoundError from agent_framework.foundry import FoundryLocalChatOptions, FoundryLocalClient from foundry_local_sdk import Configuration, DeviceType, IModel from openai import AsyncOpenAI, DefaultAsyncHttpxClient +from pydantic import BaseModel from agent_framework_foundry_local._feature_usage import FeatureIndex from agent_framework_foundry_local._foundry_local_client import FoundryLocalSettings @@ -227,6 +242,36 @@ def test_foundry_local_client_requires_bound_url(mock_foundry_local_manager: Mag FoundryLocalClient(model="test-model-id") +@pytest.mark.parametrize("service_running", [False, True]) +@pytest.mark.parametrize("failure_stage", ["lookup", "download", "load", "http"]) +def test_foundry_local_client_rolls_back_service_started_on_existing_manager( + mock_foundry_local_manager: MagicMock, service_running: bool, failure_stage: str +) -> None: + if not service_running: + mock_foundry_local_manager.urls = None + mock_foundry_local_manager.start_web_service.side_effect = lambda: setattr( + mock_foundry_local_manager, "urls", ["http://localhost:5272"] + ) + model = mock_foundry_local_manager.catalog.get_model_variant.return_value + if failure_stage == "lookup": + mock_foundry_local_manager.catalog.get_model.return_value = None + elif failure_stage in ("download", "load"): + getattr(model, failure_stage).side_effect = RuntimeError("setup failed") + with ( + patch("agent_framework_foundry_local._foundry_local_client.AsyncOpenAI") as http_client, + pytest.raises(ValueError if failure_stage == "lookup" else RuntimeError), + ): + if failure_stage == "http": + http_client.side_effect = RuntimeError("setup failed") + FoundryLocalClient(model="test-model-id") + mock_foundry_local_manager.close.assert_not_called() + if service_running: + mock_foundry_local_manager.stop_web_service.assert_not_called() + else: + mock_foundry_local_manager.start_web_service.assert_called_once_with() + mock_foundry_local_manager.stop_web_service.assert_called_once_with() + + def test_foundry_local_client_init_with_timeout(mock_foundry_local_manager: MagicMock) -> None: client = FoundryLocalClient(model="test-model-id", timeout=60.0) assert client.client.timeout == 60.0 @@ -266,6 +311,43 @@ def test_foundry_local_client_exact_id_does_not_resolve_alias(mock_foundry_local mock_foundry_local_manager.catalog.get_model.assert_not_called() +def test_foundry_local_client_passes_download_controls(mock_foundry_local_manager: MagicMock) -> None: + progress = MagicMock() + cancel_event = Event() + FoundryLocalClient(model="test-model-id", download_progress_callback=progress, download_cancel_event=cancel_event) + mock_model = mock_foundry_local_manager.catalog.get_model_variant.return_value + mock_model.download.assert_called_once_with(progress_callback=progress, cancel_event=cancel_event) + mock_model.load.assert_called_once_with() + + +def test_foundry_local_client_cancelled_download_does_not_load(mock_foundry_local_manager: MagicMock) -> None: + cancel_event = Event() + cancel_event.set() + mock_model = mock_foundry_local_manager.catalog.get_model_variant.return_value + mock_model.download.side_effect = RuntimeError("download cancelled") + + with pytest.raises(RuntimeError, match="download cancelled"): + FoundryLocalClient(model="test-model-id", download_cancel_event=cancel_event) + + mock_model.download.assert_called_once_with(progress_callback=None, cancel_event=cancel_event) + mock_model.load.assert_not_called() + mock_foundry_local_manager.close.assert_not_called() + + +def test_foundry_local_client_skips_download_controls_without_preparation( + mock_foundry_local_manager: MagicMock, +) -> None: + FoundryLocalClient( + model="test-model-id", + prepare_model=False, + download_progress_callback=MagicMock(), + download_cancel_event=Event(), + ) + mock_model = mock_foundry_local_manager.catalog.get_model_variant.return_value + mock_model.download.assert_not_called() + mock_model.load.assert_not_called() + + def test_foundry_local_client_init_from_env( foundry_local_unit_test_env: dict[str, str], mock_foundry_local_manager: MagicMock ) -> None: @@ -402,3 +484,514 @@ def respond(request: Any) -> Any: payload = json.loads(requests[0].content) assert payload["model"] == "test-model-id" assert payload["stream"] is stream + + +@asynccontextmanager +async def _client_with_transport( + respond: Callable[[Any], Any], + *, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, + function_invocation_configuration: FunctionInvocationConfiguration | None = None, +) -> AsyncGenerator[FoundryLocalClient[FoundryLocalChatOptions[None]]]: + async with ( + DefaultAsyncHttpxClient(transport=_OPENAI_HTTPX.MockTransport(respond), trust_env=False) as http_client, + AsyncOpenAI( + base_url="http://localhost:5272/v1", api_key="foundry-local", http_client=http_client, max_retries=0 + ) as async_client, + ): + with patch("agent_framework_foundry_local._foundry_local_client.AsyncOpenAI", return_value=async_client): + yield FoundryLocalClient( + model="test-model-id", + prepare_model=False, + middleware=middleware, + function_invocation_configuration=function_invocation_configuration, + ) + + +def _completion_response(message: dict[str, Any], *, stream: bool, finish_reason: str = "stop") -> Any: + usage = {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8} + message = {"role": "assistant", **message} + if not stream: + return _OPENAI_HTTPX.Response( + 200, + json={ + "id": "completion-1", + "created": 0, + "model": "test-model-id", + "object": "chat.completion", + "choices": [{"index": 0, "message": message, "finish_reason": finish_reason}], + "usage": usage, + }, + ) + if "tool_calls" in message: + message["tool_calls"] = [{"index": index, **call} for index, call in enumerate(message["tool_calls"])] + chunk = { + "id": "completion-1", + "created": 0, + "model": "test-model-id", + "object": "chat.completion.chunk", + "choices": [{"index": 0, "delta": message, "finish_reason": finish_reason}], + "usage": usage, + } + return _OPENAI_HTTPX.Response( + 200, + headers={"content-type": "text/event-stream"}, + text=f"data: {json.dumps(chunk)}\n\ndata: [DONE]\n\n", + ) + + +@pytest.mark.parametrize("stream", [False, True]) +async def test_foundry_local_generation_options_and_usage(mock_foundry_local_manager: MagicMock, stream: bool) -> None: + requests: list[Any] = [] + + def respond(request: Any) -> Any: + requests.append(request) + return _completion_response({"content": "Hello"}, stream=stream) + + options: FoundryLocalChatOptions[None] = { + "temperature": 0.0, + "top_p": 0.8, + "max_tokens": 32, + "seed": 0, + "top_k": 0, + "do_sample": False, + "early_stopping": False, + "frequency_penalty": 0.0, + "presence_penalty": 0.0, + "stop": ["END"], + "logit_bias": {"42": -1.0}, + "instructions": "Be concise.", + "metadata": {"top_k": "16", "native_custom": "keep"}, + "extra_body": {"metadata": {"top_k": "8"}, "custom_setting": "test"}, + } + original_options = json.loads(json.dumps(options)) + async with _client_with_transport(respond) as client: + messages = [Message(role="user", contents=["Hi"])] + if stream: + response_stream = client.get_response( + messages, stream=True, options=options, client_kwargs={"extra_headers": {"X-Test-Header": "present"}} + ) + response = await response_stream.get_final_response() + else: + response = await client.get_response( + messages, options=options, client_kwargs={"extra_headers": {"X-Test-Header": "present"}} + ) + + assert response.text == "Hello" + assert response.usage_details is not None + assert response.usage_details["input_token_count"] == 5 + assert response.usage_details["output_token_count"] == 3 + assert requests[0].headers["X-Test-Header"] == "present" + payload = json.loads(requests[0].content) + for key in ( + "temperature", + "top_p", + "max_tokens", + "seed", + "frequency_penalty", + "presence_penalty", + "stop", + "logit_bias", + ): + assert payload[key] == original_options[key] + assert payload["messages"][0] == {"role": "system", "content": "Be concise."} + assert payload["metadata"] == { + "top_k": "0", + "do_sample": "false", + "early_stopping": "false", + "native_custom": "keep", + } + assert all(key not in payload for key in ("top_k", "do_sample", "early_stopping", "max_completion_tokens")) + assert payload["custom_setting"] == "test" + assert options == original_options + if stream: + assert payload["stream_options"] == {"include_usage": True} + mock_foundry_local_manager.close.assert_not_called() + + +@pytest.mark.parametrize("stream", [False, True]) +async def test_foundry_local_decoder_metadata_passthrough(mock_foundry_local_manager: MagicMock, stream: bool) -> None: + payloads: list[dict[str, Any]] = [] + + def respond(request: Any) -> Any: + payloads.append(json.loads(request.content)) + return _completion_response({"content": "Hello"}, stream=stream) + + options: FoundryLocalChatOptions[None] = { + "metadata": {"do_sample": "false"}, + "extra_body": {"metadata": {"top_k": "8"}}, + } + original_options = deepcopy(options) + async with _client_with_transport(respond) as client: + messages = [Message(role="user", contents=["Hi"])] + if stream: + await client.get_response(messages, stream=True, options=options).get_final_response() + else: + await client.get_response(messages, options=options) + assert payloads[0]["metadata"] == {"top_k": "8", "do_sample": "false"} + assert options == original_options + + +@pytest.mark.parametrize("stream", [False, True]) +async def test_foundry_local_request_model_override(mock_foundry_local_manager: MagicMock, stream: bool) -> None: + payloads: list[dict[str, Any]] = [] + + def respond(request: Any) -> Any: + payloads.append(json.loads(request.content)) + return _completion_response({"content": "Hello"}, stream=stream) + + async with _client_with_transport(respond) as client: + messages = [Message(role="user", contents=["Hi"])] + if stream: + await client.get_response(messages, stream=True, options={"model": "other-variant:1"}).get_final_response() + else: + await client.get_response(messages, options={"model": "other-variant:1"}) + assert client.model == "test-model-id" + assert payloads[0]["model"] == "other-variant:1" + + +@pytest.mark.parametrize("invalid_metadata", [0, "invalid", ["top_k", "8"]]) +async def test_foundry_local_invalid_decoder_metadata( + mock_foundry_local_manager: MagicMock, invalid_metadata: object +) -> None: + requests: list[Any] = [] + + def respond(request: Any) -> Any: + requests.append(request) + return _completion_response({"content": "Hello"}, stream=False) + + options: FoundryLocalChatOptions[None] = { + "top_k": 1, + "metadata": cast(dict[str, Any], invalid_metadata), + } + async with _client_with_transport(respond) as client: + with pytest.raises(ValueError, match="metadata must be a mapping"): + await client.get_response([Message(role="user", contents=["Hi"])], options=options) + assert not requests + + +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize("format_kind", ["pydantic", "schema", "native_schema", "json_object"]) +async def test_foundry_local_structured_responses( + mock_foundry_local_manager: MagicMock, stream: bool, format_kind: str +) -> None: + class Answer(BaseModel): + answer: int + + payloads: list[dict[str, Any]] = [] + + def respond(request: Any) -> Any: + payloads.append(json.loads(request.content)) + return _completion_response({"content": '{"answer": 4}'}, stream=stream) + + schema: dict[str, Any] = { + "type": "object", + "properties": {"answer": {"type": "integer"}}, + "required": ["answer"], + } + options: FoundryLocalChatOptions[Answer] = { + "response_format": Answer + if format_kind == "pydantic" + else schema + if format_kind == "schema" + else {"type": "json_schema", "json_schema": schema} + if format_kind == "native_schema" + else {"type": "json_object"} + } + original_options = deepcopy(options) + async with _client_with_transport(respond) as client: + messages = [Message(role="user", contents=["What is 2+2?"])] + if stream: + response = await client.get_response(messages, stream=True, options=options).get_final_response() + else: + response = await client.get_response(messages, options=options) + if format_kind == "pydantic": + assert isinstance(response.value, Answer) + assert response.value.answer == 4 + else: + assert response.value == {"answer": 4} + assert payloads[0]["response_format"]["type"] == ("json_object" if format_kind == "json_object" else "json_schema") + if format_kind != "json_object": + native_schema = payloads[0]["response_format"]["json_schema"] + assert isinstance(native_schema, dict) + assert native_schema["type"] == "object" + assert native_schema["properties"]["answer"]["type"] == "integer" + assert options == original_options + + +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize("enabled", [False, True]) +async def test_foundry_local_agent_tool_loop( + mock_foundry_local_manager: MagicMock, stream: bool, enabled: bool +) -> None: + payloads: list[dict[str, Any]] = [] + invocations: list[tuple[int, int]] = [] + + @tool + def add(a: int, b: int) -> int: + """Add two integers.""" + invocations.append((a, b)) + return a + b + + def respond(request: Any) -> Any: + payloads.append(json.loads(request.content)) + if len(payloads) == 1: + return _completion_response( + { + "content": None, + "tool_calls": [ + { + "id": "call-add", + "type": "function", + "function": {"name": "add", "arguments": '{"a":2,"b":3}'}, + } + ], + }, + stream=stream, + finish_reason="tool_calls", + ) + return _completion_response({"content": "5"}, stream=stream) + + async with _client_with_transport(respond, function_invocation_configuration={"enabled": enabled}) as client: + agent = Agent(client=client, tools=add, instructions="Use the calculator.") + if stream: + response = await agent.run( + "What is 2+3?", stream=True, options={"tool_choice": "required", "allow_multiple_tool_calls": False} + ).get_final_response() + else: + response = await agent.run( + "What is 2+3?", options={"tool_choice": "required", "allow_multiple_tool_calls": False} + ) + + assert payloads[0]["tools"][0]["function"]["name"] == "add" + assert payloads[0]["tool_choice"] == "required" + assert payloads[0]["parallel_tool_calls"] is False + if enabled: + assert invocations == [(2, 3)] + assert len(payloads) == 2 + assert response.text == "5" + tool_messages = [message for message in payloads[1]["messages"] if message["role"] == "tool"] + assert len(tool_messages) == 1 + assert tool_messages[0]["tool_call_id"] == "call-add" + assert tool_messages[0]["content"] == "5" + else: + assert invocations == [] + assert len(payloads) == 1 + assert any(content.type == "function_call" for message in response.messages for content in message.contents) + + +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize("named_tool", [False, True]) +@pytest.mark.parametrize("json_object", [False, True]) +async def test_foundry_local_required_tools_with_structured_output( + mock_foundry_local_manager: MagicMock, stream: bool, named_tool: bool, json_object: bool +) -> None: + class Answer(BaseModel): + answer: int + + payloads: list[dict[str, Any]] = [] + invocations: list[tuple[int, int]] = [] + + @tool + def add(a: int, b: int) -> int: + """Add two integers.""" + invocations.append((a, b)) + return a + b + + def respond(request: Any) -> Any: + payload = json.loads(request.content) + payloads.append(payload) + if len(payloads) == 1: + assert "response_format" not in payload + assert payload["tool_choice"] == ( + {"type": "function", "function": {"name": "add"}} if named_tool else "required" + ) + return _completion_response( + { + "content": None, + "tool_calls": [ + { + "id": "call-add", + "type": "function", + "function": {"name": "add", "arguments": '{"a":2,"b":3}'}, + } + ], + }, + stream=stream, + finish_reason="tool_calls", + ) + assert payload["response_format"]["type"] == ("json_object" if json_object else "json_schema") + if not json_object: + assert payload["response_format"]["json_schema"]["properties"]["answer"]["type"] == "integer" + return _completion_response({"content": '{"answer":5}'}, stream=stream) + + options: ChatOptions[Answer] = { + "tool_choice": {"mode": "required", "required_function_name": "add"} if named_tool else "required", + "response_format": {"type": "json_object"} if json_object else Answer, + } + original_options = deepcopy(options) + async with _client_with_transport(respond) as client: + agent = Agent(client=client, tools=add) + if stream: + response = await agent.run("What is 2+3?", stream=True, options=options).get_final_response() + else: + response = await agent.run("What is 2+3?", options=options) + assert invocations == [(2, 3)] + assert len(payloads) == 2 + if json_object: + assert response.value == {"answer": 5} + else: + assert isinstance(response.value, Answer) + assert response.value.answer == 5 + assert options == original_options + + +@pytest.mark.parametrize("stream", [False, True]) +async def test_foundry_local_constructor_and_per_call_middleware( + mock_foundry_local_manager: MagicMock, stream: bool +) -> None: + order: list[str] = [] + payloads: list[dict[str, Any]] = [] + + @chat_middleware + async def constructor_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: + order.append("constructor-before") + context.options = {**(context.options or {}), "temperature": 0.1} + await call_next() + order.append("constructor-after") + + @chat_middleware + async def per_call_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: + order.append("per-call-before") + context.options = {**(context.options or {}), "top_p": 0.5} + await call_next() + order.append("per-call-after") + + def respond(request: Any) -> Any: + payloads.append(json.loads(request.content)) + return _completion_response({"content": "Hello"}, stream=stream) + + async with _client_with_transport(respond, middleware=[constructor_middleware]) as client: + messages = [Message(role="user", contents=["Hi"])] + if stream: + response = await client.get_response( + messages, stream=True, middleware=[per_call_middleware] + ).get_final_response() + else: + response = await client.get_response(messages, middleware=[per_call_middleware]) + assert response.text == "Hello" + assert order == ["constructor-before", "per-call-before", "per-call-after", "constructor-after"] + assert payloads[0]["temperature"] == 0.1 + assert payloads[0]["top_p"] == 0.5 + + +@pytest.mark.parametrize("stream", [False, True]) +async def test_foundry_local_agent_session_preserves_history( + mock_foundry_local_manager: MagicMock, stream: bool +) -> None: + payloads: list[dict[str, Any]] = [] + + def respond(request: Any) -> Any: + payloads.append(json.loads(request.content)) + return _completion_response({"content": "Remembered" if len(payloads) == 1 else "LANTERN"}, stream=stream) + + async with _client_with_transport(respond) as client: + agent = Agent(client=client, instructions="Remember the conversation.") + session = agent.create_session() + if stream: + await agent.run("Remember LANTERN", session=session, stream=True).get_final_response() + response = await agent.run("What did I say?", session=session, stream=True).get_final_response() + else: + await agent.run("Remember LANTERN", session=session) + response = await agent.run("What did I say?", session=session) + assert response.text == "LANTERN" + assert [message["role"] for message in payloads[1]["messages"]] == ["system", "user", "assistant", "user"] + assert payloads[1]["messages"][1]["content"] == "Remember LANTERN" + assert payloads[1]["messages"][2]["content"] == "Remembered" + + +@pytest.mark.parametrize("stream", [False, True]) +async def test_foundry_local_multimodal_request_serialization( + mock_foundry_local_manager: MagicMock, stream: bool +) -> None: + payloads: list[dict[str, Any]] = [] + + def respond(request: Any) -> Any: + payloads.append(json.loads(request.content)) + return _completion_response({"content": "A red square"}, stream=stream) + + async with _client_with_transport(respond) as client: + messages = [ + Message( + role="user", + contents=[ + "Describe this image", + Content.from_uri("https://example.test/image.png", media_type="image/png"), + ], + ) + ] + if stream: + response = await client.get_response(messages, stream=True).get_final_response() + else: + response = await client.get_response(messages) + assert response.text == "A red square" + assert payloads[0]["messages"] == [ + {"role": "user", "content": "Describe this image"}, + { + "role": "user", + "content": [{"type": "image_url", "image_url": {"url": "https://example.test/image.png"}}], + }, + ] + + +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize("status", [400, 404, 429, 503]) +async def test_foundry_local_http_errors_remain_visible( + mock_foundry_local_manager: MagicMock, stream: bool, status: int +) -> None: + def respond(request: Any) -> Any: + return _OPENAI_HTTPX.Response( + status, json={"error": {"message": "native inference failed", "type": "server_error"}} + ) + + async with _client_with_transport(respond) as client: + with pytest.raises(ChatClientException, match="native inference failed"): + messages = [Message(role="user", contents=["Hi"])] + if stream: + await client.get_response(messages, stream=True).get_final_response() + else: + await client.get_response(messages) + mock_foundry_local_manager.close.assert_not_called() + + +async def test_foundry_local_request_cancellation_preserves_manager(mock_foundry_local_manager: MagicMock) -> None: + started = asyncio.Event() + release = asyncio.Event() + requests = 0 + + async def respond(request: Any) -> Any: + nonlocal requests + requests += 1 + if requests == 1: + started.set() + await release.wait() + return _completion_response({"content": "Hello"}, stream=False) + + async with _client_with_transport(respond) as client: + messages = [Message(role="user", contents=["Hi"])] + + async def request_response() -> None: + await client.get_response(messages) + + task = asyncio.create_task(request_response()) + try: + await asyncio.wait_for(started.wait(), timeout=5) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + response = await client.get_response(messages) + assert response.text == "Hello" + finally: + release.set() + task.cancel() + mock_foundry_local_manager.close.assert_not_called() From ac5362b8139ca4e235aa74f514c23b3a3728a036 Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Tue, 13 Oct 2026 11:49:40 -0700 Subject: [PATCH 08/17] Retry CI after unrelated Windows approval checkpoint failure The Foundry Local checks passed. The first full matrix failed in the existing declarative MCP approval/checkpoint regression and cancelled other matrix rows. GitHub requires repository-admin rights for a rerun, so trigger a fresh check run without changing unrelated code. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d8ebdb3c-28b8-4fa1-830d-3fa7d6333d9e From bc01d2037a82e2ee94da3d1c001937b1fc2349ea Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Tue, 13 Oct 2026 14:52:03 -0700 Subject: [PATCH 09/17] Remove unchecked casts from Foundry Local request dispatch Forward streaming and non-streaming requests through checked overloads, declare the function layer's supported Foundry options explicitly, and narrow the required model setting with an explicit error guard. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d8ebdb3c-28b8-4fa1-830d-3fa7d6333d9e --- .../_foundry_local_client.py | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 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 592ff421a71..eef6665d171 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 @@ -5,7 +5,7 @@ import sys from collections.abc import Awaitable, Callable, Mapping, Sequence from threading import Event, Lock -from typing import Any, ClassVar, Generic, Literal, cast, overload +from typing import Any, ClassVar, Generic, Literal, overload from agent_framework import ( ChatAndFunctionMiddlewareTypes, @@ -21,6 +21,7 @@ TokenizerProtocol, ) from agent_framework._settings import load_settings +from agent_framework.exceptions import SettingNotFoundError from agent_framework.observability import ChatTelemetryLayer from agent_framework_openai._chat_completion_client import RawOpenAIChatCompletionClient from foundry_local_sdk import Configuration, DeviceType, FoundryLocalManager @@ -145,7 +146,7 @@ class FoundryLocalSettings(TypedDict, total=False): class FoundryLocalClient( - FunctionInvocationLayer[FoundryLocalChatOptionsT], + FunctionInvocationLayer[FoundryLocalChatOptionsT | FoundryLocalChatOptions[Any]], ChatMiddlewareLayer[FoundryLocalChatOptionsT], ChatTelemetryLayer[FoundryLocalChatOptionsT], RawOpenAIChatCompletionClient[FoundryLocalChatOptionsT], @@ -210,16 +211,22 @@ def get_response( middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: """Get a response from the Foundry Local chat client with all standard layers enabled.""" - super_get_response = cast( - "Callable[..., Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]]", - super().get_response, - ) effective_client_kwargs = dict(client_kwargs) if client_kwargs is not None else {} if middleware is not None: effective_client_kwargs["middleware"] = middleware - return super_get_response( + if stream: + return super().get_response( + messages=messages, + stream=True, + options=options, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=effective_client_kwargs, + ) + return super().get_response( messages=messages, - stream=stream, + stream=False, options=options, compaction_strategy=compaction_strategy, tokenizer=tokenizer, @@ -386,7 +393,9 @@ def __init__( env_file_path=env_file_path, env_file_encoding=env_file_encoding, ) - model_setting = cast(str, settings.get("model")) + model_setting = settings.get("model") + if model_setting is None: + raise SettingNotFoundError("Required setting 'model' was not provided.") device_type = DeviceType(device) if device is not None else None with _MANAGER_SETUP_LOCK: From c82880bee9cdfa574aada55de4e447061c5f6256 Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Tue, 13 Oct 2026 15:22:08 -0700 Subject: [PATCH 10/17] Correct Foundry Local BYOM HTTP route documentation SDK 2.1 supports registered local models through /catalogs/local/v1. Keep local-catalog selection intentionally outside this adapter, rather than incorrectly describing it as a backend limitation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d8ebdb3c-28b8-4fa1-830d-3fa7d6333d9e --- python/packages/foundry_local/AGENTS.md | 4 +++- python/packages/foundry_local/README.md | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/python/packages/foundry_local/AGENTS.md b/python/packages/foundry_local/AGENTS.md index 7c5326c5af3..f6e0d2b770d 100644 --- a/python/packages/foundry_local/AGENTS.md +++ b/python/packages/foundry_local/AGENTS.md @@ -6,7 +6,9 @@ Uses `foundry_local_sdk` (SDK 2.1+, below 3.0) on Python 3.11-3.14. Clients shar `FoundryLocalManager.instance` and use its built-in web service with the existing OpenAI transport. Model lifecycle operations live on catalog model objects. The adapter resolves public-catalog variants only; BYOM/local-catalog selection -is not exposed through SDK 2.1's HTTP service. +is intentionally not exposed by this adapter. SDK 2.1's HTTP service supports +registered local models through `/catalogs/local/v1`, separate from the public +`/v1` route used here. `FoundryLocalClient` exposes the SDK `Configuration` for initial manager setup, download progress/cancellation controls, and exact device/variant selection. diff --git a/python/packages/foundry_local/README.md b/python/packages/foundry_local/README.md index 0e5ef1b150e..52a8af6d376 100644 --- a/python/packages/foundry_local/README.md +++ b/python/packages/foundry_local/README.md @@ -103,8 +103,9 @@ must handle the returned function call before requesting a structured final repl ## Scope This adapter targets the SDK's public model catalog and HTTP chat service. -BYOM/local-catalog model selection is not exposed: SDK 2.1's HTTP service cannot -resolve models registered in its local catalog. Native-session-only features, +BYOM/local-catalog model selection is intentionally not exposed by this adapter. +SDK 2.1 supports registered local models through `/catalogs/local/v1`, but this +adapter uses the public-catalog `/v1` route. Native-session-only features, audio transcription, and embeddings are outside this chat integration. Image content follows the OpenAI transport's serialization, but a successful HTTP From fcfa815ba25cde93d34c3970ba04acb9451b390d Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Tue, 13 Oct 2026 15:39:09 -0700 Subject: [PATCH 11/17] Expose SDK-registered BYOM through the local catalog HTTP route Add explicit public/local catalog selection for model lookup and inference, retaining the public default and catalog isolation. Cover selection, invalid input, preparation, rollback, and streamed/non-streamed model overrides, and document SDK-owned registration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d8ebdb3c-28b8-4fa1-830d-3fa7d6333d9e --- python/packages/foundry_local/AGENTS.md | 9 +- python/packages/foundry_local/README.md | 57 +++++++- .../_foundry_local_client.py | 23 ++- .../packages/foundry_local/tests/conftest.py | 9 ++ .../tests/test_foundry_local_client.py | 135 +++++++++++++++++- 5 files changed, 213 insertions(+), 20 deletions(-) diff --git a/python/packages/foundry_local/AGENTS.md b/python/packages/foundry_local/AGENTS.md index f6e0d2b770d..4833d672287 100644 --- a/python/packages/foundry_local/AGENTS.md +++ b/python/packages/foundry_local/AGENTS.md @@ -5,10 +5,11 @@ Integration with Microsoft Foundry Local for local model inference. Uses `foundry_local_sdk` (SDK 2.1+, below 3.0) on Python 3.11-3.14. Clients share `FoundryLocalManager.instance` and use its built-in web service with the existing OpenAI transport. Model lifecycle operations live on catalog model objects. -The adapter resolves public-catalog variants only; BYOM/local-catalog selection -is intentionally not exposed by this adapter. SDK 2.1's HTTP service supports -registered local models through `/catalogs/local/v1`, separate from the public -`/v1` route used here. +The adapter defaults to the public catalog and `/v1`. Use `catalog_type="local"` +or `foundry_local_sdk.catalog.CatalogType.LOCAL` for SDK-registered BYOM models: +both lookup and HTTP inference use the local catalog and `/catalogs/local/v1`. +Catalogs never fall back to each other; per-request model overrides remain in the +selected catalog. The application owns local-model registration and assets. `FoundryLocalClient` exposes the SDK `Configuration` for initial manager setup, download progress/cancellation controls, and exact device/variant selection. diff --git a/python/packages/foundry_local/README.md b/python/packages/foundry_local/README.md index 52a8af6d376..890976fcffe 100644 --- a/python/packages/foundry_local/README.md +++ b/python/packages/foundry_local/README.md @@ -51,6 +51,54 @@ If construction fails after creating a new manager, that manager is closed; an existing application's manager is not closed. If failed construction started its web service, that service is stopped; a previously running service is left running. +## Bring Your Own Model + +Use `catalog_type="local"` (or `CatalogType.LOCAL`) for models registered through +the SDK's local catalog. Model lookup and HTTP inference both use that catalog: +local clients target `/catalogs/local/v1`, while the default public clients use +`/v1`. There is no fallback between catalogs. + +Register compatible ONNX Runtime GenAI model assets through the SDK before +constructing the client. The adapter does not import or convert model files. +Registration does not transfer ownership of the files. For example: + +```python +from agent_framework import Agent +from agent_framework.foundry import FoundryLocalClient +from foundry_local_sdk import Configuration, FoundryLocalManager, ModelInfoBuilder +from foundry_local_sdk.catalog import CatalogType + +FoundryLocalManager.initialize(Configuration(app_name="my-local-agent")) +manager = FoundryLocalManager.instance +if manager is None: + raise RuntimeError("Foundry Local manager was not initialized.") +local_catalog = manager.get_catalog(CatalogType.LOCAL) +with ModelInfoBuilder() as metadata: + metadata.set_string_property("task", "chat-completion") + registered_model = local_catalog.register_model( + "your-model-directory", "my-local-model:1", metadata + ) + +client = None +try: + client = FoundryLocalClient(model=registered_model.id, catalog_type="local") + agent = Agent(client=client, instructions="You are a helpful assistant.") + response = await agent.run("What is 2 + 2?") + print(response.text) +finally: + if client is not None: + await client.client.close() + registered_model.unload() + local_catalog.unregister_model(registered_model.id) + manager.close() +``` + +The normal preparation, exact variant selection, streaming, middleware, tools, +and structured-output APIs also apply to local models, subject to model/runtime +capabilities. `prepare_model=False` still requires manual loading before inference. +Clients do not unregister local models or delete their assets; the application +owns registration and manager shutdown. + ## Chat Options `FoundryLocalChatOptions` includes the standard generation settings and native @@ -94,7 +142,8 @@ print(response.value.answer) Agent sessions, middleware, streaming, and automatic tool execution retain their standard Agent Framework APIs. A per-request `model` option overrides inference -targeting only: that public-catalog variant must also be loaded in the shared manager. +targeting only: that variant must also be loaded in the client's selected catalog. +An override does not switch between the public and local catalogs. When a tool is required (including a named tool), the request uses the native tool grammar first, then applies the requested structured format to the final reply after automatic tool execution. With automatic execution disabled, the caller @@ -102,10 +151,8 @@ must handle the returned function call before requesting a structured final repl ## Scope -This adapter targets the SDK's public model catalog and HTTP chat service. -BYOM/local-catalog model selection is intentionally not exposed by this adapter. -SDK 2.1 supports registered local models through `/catalogs/local/v1`, but this -adapter uses the public-catalog `/v1` route. Native-session-only features, +This adapter targets the SDK's public and local model catalogs and HTTP chat service. +Local models must be registered through the SDK before use. Native-session-only features, audio transcription, and embeddings are outside this chat integration. Image content follows the OpenAI transport's serialization, but a successful HTTP 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 eef6665d171..3e5c7d7571b 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 @@ -25,6 +25,7 @@ from agent_framework.observability import ChatTelemetryLayer from agent_framework_openai._chat_completion_client import RawOpenAIChatCompletionClient from foundry_local_sdk import Configuration, DeviceType, FoundryLocalManager +from foundry_local_sdk.catalog import CatalogType from openai import DEFAULT_TIMEOUT, AsyncOpenAI from pydantic import BaseModel @@ -282,6 +283,7 @@ def __init__( bootstrap: bool = True, timeout: float | None = None, prepare_model: bool = True, + catalog_type: CatalogType | Literal["public", "local"] = CatalogType.PUBLIC, download_progress_callback: Callable[[float], None] | None = None, download_cancel_event: Event | None = None, device: DeviceType | Literal["CPU", "GPU", "NPU"] | None = None, @@ -306,6 +308,10 @@ def __init__( the inferencing service upon initialization. Default is True. If False, the exact model variant must already be loaded, or be downloaded and loaded manually before inference. + catalog_type: The catalog used for model lookup and HTTP inference. + Defaults to "public". Use "local" for models already registered through + the SDK's local catalog. Also accepts foundry_local_sdk.catalog.CatalogType. + Per-request model overrides remain in the selected catalog. download_progress_callback: Optional callback receiving native download progress as a percentage from 0 to 100. Used only when prepare_model is True. download_cancel_event: Optional event that cancels native model download when set. @@ -397,6 +403,13 @@ def __init__( if model_setting is None: raise SettingNotFoundError("Required setting 'model' was not provided.") device_type = DeviceType(device) if device is not None else None + catalog_kind = ( + {"public": CatalogType.PUBLIC, "local": CatalogType.LOCAL}.get(catalog_type) + if isinstance(catalog_type, str) + else catalog_type + ) + if not isinstance(catalog_kind, CatalogType): + raise ValueError("catalog_type must be 'public', 'local', or a CatalogType.") with _MANAGER_SETUP_LOCK: manager = FoundryLocalManager.instance @@ -426,11 +439,10 @@ def __init__( if not manager.urls: raise ValueError("Foundry Local web service did not provide a bound URL.") + catalog = manager.catalog if catalog_kind == CatalogType.PUBLIC else manager.get_catalog(catalog_kind) is_variant_id = ":" in model_setting model_handle = ( - manager.catalog.get_model_variant(model_setting) - if is_variant_id - else manager.catalog.get_model(model_setting) + catalog.get_model_variant(model_setting) if is_variant_id else catalog.get_model(model_setting) ) if model_handle is not None and device_type is not None: model_handle = next( @@ -443,7 +455,7 @@ def __init__( ) elif model_handle is not None and not is_variant_id: # Pin the alias's chosen variant before download changes the cached preference. - model_handle = manager.catalog.get_model_variant(model_handle.id) + model_handle = catalog.get_model_variant(model_handle.id) if model_handle is None: target = f"{model_setting}:{device_type.value}" if device_type is not None else model_setting raise ValueError(f"Model with ID or alias '{target}' not found in Foundry Local.") @@ -458,10 +470,11 @@ def __init__( ) model_handle.load() + catalog_path = "/catalogs/local" if catalog_kind == CatalogType.LOCAL else "" super().__init__( model=model_id, async_client=AsyncOpenAI( - base_url=f"{manager.urls[0].rstrip('/')}/v1", + base_url=f"{manager.urls[0].rstrip('/')}{catalog_path}/v1", api_key="foundry-local", timeout=timeout if timeout is not None else DEFAULT_TIMEOUT, ), diff --git a/python/packages/foundry_local/tests/conftest.py b/python/packages/foundry_local/tests/conftest.py index 437b53a7f45..cdb35a67cde 100644 --- a/python/packages/foundry_local/tests/conftest.py +++ b/python/packages/foundry_local/tests/conftest.py @@ -59,6 +59,15 @@ def create_manager() -> MagicMock: mock_model.variants = [mock_model] mock_manager.catalog.get_model.return_value = mock_model mock_manager.catalog.get_model_variant.return_value = mock_model + + local_catalog = MagicMock(spec=Catalog) + local_model = MagicMock(spec=IModel) + local_model.id = "local-model:1" + local_model.info = SimpleNamespace(runtime=SimpleNamespace(device_type=DeviceType.CPU)) + local_model.variants = [local_model] + local_catalog.get_model.return_value = local_model + local_catalog.get_model_variant.return_value = local_model + mock_manager.get_catalog.return_value = local_catalog return mock_manager return create_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 dc6f184105d..89d26fa7b6b 100644 --- a/python/packages/foundry_local/tests/test_foundry_local_client.py +++ b/python/packages/foundry_local/tests/test_foundry_local_client.py @@ -31,6 +31,7 @@ from agent_framework.exceptions import ChatClientException, SettingNotFoundError from agent_framework.foundry import FoundryLocalChatOptions, FoundryLocalClient from foundry_local_sdk import Configuration, DeviceType, IModel +from foundry_local_sdk.catalog import CatalogType from openai import AsyncOpenAI, DefaultAsyncHttpxClient from pydantic import BaseModel @@ -76,6 +77,113 @@ def test_foundry_local_client_init(mock_foundry_local_manager: MagicMock) -> Non assert client.client.api_key == "foundry-local" +@pytest.mark.parametrize("catalog_type", ["local", CatalogType.LOCAL]) +@pytest.mark.parametrize("model", ["local-model", "local-model:1"]) +def test_foundry_local_client_selects_local_catalog( + mock_foundry_local_manager: MagicMock, catalog_type: Literal["local"] | CatalogType, model: str +) -> None: + client = FoundryLocalClient(model=model, catalog_type=catalog_type) + + local_catalog = mock_foundry_local_manager.get_catalog.return_value + mock_foundry_local_manager.get_catalog.assert_called_once_with(CatalogType.LOCAL) + mock_foundry_local_manager.catalog.get_model.assert_not_called() + mock_foundry_local_manager.catalog.get_model_variant.assert_not_called() + local_catalog.get_model_variant.assert_called_with("local-model:1") + local_catalog.get_model_variant.return_value.download.assert_called_once_with() + local_catalog.get_model_variant.return_value.load.assert_called_once_with() + assert client.model == "local-model:1" + assert str(client.client.base_url) == "http://localhost:5272/catalogs/local/v1/" + + +@pytest.mark.parametrize("catalog_type", ["public", CatalogType.PUBLIC]) +def test_foundry_local_client_selects_public_catalog( + mock_foundry_local_manager: MagicMock, catalog_type: Literal["public"] | CatalogType +) -> None: + client = FoundryLocalClient(model="test-model-id", catalog_type=catalog_type) + mock_foundry_local_manager.get_catalog.assert_not_called() + assert client.model == "test-model-id" + assert str(client.client.base_url) == "http://localhost:5272/v1/" + + +@pytest.mark.parametrize("catalog_type", ["missing", None, 2]) +def test_foundry_local_client_rejects_invalid_catalog_before_setup( + mock_foundry_local_manager: MagicMock, catalog_type: Any +) -> None: + with ( + patch("agent_framework_foundry_local._foundry_local_client.FoundryLocalManager") as manager_class, + pytest.raises(ValueError, match="catalog_type"), + ): + manager_class.instance = None + FoundryLocalClient(model="test-model-id", catalog_type=catalog_type) + manager_class.initialize.assert_not_called() + mock_foundry_local_manager.start_web_service.assert_not_called() + + +def test_foundry_local_client_local_catalog_does_not_fall_back_to_public( + mock_foundry_local_manager: MagicMock, +) -> None: + mock_foundry_local_manager.get_catalog.return_value.get_model_variant.return_value = None + with pytest.raises(ValueError, match="not found"): + FoundryLocalClient(model="test-model-id:1", catalog_type="local") + mock_foundry_local_manager.catalog.get_model_variant.assert_not_called() + mock_foundry_local_manager.close.assert_not_called() + mock_foundry_local_manager.stop_web_service.assert_not_called() + + +def test_foundry_local_client_local_catalog_respects_manual_preparation(mock_foundry_local_manager: MagicMock) -> None: + client = FoundryLocalClient(model="local-model:1", catalog_type="local", prepare_model=False, bootstrap=False) + local_model = mock_foundry_local_manager.get_catalog.return_value.get_model_variant.return_value + local_model.download.assert_not_called() + local_model.load.assert_not_called() + mock_foundry_local_manager.start_web_service.assert_not_called() + assert str(client.client.base_url) == "http://localhost:5272/catalogs/local/v1/" + + +def test_foundry_local_client_local_catalog_selects_exact_device_variant(mock_foundry_local_manager: MagicMock) -> None: + with pytest.raises(ValueError, match="not found"): + FoundryLocalClient(model="local-model:1", catalog_type="local", device=DeviceType("GPU")) + local_catalog = mock_foundry_local_manager.get_catalog.return_value + local_catalog.get_model.assert_not_called() + local_catalog.get_model_variant.return_value.load.assert_not_called() + + +def test_foundry_local_client_local_catalog_preserves_download_controls(mock_foundry_local_manager: MagicMock) -> None: + progress: list[float] = [] + cancel_event = Event() + FoundryLocalClient( + model="local-model:1", + catalog_type="local", + download_progress_callback=progress.append, + download_cancel_event=cancel_event, + ) + local_model = mock_foundry_local_manager.get_catalog.return_value.get_model_variant.return_value + local_model.download.assert_called_once_with(progress_callback=progress.append, cancel_event=cancel_event) + local_model.load.assert_called_once_with() + + +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize("override_model", [False, True]) +async def test_foundry_local_client_local_catalog_http_route( + mock_foundry_local_manager: MagicMock, stream: bool, override_model: bool +) -> None: + requests: list[Any] = [] + + def respond(request: Any) -> Any: + requests.append(request) + return _completion_response({"content": "Hello"}, stream=stream) + + async with _client_with_transport(respond, catalog_type="local", model="local-model:1") as client: + messages = [Message(role="user", contents=["Hi"])] + options: FoundryLocalChatOptions[None] = {"model": "other-local:2"} if override_model else {} + if stream: + response = await client.get_response(messages, stream=True, options=options).get_final_response() + else: + response = await client.get_response(messages, options=options) + assert response.text == "Hello" + assert requests[0].url.path == "/catalogs/local/v1/chat/completions" + assert json.loads(requests[0].content)["model"] == ("other-local:2" if override_model else "local-model:1") + + def test_agent_accepts_foundry_local_client(mock_foundry_local_manager: MagicMock) -> None: client = FoundryLocalClient(model="test-model-id") agent = Agent(client=client, instructions="test agent") @@ -244,17 +352,26 @@ def test_foundry_local_client_requires_bound_url(mock_foundry_local_manager: Mag @pytest.mark.parametrize("service_running", [False, True]) @pytest.mark.parametrize("failure_stage", ["lookup", "download", "load", "http"]) +@pytest.mark.parametrize("catalog_type", ["public", "local"]) def test_foundry_local_client_rolls_back_service_started_on_existing_manager( - mock_foundry_local_manager: MagicMock, service_running: bool, failure_stage: str + mock_foundry_local_manager: MagicMock, + service_running: bool, + failure_stage: str, + catalog_type: Literal["public", "local"], ) -> None: if not service_running: mock_foundry_local_manager.urls = None mock_foundry_local_manager.start_web_service.side_effect = lambda: setattr( mock_foundry_local_manager, "urls", ["http://localhost:5272"] ) - model = mock_foundry_local_manager.catalog.get_model_variant.return_value + catalog = ( + mock_foundry_local_manager.catalog + if catalog_type == "public" + else mock_foundry_local_manager.get_catalog.return_value + ) + model = catalog.get_model_variant.return_value if failure_stage == "lookup": - mock_foundry_local_manager.catalog.get_model.return_value = None + catalog.get_model.return_value = None elif failure_stage in ("download", "load"): getattr(model, failure_stage).side_effect = RuntimeError("setup failed") with ( @@ -263,7 +380,7 @@ def test_foundry_local_client_rolls_back_service_started_on_existing_manager( ): if failure_stage == "http": http_client.side_effect = RuntimeError("setup failed") - FoundryLocalClient(model="test-model-id") + FoundryLocalClient(model="test-model-id", catalog_type=catalog_type) mock_foundry_local_manager.close.assert_not_called() if service_running: mock_foundry_local_manager.stop_web_service.assert_not_called() @@ -492,16 +609,22 @@ async def _client_with_transport( *, middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, + catalog_type: Literal["public", "local"] = "public", + model: str = "test-model-id", ) -> AsyncGenerator[FoundryLocalClient[FoundryLocalChatOptions[None]]]: async with ( DefaultAsyncHttpxClient(transport=_OPENAI_HTTPX.MockTransport(respond), trust_env=False) as http_client, AsyncOpenAI( - base_url="http://localhost:5272/v1", api_key="foundry-local", http_client=http_client, max_retries=0 + base_url=f"http://localhost:5272{'/catalogs/local' if catalog_type == 'local' else ''}/v1", + api_key="foundry-local", + http_client=http_client, + max_retries=0, ) as async_client, ): with patch("agent_framework_foundry_local._foundry_local_client.AsyncOpenAI", return_value=async_client): yield FoundryLocalClient( - model="test-model-id", + model=model, + catalog_type=catalog_type, prepare_model=False, middleware=middleware, function_invocation_configuration=function_invocation_configuration, From 78626760a70373b1be2d698b3496b4a253c69cd1 Mon Sep 17 00:00:00 2001 From: bmehta001 Date: Thu, 15 Oct 2026 03:29:04 -0500 Subject: [PATCH 12/17] Simplify docs --- python/packages/foundry_local/README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/python/packages/foundry_local/README.md b/python/packages/foundry_local/README.md index 890976fcffe..5a775e52043 100644 --- a/python/packages/foundry_local/README.md +++ b/python/packages/foundry_local/README.md @@ -1,6 +1,6 @@ # Get Started with Microsoft Agent Framework Foundry Local -Requires Python 3.11-3.14 and Foundry Local SDK 2.1 or newer (below 3.0). +Requires Python 3.11-3.14 and Foundry Local SDK 2.1 or newer. Install the integration: ```bash @@ -163,8 +163,6 @@ configuration before relying on it. ## Migration from SDK 0.5.1 This is a breaking SDK migration: Python 3.10 and SDK 0.5.1/1.x are no longer supported. -Python 3.15 is excluded because SDK 2.1 pins ONNX Runtime 1.30.0, which does not yet -publish Python 3.15 wheels. Update SDK imports from `foundry_local` to `foundry_local_sdk`. Use `client.manager.catalog.list_models()` instead of `list_catalog_models()`, inspect metadata through `model.info`, and call `download()`/`load()` on catalog model objects From 1078c33c2962c918448123d493c5d48a9fadae3e Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Thu, 15 Oct 2026 13:27:19 -0500 Subject: [PATCH 13/17] Preserve lazy first-call preparation for Foundry Local models Defer configured-model download and load to the async provider boundary when prepare_model=False. Reuse loaded variants, serialize native preparation without blocking the event loop, preserve download controls, and leave request overrides caller-prepared. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ecbd0aec-c6cd-4997-a0a7-d75f9a265e5b --- python/packages/foundry_local/AGENTS.md | 4 +- python/packages/foundry_local/README.md | 18 +- .../_foundry_local_client.py | 72 +++- .../packages/foundry_local/tests/conftest.py | 4 + .../tests/test_foundry_local_client.py | 312 +++++++++++++++++- 5 files changed, 388 insertions(+), 22 deletions(-) diff --git a/python/packages/foundry_local/AGENTS.md b/python/packages/foundry_local/AGENTS.md index 4833d672287..70e000b9b16 100644 --- a/python/packages/foundry_local/AGENTS.md +++ b/python/packages/foundry_local/AGENTS.md @@ -13,7 +13,9 @@ selected catalog. The application owns local-model registration and assets. `FoundryLocalClient` exposes the SDK `Configuration` for initial manager setup, download progress/cancellation controls, and exact device/variant selection. -Skipping preparation requires the selected variant to be loaded before inference. +`prepare_model=False` defers download/load of the configured variant until inference, +on a worker thread under the shared setup lock. Already loaded variants are reused. +Per-request model overrides remain caller-prepared; they do not prepare the configured model. Clients own their HTTP connections, not the shared native manager. Failed setup stops a web service it started on an existing manager without closing that manager or stopping a previously running service. diff --git a/python/packages/foundry_local/README.md b/python/packages/foundry_local/README.md index 5a775e52043..d6d653628b6 100644 --- a/python/packages/foundry_local/README.md +++ b/python/packages/foundry_local/README.md @@ -27,6 +27,7 @@ function invocation continue to use Agent Framework's OpenAI transport. All clients in a process share the SDK manager and web service. Concurrent client construction is serialized through model preparation and failed-setup cleanup. +Deferred preparation is serialized with setup too; HTTP inference does not hold that lock. To customize cache directories or other SDK settings, pass `configuration=Configuration(app_name="my-app", ...)` when creating the first client, or initialize `FoundryLocalManager` yourself before @@ -35,15 +36,20 @@ before selecting GPU/NPU variants. A `device` value of `"CPU"`, `"GPU"`, or `"NP selects a matching variant; an exact model ID is never replaced by a sibling variant. `bootstrap=False` requires an already initialized SDK manager with a running web -service in the same process. `prepare_model=False` skips native download/load during -construction; the exact selected variant must already be loaded or be prepared -manually before inference. The HTTP service does not load it automatically. +service in the same process. `prepare_model=False` defers native download/load until +the first inference request for the configured model. The adapter prepares the exact +selected variant on a worker thread before sending HTTP inference; an already loaded +variant is reused. This preserves lazy first-call loading even though the HTTP service +does not load models automatically. Applications can still download/load the variant +manually beforehand to avoid first-request preparation latency. `timeout` applies to HTTP inference requests, not native preparation. `download_progress_callback` receives download percentages from 0 to 100, and `download_cancel_event` accepts a `threading.Event` to cancel the download. -Both apply only when `prepare_model=True`. They do not cancel model loading or -inference; asynchronous request cancellation uses the normal Agent Framework API. +Both apply to eager and deferred preparation. Deferred progress callbacks run on a +worker thread. These controls do not cancel model loading or inference; asynchronous +request cancellation uses the normal Agent Framework API and does not interrupt +native preparation already running. Clients do not close or unload the shared manager. After all clients finish, close their HTTP clients and call `client.manager.close()` once at application shutdown. @@ -95,7 +101,7 @@ finally: The normal preparation, exact variant selection, streaming, middleware, tools, and structured-output APIs also apply to local models, subject to model/runtime -capabilities. `prepare_model=False` still requires manual loading before inference. +capabilities. `prepare_model=False` also supports lazy preparation of registered local models. Clients do not unregister local models or delete their assets; the application owns registration and manager shutdown. 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 3e5c7d7571b..193a7d8b87a 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 @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import sys from collections.abc import Awaitable, Callable, Mapping, Sequence from threading import Event, Lock @@ -24,7 +25,7 @@ from agent_framework.exceptions import SettingNotFoundError from agent_framework.observability import ChatTelemetryLayer from agent_framework_openai._chat_completion_client import RawOpenAIChatCompletionClient -from foundry_local_sdk import Configuration, DeviceType, FoundryLocalManager +from foundry_local_sdk import Configuration, DeviceType, FoundryLocalManager, IModel from foundry_local_sdk.catalog import CatalogType from openai import DEFAULT_TIMEOUT, AsyncOpenAI from pydantic import BaseModel @@ -235,6 +236,49 @@ def get_response( client_kwargs=effective_client_kwargs, ) + def _inner_get_response( + self, + *, + messages: Sequence[Message], + options: Mapping[str, Any], + stream: bool = False, + **kwargs: Any, + ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]: + inner_get_response = super()._inner_get_response + if not self._prepare_model_on_request or (options.get("model") or self.model) != self._model_handle.id: + return inner_get_response(messages=messages, options=options, stream=stream, **kwargs) + + if stream: + + async def _get_stream() -> ResponseStream[ChatResponseUpdate, ChatResponse]: + await asyncio.to_thread(self._ensure_model_loaded) + response = inner_get_response(messages=messages, options=options, stream=True, **kwargs) + if not isinstance(response, ResponseStream): + raise TypeError("Streaming responses must return a ResponseStream.") + return response # type: ignore[reportUnknownVariableType] + + return ResponseStream[ChatResponseUpdate, ChatResponse].from_awaitable(_get_stream()) + + async def _get_response() -> ChatResponse: + await asyncio.to_thread(self._ensure_model_loaded) + return await inner_get_response(messages=messages, options=options, stream=False, **kwargs) + + return _get_response() + + def _ensure_model_loaded(self) -> None: + with _MANAGER_SETUP_LOCK: + if not self._model_handle.is_loaded: + self._prepare_model() + + def _prepare_model(self) -> None: + if self._download_progress_callback is None and self._download_cancel_event is None: + self._model_handle.download() + else: + self._model_handle.download( + progress_callback=self._download_progress_callback, cancel_event=self._download_cancel_event + ) + self._model_handle.load() + def _prepare_options(self, messages: Sequence[Message], options: Mapping[str, Any]) -> dict[str, Any]: prepared = super()._prepare_options(messages, options) native_metadata: dict[str, str] = {} @@ -306,16 +350,19 @@ def __init__( client's default when omitted; does not apply to native model preparation. prepare_model: Whether to download the model into the cache, and load the model into the inferencing service upon initialization. Default is True. - If False, the exact model variant must already be loaded, or be downloaded - and loaded manually before inference. + If False, preparation is deferred until the first inference request for the + configured model. An already loaded variant is reused. The first request + may take longer while downloading and loading the model. catalog_type: The catalog used for model lookup and HTTP inference. Defaults to "public". Use "local" for models already registered through the SDK's local catalog. Also accepts foundry_local_sdk.catalog.CatalogType. Per-request model overrides remain in the selected catalog. download_progress_callback: Optional callback receiving native download progress - as a percentage from 0 to 100. Used only when prepare_model is True. + as a percentage from 0 to 100, including deferred preparation. + Deferred callbacks run on a worker thread. download_cancel_event: Optional event that cancels native model download when set. - Used only when prepare_model is True; does not cancel model loading or inference. + Also applies to deferred preparation; does not cancel model loading or inference. + Cancelling an awaiting request does not interrupt native preparation already in progress. 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. @@ -369,7 +416,8 @@ def __init__( model="phi-4-mini", prepare_model=False, ) - # Prepare the exact resolved variant manually before inference. + # The first inference request prepares the exact resolved variant. + # Alternatively, prepare it manually before inference: model = client.manager.catalog.get_model_variant(client.model) if model is not None: model.download() @@ -461,14 +509,12 @@ def __init__( raise ValueError(f"Model with ID or alias '{target}' not found in Foundry Local.") model_id = model_handle.id + self._model_handle: IModel = model_handle + self._prepare_model_on_request = not prepare_model + self._download_progress_callback = download_progress_callback + self._download_cancel_event = download_cancel_event if prepare_model: - if download_progress_callback is None and download_cancel_event is None: - model_handle.download() - else: - model_handle.download( - progress_callback=download_progress_callback, cancel_event=download_cancel_event - ) - model_handle.load() + self._prepare_model() catalog_path = "/catalogs/local" if catalog_kind == CatalogType.LOCAL else "" super().__init__( diff --git a/python/packages/foundry_local/tests/conftest.py b/python/packages/foundry_local/tests/conftest.py index cdb35a67cde..99ec805b9ea 100644 --- a/python/packages/foundry_local/tests/conftest.py +++ b/python/packages/foundry_local/tests/conftest.py @@ -57,6 +57,8 @@ def create_manager() -> MagicMock: mock_model.id = "test-model-id" mock_model.info = SimpleNamespace(runtime=SimpleNamespace(device_type=DeviceType.CPU)) mock_model.variants = [mock_model] + mock_model.is_loaded = False + mock_model.load.side_effect = lambda: setattr(mock_model, "is_loaded", True) mock_manager.catalog.get_model.return_value = mock_model mock_manager.catalog.get_model_variant.return_value = mock_model @@ -65,6 +67,8 @@ def create_manager() -> MagicMock: local_model.id = "local-model:1" local_model.info = SimpleNamespace(runtime=SimpleNamespace(device_type=DeviceType.CPU)) local_model.variants = [local_model] + local_model.is_loaded = False + local_model.load.side_effect = lambda: setattr(local_model, "is_loaded", True) local_catalog.get_model.return_value = local_model local_catalog.get_model_variant.return_value = local_model mock_manager.get_catalog.return_value = local_catalog 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 89d26fa7b6b..0173f9245a0 100644 --- a/python/packages/foundry_local/tests/test_foundry_local_client.py +++ b/python/packages/foundry_local/tests/test_foundry_local_client.py @@ -9,7 +9,7 @@ from copy import deepcopy from importlib import import_module from queue import Queue -from threading import Event, Lock +from threading import Event, Lock, get_ident from types import SimpleNamespace from typing import Any, Literal, cast from unittest.mock import MagicMock, patch @@ -611,6 +611,10 @@ async def _client_with_transport( function_invocation_configuration: FunctionInvocationConfiguration | None = None, catalog_type: Literal["public", "local"] = "public", model: str = "test-model-id", + prepare_model: bool = False, + device: DeviceType | Literal["CPU", "GPU", "NPU"] | None = None, + download_progress_callback: Callable[[float], None] | None = None, + download_cancel_event: Event | None = None, ) -> AsyncGenerator[FoundryLocalClient[FoundryLocalChatOptions[None]]]: async with ( DefaultAsyncHttpxClient(transport=_OPENAI_HTTPX.MockTransport(respond), trust_env=False) as http_client, @@ -625,7 +629,10 @@ async def _client_with_transport( yield FoundryLocalClient( model=model, catalog_type=catalog_type, - prepare_model=False, + prepare_model=prepare_model, + device=device, + download_progress_callback=download_progress_callback, + download_cancel_event=download_cancel_event, middleware=middleware, function_invocation_configuration=function_invocation_configuration, ) @@ -663,6 +670,307 @@ def _completion_response(message: dict[str, Any], *, stream: bool, finish_reason ) +@pytest.mark.parametrize("stream", [False, True]) +async def test_foundry_local_eager_preparation_still_prepares_during_construction( + mock_foundry_local_manager: MagicMock, stream: bool +) -> None: + model = mock_foundry_local_manager.catalog.get_model_variant.return_value + + def respond(request: Any) -> Any: + model.download.assert_called_once_with() + model.load.assert_called_once_with() + return _completion_response({"content": "Hello"}, stream=stream) + + async with _client_with_transport(respond, prepare_model=True) as client: + model.download.assert_called_once_with() + model.load.assert_called_once_with() + messages = [Message(role="user", contents=["Hi"])] + if stream: + response = await client.get_response(messages, stream=True).get_final_response() + else: + response = await client.get_response(messages) + assert response.text == "Hello" + + +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize("catalog_type", ["public", "local"]) +async def test_foundry_local_lazy_preparation_pins_variant_and_preserves_download_controls( + mock_foundry_local_manager: MagicMock, stream: bool, catalog_type: Literal["public", "local"] +) -> None: + catalog = ( + mock_foundry_local_manager.catalog + if catalog_type == "public" + else mock_foundry_local_manager.get_catalog.return_value + ) + variant = MagicMock(spec=IModel) + variant.id = "selected-gpu:2" + variant.info = SimpleNamespace(runtime=SimpleNamespace(device_type=DeviceType.GPU)) + variant.is_loaded = False + catalog.get_model.return_value.variants.append(variant) + event_loop_thread = get_ident() + progress_threads: list[int] = [] + progress: list[float] = [] + cancel_event = Event() + payloads: list[dict[str, Any]] = [] + + def report_progress(value: float) -> None: + progress_threads.append(get_ident()) + progress.append(value) + + def download(*, progress_callback: Callable[[float], None], cancel_event: Event) -> None: + assert not cancel_event.is_set() + assert get_ident() != event_loop_thread + progress_callback(100.0) + + def load() -> None: + assert get_ident() != event_loop_thread + variant.is_loaded = True + + variant.download.side_effect = download + variant.load.side_effect = load + + def respond(request: Any) -> Any: + assert variant.is_loaded + payloads.append(json.loads(request.content)) + return _completion_response({"content": "Hello"}, stream=stream) + + async with _client_with_transport( + respond, + model="model-alias", + catalog_type=catalog_type, + device="GPU", + download_progress_callback=report_progress, + download_cancel_event=cancel_event, + ) as client: + variant.download.assert_not_called() + variant.load.assert_not_called() + for _ in range(2): + messages = [Message(role="user", contents=["Hi"])] + if stream: + response_stream = client.get_response(messages, stream=True) + if not payloads: + variant.load.assert_not_called() + response = await response_stream.get_final_response() + else: + response = await client.get_response(messages) + assert response.text == "Hello" + assert [payload["model"] for payload in payloads] == ["selected-gpu:2", "selected-gpu:2"] + variant.download.assert_called_once_with(progress_callback=report_progress, cancel_event=cancel_event) + variant.load.assert_called_once_with() + catalog.get_model.return_value.select_variant.assert_not_called() + assert progress == [100.0] + assert progress_threads[0] != event_loop_thread + + +@pytest.mark.parametrize("stream", [False, True]) +async def test_foundry_local_lazy_preparation_reuses_loaded_model( + mock_foundry_local_manager: MagicMock, stream: bool +) -> None: + model = mock_foundry_local_manager.catalog.get_model_variant.return_value + + def respond(request: Any) -> Any: + return _completion_response({"content": "Hello"}, stream=stream) + + async with _client_with_transport(respond) as client: + # Manual preparation after client construction must also be respected. + model.is_loaded = True + messages = [Message(role="user", contents=["Hi"])] + if stream: + response = await client.get_response(messages, stream=True).get_final_response() + else: + response = await client.get_response(messages) + assert response.text == "Hello" + model.download.assert_not_called() + model.load.assert_not_called() + + +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize("stage", ["download", "load"]) +async def test_foundry_local_lazy_preparation_failure_does_not_send_inference_and_can_retry( + mock_foundry_local_manager: MagicMock, stream: bool, stage: str +) -> None: + model = mock_foundry_local_manager.catalog.get_model_variant.return_value + failed_operation = model.download if stage == "download" else model.load + failed_operation.side_effect = RuntimeError(f"{stage} failed") + requests: list[Any] = [] + + def respond(request: Any) -> Any: + requests.append(request) + assert model.is_loaded + return _completion_response({"content": "Hello"}, stream=stream) + + async with _client_with_transport(respond) as client: + messages = [Message(role="user", contents=["Hi"])] + with pytest.raises(RuntimeError, match=f"{stage} failed"): + if stream: + await client.get_response(messages, stream=True).get_final_response() + else: + await client.get_response(messages) + assert not requests + if stage == "download": + model.load.assert_not_called() + failed_operation.side_effect = None if stage == "download" else lambda: setattr(model, "is_loaded", True) + if stream: + response = await client.get_response(messages, stream=True).get_final_response() + else: + response = await client.get_response(messages) + assert response.text == "Hello" + assert len(requests) == 1 + mock_foundry_local_manager.close.assert_not_called() + mock_foundry_local_manager.stop_web_service.assert_not_called() + + +@pytest.mark.parametrize("stream", [False, True]) +async def test_foundry_local_lazy_preparation_serializes_clients_without_blocking_event_loop( + mock_foundry_local_manager: MagicMock, stream: bool +) -> None: + model = mock_foundry_local_manager.catalog.get_model_variant.return_value + started = Event() + release = Event() + requests: list[Any] = [] + + def load() -> None: + started.set() + if not release.wait(timeout=5): + raise TimeoutError("Test did not release native loading.") + model.is_loaded = True + + model.load.side_effect = load + + def respond(request: Any) -> Any: + assert model.is_loaded + requests.append(request) + return _completion_response({"content": "Hello"}, stream=stream) + + async with _client_with_transport(respond) as first, _client_with_transport(respond) as second: + + async def request_response(client: FoundryLocalClient[FoundryLocalChatOptions[None]]) -> str: + messages = [Message(role="user", contents=["Hi"])] + if stream: + return (await client.get_response(messages, stream=True).get_final_response()).text + return (await client.get_response(messages)).text + + first_request = asyncio.create_task(request_response(first)) + second_request = asyncio.create_task(request_response(second)) + try: + assert await asyncio.to_thread(started.wait, 5) + await asyncio.sleep(0) + assert not first_request.done() + assert not second_request.done() + assert not requests + finally: + release.set() + assert await asyncio.gather(first_request, second_request) == ["Hello", "Hello"] + model.download.assert_called_once_with() + model.load.assert_called_once_with() + assert len(requests) == 2 + + +@pytest.mark.parametrize("stream", [False, True]) +async def test_foundry_local_cancelling_lazy_request_preserves_native_preparation( + mock_foundry_local_manager: MagicMock, stream: bool +) -> None: + model = mock_foundry_local_manager.catalog.get_model_variant.return_value + started = Event() + release = Event() + requests: list[Any] = [] + + def load() -> None: + started.set() + if not release.wait(timeout=5): + raise TimeoutError("Test did not release native loading.") + model.is_loaded = True + + model.load.side_effect = load + + def respond(request: Any) -> Any: + assert model.is_loaded + requests.append(request) + return _completion_response({"content": "Hello"}, stream=stream) + + async with _client_with_transport(respond) as client: + + async def request_response() -> str: + messages = [Message(role="user", contents=["Hi"])] + if stream: + return (await client.get_response(messages, stream=True).get_final_response()).text + return (await client.get_response(messages)).text + + first_request = asyncio.create_task(request_response()) + try: + assert await asyncio.to_thread(started.wait, 5) + first_request.cancel() + with pytest.raises(asyncio.CancelledError): + await first_request + assert not requests + assert not model.is_loaded + finally: + release.set() + assert await request_response() == "Hello" + assert len(requests) == 1 + model.download.assert_called_once_with() + model.load.assert_called_once_with() + mock_foundry_local_manager.close.assert_not_called() + mock_foundry_local_manager.stop_web_service.assert_not_called() + + +@pytest.mark.parametrize("stream", [False, True]) +async def test_foundry_local_deferred_download_honors_cancellation_event( + mock_foundry_local_manager: MagicMock, stream: bool +) -> None: + model = mock_foundry_local_manager.catalog.get_model_variant.return_value + cancel_event = Event() + cancel_event.set() + requests: list[Any] = [] + + def download(*, progress_callback: Callable[[float], None] | None, cancel_event: Event) -> None: + assert cancel_event.is_set() + raise RuntimeError("download cancelled") + + model.download.side_effect = download + + def respond(request: Any) -> Any: + requests.append(request) + return _completion_response({"content": "Hello"}, stream=stream) + + async with _client_with_transport(respond, download_cancel_event=cancel_event) as client: + model.download.assert_not_called() + with pytest.raises(RuntimeError, match="download cancelled"): + messages = [Message(role="user", contents=["Hi"])] + if stream: + await client.get_response(messages, stream=True).get_final_response() + else: + await client.get_response(messages) + model.download.assert_called_once_with(progress_callback=None, cancel_event=cancel_event) + model.load.assert_not_called() + assert not requests + + +@pytest.mark.parametrize("stream", [False, True]) +async def test_foundry_local_lazy_preparation_does_not_provision_request_overrides( + mock_foundry_local_manager: MagicMock, stream: bool +) -> None: + model = mock_foundry_local_manager.catalog.get_model_variant.return_value + payloads: list[dict[str, Any]] = [] + + def respond(request: Any) -> Any: + payloads.append(json.loads(request.content)) + return _completion_response({"content": "Hello"}, stream=stream) + + async with _client_with_transport(respond) as client: + messages = [Message(role="user", contents=["Hi"])] + if stream: + response = await client.get_response( + messages, stream=True, options={"model": "caller-prepared:1"} + ).get_final_response() + else: + response = await client.get_response(messages, options={"model": "caller-prepared:1"}) + assert response.text == "Hello" + assert payloads[0]["model"] == "caller-prepared:1" + model.download.assert_not_called() + model.load.assert_not_called() + + @pytest.mark.parametrize("stream", [False, True]) async def test_foundry_local_generation_options_and_usage(mock_foundry_local_manager: MagicMock, stream: bool) -> None: requests: list[Any] = [] From 7c6c898de57e0d94e1116ce4ee87bbbd0963417f Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Mon, 19 Oct 2026 18:49:39 -0500 Subject: [PATCH 14/17] Normalize raw Foundry Local response schemas with older OpenAI adapters Keep dependency floors unchanged and cover raw schemas, schema references, and named envelopes for streaming and non-streaming requests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8cc02580-c3c4-41aa-a2f6-f06f37c1cb62 --- .../_foundry_local_client.py | 18 +++++---- .../tests/test_foundry_local_client.py | 40 +++++++++++++++++-- 2 files changed, 48 insertions(+), 10 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 193a7d8b87a..5012c2f5265 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 @@ -307,13 +307,17 @@ def _prepare_options(self, messages: Sequence[Message], options: Mapping[str, An if "max_completion_tokens" in prepared: prepared["max_tokens"] = prepared.pop("max_completion_tokens") response_format: Mapping[str, Any] | None = prepared.get("response_format") - if isinstance(response_format, Mapping) and response_format.get("type") == "json_schema": - schema: Mapping[str, Any] | None = response_format.get("json_schema") - if isinstance(schema, Mapping): - prepared["response_format"] = { - **response_format, - "json_schema": schema.get("schema", schema), - } + if isinstance(response_format, Mapping): + if response_format.get("type") == "json_schema": + schema: Mapping[str, Any] | None = response_format.get("json_schema") + if isinstance(schema, Mapping): + prepared["response_format"] = { + **response_format, + "json_schema": schema.get("schema", schema), + } + elif response_format.get("type") not in ("json_object", "text"): + # Older OpenAI adapters pass raw JSON schemas through unchanged. + prepared["response_format"] = {"type": "json_schema", "json_schema": response_format} tool_choice: str | Mapping[str, Any] | None = prepared.get("tool_choice") if tool_choice == "required" or (isinstance(tool_choice, Mapping) and tool_choice.get("type") == "function"): # Native schema grammar overrides forced tools; retain it only for the final reply. 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 0173f9245a0..8ce116f4128 100644 --- a/python/packages/foundry_local/tests/test_foundry_local_client.py +++ b/python/packages/foundry_local/tests/test_foundry_local_client.py @@ -30,6 +30,7 @@ from agent_framework._settings import load_settings from agent_framework.exceptions import ChatClientException, SettingNotFoundError from agent_framework.foundry import FoundryLocalChatOptions, FoundryLocalClient +from agent_framework_openai import RawOpenAIChatCompletionClient from foundry_local_sdk import Configuration, DeviceType, IModel from foundry_local_sdk.catalog import CatalogType from openai import AsyncOpenAI, DefaultAsyncHttpxClient @@ -1101,8 +1102,31 @@ def respond(request: Any) -> Any: assert not requests +@pytest.mark.parametrize( + "schema", + [ + {"type": "object", "properties": {"answer": {"type": "integer"}}}, + {"properties": {"answer": {"type": "integer"}}}, + {"$defs": {"Answer": {"type": "integer"}}, "$ref": "#/$defs/Answer"}, + ], +) +def test_foundry_local_raw_schema_from_older_openai_adapter( + mock_foundry_local_manager: MagicMock, schema: dict[str, Any] +) -> None: + client = FoundryLocalClient(model="test-model-id") + original_schema = deepcopy(schema) + # agent-framework-openai 1.6.0 passes raw schema dictionaries through unchanged. + with patch.object(RawOpenAIChatCompletionClient, "_prepare_options", return_value={"response_format": schema}): + prepared = client._prepare_options([], {"response_format": schema}) + assert prepared["response_format"] == {"type": "json_schema", "json_schema": schema} + assert schema == original_schema + + @pytest.mark.parametrize("stream", [False, True]) -@pytest.mark.parametrize("format_kind", ["pydantic", "schema", "native_schema", "json_object"]) +@pytest.mark.parametrize( + "format_kind", + ["pydantic", "schema", "schema_no_type", "schema_ref", "native_schema", "named_schema", "json_object"], +) async def test_foundry_local_structured_responses( mock_foundry_local_manager: MagicMock, stream: bool, format_kind: str ) -> None: @@ -1120,13 +1144,19 @@ def respond(request: Any) -> Any: "properties": {"answer": {"type": "integer"}}, "required": ["answer"], } + if format_kind == "schema_no_type": + schema.pop("type") + elif format_kind == "schema_ref": + schema = {"$defs": {"Answer": schema}, "$ref": "#/$defs/Answer"} options: FoundryLocalChatOptions[Answer] = { "response_format": Answer if format_kind == "pydantic" else schema - if format_kind == "schema" + if format_kind in ("schema", "schema_no_type", "schema_ref") else {"type": "json_schema", "json_schema": schema} if format_kind == "native_schema" + else {"type": "json_schema", "json_schema": {"name": "Answer", "schema": schema, "strict": True}} + if format_kind == "named_schema" else {"type": "json_object"} } original_options = deepcopy(options) @@ -1145,7 +1175,11 @@ def respond(request: Any) -> Any: if format_kind != "json_object": native_schema = payloads[0]["response_format"]["json_schema"] assert isinstance(native_schema, dict) - assert native_schema["type"] == "object" + if format_kind == "schema_ref": + assert native_schema["$ref"] == "#/$defs/Answer" + native_schema = native_schema["$defs"]["Answer"] + if format_kind != "schema_no_type": + assert native_schema["type"] == "object" assert native_schema["properties"]["answer"]["type"] == "integer" assert options == original_options From 55e5050cb0a97b56e7867aacb91077efbc3a7d5d Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Tue, 20 Oct 2026 23:42:16 -0500 Subject: [PATCH 15/17] Update Foundry Local integration lock to SDK 2.2.0 Use the released SDK with its pinned ORT 1.31.0 and GenAI 0.18.0 runtimes while retaining the existing SDK compatibility range. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 90bff81a-94dd-4dc2-aa6e-3c500fd5232b --- python/uv.lock | 74 +++++++++++++++++++++++++------------------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/python/uv.lock b/python/uv.lock index 8cb425d7f4e..8e61599af45 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -2202,7 +2202,7 @@ wheels = [ [[package]] name = "foundry-local-sdk" -version = "2.1.0" +version = "2.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, @@ -2214,11 +2214,11 @@ dependencies = [ { name = "typing-extensions" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/17/26/53f7ad8b1015861b4b485796e5f81d2423d0c9cb1b7ffa5063e9da515d7e/foundry_local_sdk-2.1.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b1d24f24945be74b651b5a5e9c3c85e17d08e15f50b27b22a6ecd5bc23889824", size = 6602643, upload-time = "2026-09-29T23:04:24.263Z" }, - { url = "https://files.pythonhosted.org/packages/33/9a/644d2c00941850c67c351284c13c9190d6faa41b6c632ee5c71966739252/foundry_local_sdk-2.1.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:93d8c398be098d4fc5e25bedf48248bc5a273b658aec6c0bee92d3fc4f84e9fe", size = 8435620, upload-time = "2026-09-29T23:04:26.371Z" }, - { url = "https://files.pythonhosted.org/packages/10/3a/dfe75715c97f0c5372c991653c36adf11a3baed63d7b263d59af5651dae4/foundry_local_sdk-2.1.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2044f4cca65f8ce562094860d5ca849fe7c41711aa70000ea4966871e0d43d49", size = 8635403, upload-time = "2026-09-29T23:04:28.574Z" }, - { url = "https://files.pythonhosted.org/packages/ce/fd/5a09876e7c65447c590bf35e9b9b013df1f5849b2a85706639732d1d183f/foundry_local_sdk-2.1.0-cp311-abi3-win_amd64.whl", hash = "sha256:e71c8db275e157ff84ce2db5471ff0c418a42e26d49f2dd88bcb291a8d532022", size = 37527856, upload-time = "2026-09-29T23:04:31.797Z" }, - { url = "https://files.pythonhosted.org/packages/f6/a3/d19bb89667583e3705d46aba0b6dc97e523e9fdb3cbfc1db638b416fb70f/foundry_local_sdk-2.1.0-cp311-abi3-win_arm64.whl", hash = "sha256:822950dc45e9b48f76f9cbfb6aa86f23bba591b2d76bbfa300500228df959f73", size = 37028640, upload-time = "2026-09-29T23:04:35.833Z" }, + { url = "https://files.pythonhosted.org/packages/19/e1/d46db6cfbcd10f359283fcf9e1721f3d816b1a6efadb719c680c3ce9e1f5/foundry_local_sdk-2.2.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:4c2adf49d17b5f8af85a8c3441c1f89b2c500288208b4dfa1947a1ecce9b8770", size = 6394412 }, + { url = "https://files.pythonhosted.org/packages/11/9b/503670a54d23974034e311d50c29c1027df488e43df2891e0e82838c8420/foundry_local_sdk-2.2.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6c4b50820a0535989c90639ad5854c46753222fbf225c7491eabaa96a35eb872", size = 8993455 }, + { url = "https://files.pythonhosted.org/packages/14/6e/22690505066b3ac40ccbfe41400cb1d17bc36f0b4119062599dfdd263721/foundry_local_sdk-2.2.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9ff88a6b6ef6dc08ec43e9960c7cd7176f7f6d1af20e742a08e899974c9205e8", size = 8694204 }, + { url = "https://files.pythonhosted.org/packages/a7/e7/d91bf7590cd761b3b522ee6a20d894cae3703742c3fb20d40fd21df3d1aa/foundry_local_sdk-2.2.0-cp311-abi3-win_amd64.whl", hash = "sha256:a3e20fb1fbacc470a700f9c1462dd9b8db16a282c61ee450024074c764258124", size = 40810205 }, + { url = "https://files.pythonhosted.org/packages/0b/89/55c873bb8fcc4acb2933fe6ee9123870e61e020c9f20e921459f0486f1ce/foundry_local_sdk-2.2.0-cp311-abi3-win_arm64.whl", hash = "sha256:3af9de4ec792fef66800c19ae63cdc6e99c464c7e52c98c3a59ba09d3366e38c", size = 40575017 }, ] [[package]] @@ -3611,7 +3611,7 @@ wheels = [ [[package]] name = "onnxruntime" -version = "1.30.0" +version = "1.31.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "flatbuffers" }, @@ -3621,42 +3621,42 @@ dependencies = [ { name = "protobuf" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/73/b6/e2fa6dfc8c3cd19639d452c3120fb0e02f5176b4e24ecabb24b5362be725/onnxruntime-1.30.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:f36c87ec504f4f328e968ee322e4ba9682abe66280b3425625afcbed5333fed7", size = 21533037, upload-time = "2026-09-10T16:31:03.582Z" }, - { url = "https://files.pythonhosted.org/packages/4f/1a/4d197d2676a866f6239cd2d06a95b6db40d47dc29d5bec9fb1493a13dd94/onnxruntime-1.30.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bac3aee7b40acd805659fd81a2acd6096312d5b0f014c97ddfe75fb4c1644627", size = 21332697, upload-time = "2026-09-10T16:31:08.118Z" }, - { url = "https://files.pythonhosted.org/packages/4f/2e/4c96278a99140d0307ccda6fd51e6e9d1ca7c9f6fdd1fa96ff375acfc713/onnxruntime-1.30.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:fd54b314ea385bcecac69ab431f020ba503e3878dad4ebb645fec5a24b041242", size = 23561046, upload-time = "2026-09-10T16:31:11.494Z" }, - { url = "https://files.pythonhosted.org/packages/66/68/b8aaade8c1883f569228f4b57201cce922bb7d4af6f977d619520a733430/onnxruntime-1.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:0edd0145a6e3fce8a1276491dc784d615e3c58bcb952c9b4e5c876d5c6a12ad7", size = 14309136, upload-time = "2026-09-10T16:31:14.181Z" }, - { url = "https://files.pythonhosted.org/packages/96/f1/fbda6fb08ac4db6c5c6f7c3e128e4d8fb398b52fe2fc3282c45cadedd18b/onnxruntime-1.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:16506cfa5d218850f2b46e0edce1b952121f2f84f8e0074dafe15f4787f0c8fb", size = 14167408, upload-time = "2026-09-10T16:31:16.505Z" }, - { url = "https://files.pythonhosted.org/packages/31/6f/48169f2e62b405bff5053cbd1d73fb5ce41ef7ecd13bb3bfcc191e689b8a/onnxruntime-1.30.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:001ed726c9bd5e2bc92faade7d37d889e9606a350b7d5529f0227df2e3bb57fd", size = 21544867, upload-time = "2026-09-10T16:31:19.876Z" }, - { url = "https://files.pythonhosted.org/packages/16/bd/cbc5b8f91963689fdd622f463508c01d0aa95d3f944747b1e0b1eb2160b8/onnxruntime-1.30.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:6c32a000d5139a38ba9349030b0032e3331acb559d596b22738d9d2b343a2b83", size = 21345202, upload-time = "2026-09-10T16:31:23.361Z" }, - { url = "https://files.pythonhosted.org/packages/34/35/e7f862dbacbc99fadd9b14a614e49c99bf0f35fd9927a82f096e3de33531/onnxruntime-1.30.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:fa688e7891a6aa206636fe7372e27ee75fd17713289f6b4fc7b190e0a7de9328", size = 23585654, upload-time = "2026-09-10T16:31:26.65Z" }, - { url = "https://files.pythonhosted.org/packages/a6/13/0f1699f6de549c9324bc9112a2a85b14c517904cd11b562a654643b755a1/onnxruntime-1.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:f3501472571f1b1eee50e017851e7929f5ea37312d2d8c2494a19e8fc58b4a38", size = 14311470, upload-time = "2026-09-10T16:31:31.273Z" }, - { url = "https://files.pythonhosted.org/packages/a2/17/02b13e5f51461f0453b18ab854e2d0bc1b6ec353241b05a2ee6b79e35d87/onnxruntime-1.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:dc4c706f1935ebb62356e6a095b047859badd854482c40560888e95c328ed262", size = 14175072, upload-time = "2026-09-10T16:31:34.541Z" }, - { url = "https://files.pythonhosted.org/packages/f0/75/508454c5d01f31641dabc597fe559594c931a520a2673031179319d0afd8/onnxruntime-1.30.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:05e4fc41711d1f4abd19a9124b5be7a65a506cb2670a7f14b16162ef13c58134", size = 21544990, upload-time = "2026-09-10T16:31:37.685Z" }, - { url = "https://files.pythonhosted.org/packages/89/06/e603c71f43f4fe3fd156a053af79cbed6e27a2c649f0988a67d97fedd39f/onnxruntime-1.30.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:5327cf6aa15a02bad805fac8bd6882a62571e8b72f6f2938a8f37e6bd1966ce9", size = 21344996, upload-time = "2026-09-10T16:31:40.915Z" }, - { url = "https://files.pythonhosted.org/packages/f1/a1/ede48ab5dc54907a2999362777f541e132639fb06628ded1932058aa8a36/onnxruntime-1.30.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:86f940afc801ea9681a4da8af84fbe95e1d9ea7d80903952cc1bfad54faad38f", size = 23585560, upload-time = "2026-09-10T16:31:44.941Z" }, - { url = "https://files.pythonhosted.org/packages/3c/dd/c57c529dbc6dd55eca24b12cfbeab1b6a690de72083824eca689085f55b0/onnxruntime-1.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:4b63041bd623a9a9ac5e353948436c6fa7f43edd12d6b4a4ebc340bca959ba93", size = 14311378, upload-time = "2026-09-10T16:31:48.034Z" }, - { url = "https://files.pythonhosted.org/packages/11/2f/ef00b45b911e7f2115273a24ed1b43e73c8c6c9ec17f9bfa13f8492581f8/onnxruntime-1.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:c389b6887fc95e0fcb80e89b156bc2cb18e662c29df55e9326fe64140e7d7b4f", size = 14175134, upload-time = "2026-09-10T16:31:50.799Z" }, - { url = "https://files.pythonhosted.org/packages/92/0a/284fd6fe701c9a8aff39dbc119c37582ca40e9cff1ea05425a7cc8606a02/onnxruntime-1.30.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:e5224ba2b00284cb1c48b3edcd303c109245d66df9ec1b861858fd6de672a38e", size = 21349453, upload-time = "2026-09-10T16:31:53.898Z" }, - { url = "https://files.pythonhosted.org/packages/d6/72/4f4466f8fa1ec267a9ef5e2f3bd175c203d3da0facbdf4049dc93abbe91c/onnxruntime-1.30.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:3f9e002417f1e3bbb31ed43dafa4f22ca1b2b68832244fdee192fcaa1ae19bca", size = 23579837, upload-time = "2026-09-10T16:31:56.82Z" }, - { url = "https://files.pythonhosted.org/packages/6a/03/05c9a9234688757d2876ddecf80bba908561ee11debf125bc1a427ae6f48/onnxruntime-1.30.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:8b6169c16a48429890d2f4a0c774ebf54dfe9066a998514aad0518a16d398547", size = 21545313, upload-time = "2026-09-10T16:31:59.654Z" }, - { url = "https://files.pythonhosted.org/packages/c6/bc/1069e58b24779ba9d2fd479db5ecb3a15a6f49b585107c898819c0789558/onnxruntime-1.30.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:d2184fddb6798136e7c478244391ca82443f5c757f59f15bb9e5ad2da5e03175", size = 21346830, upload-time = "2026-09-10T16:32:02.275Z" }, - { url = "https://files.pythonhosted.org/packages/f1/38/8138eed225c5bc6ddfc05879ecac7dacc63c34b9b6f99be72839c1f6dc49/onnxruntime-1.30.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:8b611d24db2954545ce6bd9acd4670183cb368e4642450de7a9ab6474eb374ec", size = 23586333, upload-time = "2026-09-10T16:32:04.996Z" }, - { url = "https://files.pythonhosted.org/packages/4d/0a/748b86000fbf9518b5dfc5bdc1b924eeaca976203740fd9d30649e568ee6/onnxruntime-1.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bdd1752a8502ac1a7ccc6e878d16db6943df574d54ed7f01058a6806ae05be4", size = 14675319, upload-time = "2026-09-10T16:32:07.5Z" }, - { url = "https://files.pythonhosted.org/packages/2f/db/db8e4c0cf6f70f1311060560630fd21763648e01dfbba5195a2592c052ef/onnxruntime-1.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:83d543843cbd352cfa9996a6c7b92f8a480f114a18f95ff2a1acaf6921e85b6d", size = 14569360, upload-time = "2026-09-10T16:32:09.896Z" }, - { url = "https://files.pythonhosted.org/packages/24/0a/ec0a9d656e39b43887c378a5388b20c3b1e1ee43bded84580b0936466694/onnxruntime-1.30.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:265de607ba6f9814264e1d5d413fa7069d70f48e3049b5e460b3b04bdfcef294", size = 21348433, upload-time = "2026-09-10T16:32:12.772Z" }, - { url = "https://files.pythonhosted.org/packages/91/f0/40f74b7c00077e1e25627067ed98a70df1fef5c0e21b82849190d312554e/onnxruntime-1.30.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:67ad7f03433b6462c627d0f555dece80e6a26bc71e8542ced35cebd32142d1b7", size = 23579340, upload-time = "2026-09-10T16:32:15.532Z" }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/37/de/6cab7e39917cc87728d2f00abe97c81fe86b29f9e1f758627864c28f0c21/onnxruntime-1.31.0-cp311-cp311-win_amd64.whl", hash = "sha256:317608967b03807ed4661113b08293fac02a1db6496a6863a07d9f19232936ad", size = 14885750 }, + { url = "https://files.pythonhosted.org/packages/1d/11/f335a124a1aadda99e5a2b618264606504bd9e3763b1b2486e6441cd65e5/onnxruntime-1.31.0-cp311-cp311-win_arm64.whl", hash = "sha256:e85c1632c0a8cf488bd8f1039f5320877b864c8f9ebd4122fb8bb909f83b7096", size = 14735138 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/be/ed/b12cea136ccd7b03d924f46b8393faf7ceac21115c0c50e729faa248cf23/onnxruntime-1.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:09d56445c1753e66e0912de69d3f0184016ad9a191dcd6925bf5dd570d2bfbe5", size = 14888841 }, + { url = "https://files.pythonhosted.org/packages/02/ad/37bbc51dcb5cd105c5b2fe98f122b23e90171c2719516964edc65bb1d4cc/onnxruntime-1.31.0-cp312-cp312-win_arm64.whl", hash = "sha256:5c54a0eb7b2b4eef3eb9dcfaf82f5ce880db07288dc309574f6657e9da5cc754", size = 14740604 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/c4/86/05ed2056f43b27aaf12ebc592ebd9037a26bed315958cf882f43425fd469/onnxruntime-1.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:73e0165d58ece068c2a8a1c477c90b38e5a8adbbd399fdfdfd4bd79cbc28ff8d", size = 14888306 }, + { url = "https://files.pythonhosted.org/packages/c9/93/d33bae7b1a78780c4946ce03989c59a67d42d7015ad62d2098975fc5a580/onnxruntime-1.31.0-cp313-cp313-win_arm64.whl", hash = "sha256:e51d10d2e2e1e5bbf9b126a0cd9853d3e6c4e21424518dd50160b91471be33dc", size = 14740892 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/6a/66/0bf4fdb9f58efa69cf4eddde24c72aebcc628d6ff1d67c9546145c6b9922/onnxruntime-1.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:83e3dbcf6abc6189c4bdf7d329c07ba1133c88172134c266d84b4409aa3b9dbf", size = 15268709 }, + { url = "https://files.pythonhosted.org/packages/af/99/75a36172c1ed1d74ac0e91c11d642548081e2c9c63f15ee796564619556f/onnxruntime-1.31.0-cp314-cp314-win_arm64.whl", hash = "sha256:d2d5ac22f896c810be2b2b171392bb908f80b6c9a7e2d592ddb7435c928044e1", size = 15153795 }, + { 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 }, + { 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 }, ] [[package]] name = "onnxruntime-genai-core" -version = "0.17.1" +version = "0.18.0" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bb/36/b77fce1cd96379f6eff16ba9c8ec2232fba51bd03c88a3cfc6f03d2afa7a/onnxruntime_genai_core-0.17.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0b4a59016f30e1d3b016d5886b28ec94e61d5ec1b387ab13c5db55ee392b0768", size = 4241813, upload-time = "2026-09-29T05:51:23.563Z" }, - { url = "https://files.pythonhosted.org/packages/a4/2a/0bfac7f2742868bdeb96faa46107c0ab68aba016bf5b40b4015365e695fa/onnxruntime_genai_core-0.17.1-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:905591a6b8bb3f002933d5fc4352b7c3d3d9a70c43173bba84453e64aa4ef12d", size = 11440816, upload-time = "2026-09-29T05:51:25.477Z" }, - { url = "https://files.pythonhosted.org/packages/40/e4/380b185ae8b2791595be1df5d48b8a7039acf9f5aa02c4a1759710e52ee6/onnxruntime_genai_core-0.17.1-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:07b6a719d7251bf60ceb404b4d645c7a636fe0cd1e476de63f7d1b68418d3e59", size = 11752690, upload-time = "2026-09-29T05:51:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/fd/b2/aff81f8abc40caffe5cd0e9161ab5f620ed84f4eb758aed3574fb5927bc1/onnxruntime_genai_core-0.17.1-py3-none-win_amd64.whl", hash = "sha256:c930c8974def0cb6159390be3fdd1f1c9a0ab13f29d11666dee2f4d255f9f6c6", size = 3727344, upload-time = "2026-09-29T05:51:29.682Z" }, - { url = "https://files.pythonhosted.org/packages/a1/85/8759cb300c012968da1d08f48344327c8f4fa0fcb0623589baf55a5700a4/onnxruntime_genai_core-0.17.1-py3-none-win_arm64.whl", hash = "sha256:32baddf2637c8b392e22cef821c3fc7a7bb39045bc2978880c294edbcfe215fb", size = 3558257, upload-time = "2026-09-29T05:51:30.991Z" }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, ] [[package]] From da5d088c37d8607f5287b8c0eba4ee937a8d067e Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Wed, 21 Oct 2026 16:35:36 -0500 Subject: [PATCH 16/17] Python: require Foundry Local SDK 2.2.1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 294da005-b292-4823-9b62-763305e6b0c1 --- python/packages/foundry_local/AGENTS.md | 2 +- python/packages/foundry_local/README.md | 2 +- python/packages/foundry_local/pyproject.toml | 2 +- python/uv.lock | 14 +++++++------- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/python/packages/foundry_local/AGENTS.md b/python/packages/foundry_local/AGENTS.md index 70e000b9b16..449d76a295d 100644 --- a/python/packages/foundry_local/AGENTS.md +++ b/python/packages/foundry_local/AGENTS.md @@ -2,7 +2,7 @@ Integration with Microsoft Foundry Local for local model inference. -Uses `foundry_local_sdk` (SDK 2.1+, below 3.0) on Python 3.11-3.14. Clients share +Uses `foundry_local_sdk` (SDK 2.2.1+, below 3.0) on Python 3.11-3.14. Clients share `FoundryLocalManager.instance` and use its built-in web service with the existing OpenAI transport. Model lifecycle operations live on catalog model objects. The adapter defaults to the public catalog and `/v1`. Use `catalog_type="local"` diff --git a/python/packages/foundry_local/README.md b/python/packages/foundry_local/README.md index d6d653628b6..44a0d83033f 100644 --- a/python/packages/foundry_local/README.md +++ b/python/packages/foundry_local/README.md @@ -1,6 +1,6 @@ # Get Started with Microsoft Agent Framework Foundry Local -Requires Python 3.11-3.14 and Foundry Local SDK 2.1 or newer. +Requires Python 3.11-3.14 and Foundry Local SDK 2.2.1 or newer, below 3.0. Install the integration: ```bash diff --git a/python/packages/foundry_local/pyproject.toml b/python/packages/foundry_local/pyproject.toml index e32b197a211..55905709146 100644 --- a/python/packages/foundry_local/pyproject.toml +++ b/python/packages/foundry_local/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ dependencies = [ "agent-framework-core>=1.13.0,<2", "agent-framework-openai>=1.6.0,<2", - "foundry-local-sdk>=2.1.0,<3", + "foundry-local-sdk>=2.2.1,<3", ] [tool.uv] diff --git a/python/uv.lock b/python/uv.lock index 8e61599af45..1a736320bfe 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -708,7 +708,7 @@ dependencies = [ requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, { name = "agent-framework-openai", editable = "packages/openai" }, - { name = "foundry-local-sdk", specifier = ">=2.1.0,<3" }, + { name = "foundry-local-sdk", specifier = ">=2.2.1,<3" }, ] [[package]] @@ -2202,7 +2202,7 @@ wheels = [ [[package]] name = "foundry-local-sdk" -version = "2.2.0" +version = "2.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, @@ -2214,11 +2214,11 @@ dependencies = [ { name = "typing-extensions" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/19/e1/d46db6cfbcd10f359283fcf9e1721f3d816b1a6efadb719c680c3ce9e1f5/foundry_local_sdk-2.2.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:4c2adf49d17b5f8af85a8c3441c1f89b2c500288208b4dfa1947a1ecce9b8770", size = 6394412 }, - { url = "https://files.pythonhosted.org/packages/11/9b/503670a54d23974034e311d50c29c1027df488e43df2891e0e82838c8420/foundry_local_sdk-2.2.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6c4b50820a0535989c90639ad5854c46753222fbf225c7491eabaa96a35eb872", size = 8993455 }, - { url = "https://files.pythonhosted.org/packages/14/6e/22690505066b3ac40ccbfe41400cb1d17bc36f0b4119062599dfdd263721/foundry_local_sdk-2.2.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9ff88a6b6ef6dc08ec43e9960c7cd7176f7f6d1af20e742a08e899974c9205e8", size = 8694204 }, - { url = "https://files.pythonhosted.org/packages/a7/e7/d91bf7590cd761b3b522ee6a20d894cae3703742c3fb20d40fd21df3d1aa/foundry_local_sdk-2.2.0-cp311-abi3-win_amd64.whl", hash = "sha256:a3e20fb1fbacc470a700f9c1462dd9b8db16a282c61ee450024074c764258124", size = 40810205 }, - { url = "https://files.pythonhosted.org/packages/0b/89/55c873bb8fcc4acb2933fe6ee9123870e61e020c9f20e921459f0486f1ce/foundry_local_sdk-2.2.0-cp311-abi3-win_arm64.whl", hash = "sha256:3af9de4ec792fef66800c19ae63cdc6e99c464c7e52c98c3a59ba09d3366e38c", size = 40575017 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, ] [[package]] From bdb80a311ba5d603dc23a83d956017b1cf08394c Mon Sep 17 00:00:00 2001 From: Bhagirath Mehta Date: Thu, 22 Oct 2026 18:02:33 -0500 Subject: [PATCH 17/17] Fix NumPy reference in merged Python lockfile Align ONNX Runtime's dependency with the NumPy 2.5.4 package already locked by main. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 581ec03f-0146-4693-8378-a80600bb504c --- python/uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/uv.lock b/python/uv.lock index 6a9e4cc0b3f..5e79ff6290f 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -3666,7 +3666,7 @@ 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.3", 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" }, ]