Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/python-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
os: ${{ runner.os }}
# RedisVL still declares Python <3.15.
exclude-packages: ${{ matrix.python-version == '3.10' && 'agent-framework-github-copilot agent-framework-azure-cosmos-memory' || matrix.python-version == '3.15' && 'agent-framework-redis' || '' }}
# RedisVL still declares Python <3.15, and foundry-local-sdk 2.x supports 3.11-3.14.
exclude-packages: ${{ matrix.python-version == '3.10' && 'agent-framework-github-copilot agent-framework-azure-cosmos-memory agent-framework-foundry-local' || matrix.python-version == '3.15' && 'agent-framework-redis agent-framework-foundry-local' || '' }}
env:
# Configure a constant location for the uv cache
UV_CACHE_DIR: /tmp/.uv-cache
Expand Down
2 changes: 1 addition & 1 deletion python/packages/core/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ all = [
"agent-framework-durabletask>=1.0.0b260922,<2",
"agent-framework-foundry>=1.15.0,<2",
"agent-framework-foundry-hosting>=1.0.0b261022,<2",
"agent-framework-foundry-local>=1.0.0b261015,<2",
"agent-framework-foundry-local>=1.0.0b261015,<2; python_version >= '3.11' and python_version < '3.15'",
"agent-framework-gemini>=1.0.0b261022,<2",
"agent-framework-github-copilot>=2.1.0,<3; python_version >= '3.11'",
"agent-framework-hyperlight>=1.0.0b261022,<2; (sys_platform == 'linux' and platform_machine == 'x86_64') or (sys_platform == 'win32' and platform_machine == 'AMD64')",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import sys
import threading
from collections.abc import Awaitable, Callable, Mapping, Sequence
from typing import Any, ClassVar, Generic, Literal, cast, overload

Expand All @@ -22,8 +23,7 @@
from agent_framework._settings import load_settings
from agent_framework.observability import ChatTelemetryLayer
from agent_framework_openai._chat_completion_client import RawOpenAIChatCompletionClient
from foundry_local import FoundryLocalManager
from foundry_local.models import DeviceType, FoundryModelInfo
from foundry_local_sdk import Configuration, DeviceType, FoundryLocalManager, IModel
from openai import AsyncOpenAI
from pydantic import BaseModel

Expand All @@ -47,6 +47,82 @@

ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None)

# The v2 SDK identifies the host application in native logs and telemetry.
_APP_NAME = "agent-framework"
# The built-in web service performs no authentication; the OpenAI SDK still requires a value.
_WEB_SERVICE_API_KEY = "none"

# Guards first-use initialization: without it, two threads can both observe an
# uninitialized singleton and the SDK rejects the second `initialize()` call.
_MANAGER_LOCK = threading.Lock()


def _resolve_foundry_local_manager() -> FoundryLocalManager:
"""Return the process-wide Foundry Local manager, initializing it on first use.

The v2 SDK owns a single native manager per process and raises when a second one
is constructed, so every client shares one manager, catalog, and web service.
"""
manager = FoundryLocalManager.instance
if manager is not None:
return manager
with _MANAGER_LOCK:
# Re-check under the lock: another thread may have initialized while we waited.
manager = FoundryLocalManager.instance
if manager is None:
FoundryLocalManager.initialize(Configuration(app_name=_APP_NAME))
manager = FoundryLocalManager.instance
if manager is None: # pragma: no cover - initialize() sets the singleton
raise RuntimeError("Foundry Local manager could not be initialized.")
return manager


def _resolve_model(
manager: FoundryLocalManager,
model_setting: str,
*,
device: DeviceType | None = None,
) -> IModel:
"""Resolve a model alias (or a full model ID) to a catalog model.

Args:
manager: The Foundry Local manager owning the catalog.
model_setting: A model alias such as ``phi-4-mini``, or a full model ID.
device: When set, select the variant optimized for that device.

Returns:
The catalog model, with the requested variant selected when ``device`` is set.

Raises:
ValueError: If no model matches, or no variant matches ``device``.
"""
model = manager.catalog.get_model(model_setting)
if model is None:
# An alias lookup misses for full model IDs; the variant lookup covers those.
model = manager.catalog.get_model_variant(model_setting)
not_found = (
f"Model with ID or alias '{model_setting}:{device.value}' not found in Foundry Local."
if device
else (f"Model with ID or alias '{model_setting}' for your current device not found in Foundry Local.")
)
if model is None:
raise ValueError(not_found)
if device is not None:
variant = next(
(
candidate
for candidate in model.variants
if candidate.info is not None
and candidate.info.runtime is not None
and candidate.info.runtime.device_type == device
),
None,
)
if variant is None:
raise ValueError(not_found)
model.select_variant(variant)
return model


# region Foundry Local Chat Options TypedDict

Expand Down Expand Up @@ -234,8 +310,12 @@ def __init__(
Keyword Args:
model: The Foundry Local model ID or alias to use. If not provided,
it will be loaded from the FoundryLocalSettings.
bootstrap: Whether to start the Foundry Local service if not already running.
Default is True.
bootstrap: Whether to start the Foundry Local web service if it is not already
running. Default is True. The client talks to Foundry Local over the
web service's OpenAI-compatible endpoint, so it needs to be running.
Pass False only when something else has already started it.
This also downloads and registers the execution providers that
accelerated (GPU/NPU) model variants need, unless `device` is CPU.
timeout: Optional timeout for requests to Foundry Local.
This timeout is applied to any call to the Foundry Local service.
prepare_model: Whether to download the model into the cache, and load the model into
Expand All @@ -245,7 +325,7 @@ def __init__(
device: The device type to use for model inference.
The device is used to select the appropriate model variant.
If not provided, the default device for your system will be used.
The values are in the foundry_local.models.DeviceType enum.
The values are in the foundry_local_sdk.DeviceType enum.
additional_properties: Additional properties stored on the client instance.
middleware: Optional sequence of ChatAndFunctionMiddlewareTypes to apply to requests.
function_invocation_configuration: Optional configuration for function invocation support.
Expand All @@ -272,14 +352,15 @@ def __init__(
os.environ["FOUNDRY_LOCAL_MODEL"] = "phi-4-mini"
client = FoundryLocalClient()

# A FoundryLocalManager is created and if set, the service is started.
# A process-wide FoundryLocalManager is created on first use and, when
# bootstrap is set, the web service is started.
# The FoundryLocalManager is available via the `manager` property.
# For instance to find out which models are available:
for model in client.manager.list_catalog_models():
print(f"- {model.alias} for {model.task} - id={model.id}")
for model in client.manager.catalog.list_models():
print(f"- {model.alias} - id={model.id}")

# Other options include specifying the device type:
from foundry_local.models import DeviceType
from foundry_local_sdk import DeviceType

client = FoundryLocalClient(
model="phi-4-mini",
Expand All @@ -292,10 +373,10 @@ def __init__(
)
# Beware, in this case the first request to generate a completion
# will take a long time as the model is loaded then.
# Alternatively, you could call the `download_model` and `load_model` methods
# on the `manager` property manually.
client.manager.download_model("phi-4-mini", device=DeviceType.CPU)
client.manager.load_model("phi-4-mini", device=DeviceType.CPU)
# Alternatively, you could download and load the model yourself.
model = client.manager.catalog.get_model("phi-4-mini")
model.download()
model.load()

# You can also use the CLI:
`foundry model load phi-4-mini --device Auto`
Expand Down Expand Up @@ -326,25 +407,32 @@ class MyOptions(FoundryLocalChatOptions, total=False):
)
model_setting: str = settings["model"] # type: ignore[assignment]

manager = FoundryLocalManager(bootstrap=bootstrap, timeout=timeout)
model_info: FoundryModelInfo | None = manager.get_model_info(
model_setting,
device=device,
)
if model_info is None:
message = (
f"Model with ID or alias '{model_setting}:{device.value}' not found in Foundry Local."
if device
else (f"Model with ID or alias '{model_setting}' for your current device not found in Foundry Local.")
manager = _resolve_foundry_local_manager()
if bootstrap and device is not DeviceType.CPU:
# A variant is only listed in the catalog once its execution provider has been
# downloaded and registered, so on a fresh installation asking for GPU or NPU
# (or letting the SDK auto-select) would otherwise report "not found" or
# silently fall back to CPU. This is a no-op when the providers are registered.
manager.download_and_register_eps()
# The v2 SDK runs inference in-process; the optional built-in web service is what
# exposes the OpenAI-compatible REST endpoint this client talks to.
if bootstrap and not manager.urls:
manager.start_web_service()
if not manager.urls:
raise ValueError(
"The Foundry Local web service is not running, so there is no OpenAI-compatible endpoint to call. "
"Pass bootstrap=True (the default) to start it, or start it on the manager first."
)
raise ValueError(message)
base_url = f"{manager.urls[0]}/v1"

model_info = _resolve_model(manager, model_setting, device=device)

@lisry-1 lisry-1 Oct 26, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. bootstrap now calls download_and_register_eps() before the catalog is read, so accelerated variants are registered and therefore discoverable instead of reporting "not found" or silently falling back to CPU.

I scoped it to skip when device is explicitly DeviceType.CPU, so those callers do not download providers they will not use. Two tests cover it: one asserts the call happens for a GPU variant, one asserts it does not happen for CPU while the CPU variant is still selected.

if prepare_model:
manager.download_model(model_info.id, device=device)
manager.load_model(model_info.id, device=device)
model_info.download()
model_info.load()

super().__init__(
model=model_info.id,
async_client=AsyncOpenAI(base_url=manager.endpoint, api_key=manager.api_key),
async_client=AsyncOpenAI(base_url=base_url, api_key=_WEB_SERVICE_API_KEY, timeout=timeout),
additional_properties=additional_properties,
middleware=middleware,
function_invocation_configuration=function_invocation_configuration,
Expand Down
9 changes: 4 additions & 5 deletions python/packages/foundry_local/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ name = "agent-framework-foundry-local"
description = "Foundry Local integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
# The Foundry Local SDK 2.x ships for Python 3.11-3.14 only.
requires-python = ">=3.11"
version = "1.0.0b261015"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
Expand All @@ -15,18 +16,16 @@ classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: 3.15",
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.13.0,<2",
"agent-framework-openai>=1.6.0,<2",
"foundry-local-sdk>=0.5.1,<0.5.2",
"foundry-local-sdk>=2.0.0,<3",

@lisry-1 lisry-1 Oct 26, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed and aligned in all four places. requires-python is now >=3.11 and the 3.10 and 3.15 classifiers are gone, matching the SDK's own Requires-Python: >=3.11,<3.15; the mypy target moved to 3.11; core's all extra now carries the marker python_version >= '3.11' and python_version < '3.15'; and the test matrix excludes the integration on both 3.10 and 3.15.

One correction on the lockfile: uv.lock already declared requires-python = ">=3.11, <3.15" upstream, before this PR, so regenerating it is a no-op and uv lock --check passes as-is. I did not force a regeneration because that would churn unrelated pinned versions.

]

[tool.uv]
Expand Down Expand Up @@ -67,7 +66,7 @@ exclude = ['tests']
[tool.mypy]
plugins = ['pydantic.mypy']
strict = true
python_version = "3.10"
python_version = "3.11"
ignore_missing_imports = true
disallow_untyped_defs = true
no_implicit_optional = true
Expand Down
24 changes: 17 additions & 7 deletions python/packages/foundry_local/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,24 @@ def foundry_local_unit_test_env(monkeypatch: Any, exclude_list: list[str], overr


@fixture
def mock_foundry_local_manager() -> MagicMock:
def mock_foundry_local_model() -> MagicMock:
"""Fixture that provides a mock catalog model."""
mock_model = MagicMock()
mock_model.id = "test-model-id"
mock_model.alias = "test-model"
mock_model.variants = []

return mock_model


@fixture
def mock_foundry_local_manager(mock_foundry_local_model: MagicMock) -> MagicMock:
"""Fixture that provides a mock FoundryLocalManager."""
mock_manager = MagicMock()
mock_manager.endpoint = "http://localhost:5272/v1"
mock_manager.api_key = "test-api-key"

mock_model_info = MagicMock()
mock_model_info.id = "test-model-id"
mock_manager.get_model_info.return_value = mock_model_info
# ``urls`` is populated by ``start_web_service()``; model a service that is already up.
mock_manager.urls = ["http://localhost:5272"]
mock_manager.catalog.get_model.return_value = mock_foundry_local_model
# An alias lookup is the primary path; leave the model-ID fallback empty by default.
mock_manager.catalog.get_model_variant.return_value = None

return mock_manager
Loading
Loading