Skip to content
Merged
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
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,17 @@ the first install and the entire file is identical after a repeat install.

## Lightweight provider configuration

Resolve the effective runtime source before configuration-time provider imports.
Installed-package checks and picker inventories must not execute provider packages:
a wheel's cached children cannot be mixed with the selected source at startup.
Activate provider dependencies before their dependents; preserve installed-only
unknown providers and explicit activated paths. Selected-source/origin failures
must not fall back to a wheel, evict imports, or trigger an install to hide the
conflict. Run `tests/test_provider_source_origin.py` and
`tests/test_provider_source_precedence.py`; verify first-run startup with a
preinstalled provider wheel in an isolated environment, without weakening Core's
origin checks.

Model discovery and login must expand an independent full configuration copy
once with `lib.env_vars.expand_env_vars`, before deriving constructor arguments.
Do not discard saved account settings or expand returned environment values a
Expand Down
65 changes: 61 additions & 4 deletions amplifier_app_cli/provider_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
logger = logging.getLogger(__name__)


class ProviderSourceError(ImportError):
"""The selected provider source cannot be used without mixing origins."""


def _get_provider_module_name(provider_id: str) -> str:
"""Convert provider ID to Python module name.

Expand All @@ -50,7 +54,7 @@ def _load_provider_module_from_source_path(provider_id: str, source_path: Path)
module_name = _get_provider_module_name(provider_id)
expected_file = (source_path / module_name / "__init__.py").resolve()
if not expected_file.is_file():
raise ImportError(
raise ProviderSourceError(
f"Configured source for provider '{provider_id}' does not contain "
f"expected package '{module_name}'"
)
Expand All @@ -59,23 +63,63 @@ def _load_provider_module_from_source_path(provider_id: str, source_path: Path)
if cached_module is not None:
cached_file = getattr(cached_module, "__file__", None)
if cached_file is None or Path(cached_file).resolve() != expected_file:
raise ImportError(
raise ProviderSourceError(
f"Provider '{provider_id}' is already loaded from a different source"
)

# A prior failed import can leave children behind without the parent.
# Never evict them or combine them with another copy of the package.
expected_package = expected_file.parent
for name, child in tuple(sys.modules.items()):
if name.startswith(module_name + ".") and child is not None:
child_file = getattr(child, "__file__", None)
if child_file is not None and not Path(child_file).resolve().is_relative_to(
expected_package
):
raise ProviderSourceError(
f"Provider '{provider_id}' has a submodule loaded from a different source"
)

module = importlib.import_module(module_name)
loaded_file = getattr(module, "__file__", None)
if loaded_file is None or Path(loaded_file).resolve() != expected_file:
raise ImportError(
raise ProviderSourceError(
f"Provider '{provider_id}' loaded from a different source than configured"
)
return module


def _load_selected_provider_module(
module_id: str, sources: dict[str, str]
) -> Any:
"""Resolve the runtime source before the first configuration-time import."""
from .provider_sources import PROVIDER_DEPENDENCIES, source_from_uri

# A provider can import another provider at module scope (Azure/OpenAI).
# Resolve that dependency first, not whichever wheel happens to be installed.
for dependency in PROVIDER_DEPENDENCIES.get(module_id, []):
if dependency in sources:
_load_selected_provider_module(dependency, sources)

try:
source_path = source_from_uri(sources[module_id]).resolve()
except Exception as exc:
raise ProviderSourceError(
f"Could not resolve the selected source for provider '{module_id}'"
) from exc

path = str(source_path)
if path not in sys.path:
sys.path.insert(0, path)
importlib.invalidate_caches()
return _load_provider_module_from_source_path(module_id, source_path)


def _load_provider_module(provider_id: str, *, source_path: Path | None = None) -> Any:
"""Load a provider module.

Tries entry points first, then direct import.
Uses the selected runtime source before entry points or direct import.
Installed-only providers remain discoverable when no source is configured.

Args:
provider_id: Provider ID (e.g., "provider-anthropic")
Expand All @@ -96,6 +140,13 @@ def _load_provider_module(provider_id: str, *, source_path: Path | None = None)
else f"provider-{provider_id}"
)

from .lib.settings import AppSettings
from .provider_sources import get_effective_provider_sources

sources = get_effective_provider_sources(AppSettings())
if module_id in sources:
return _load_selected_provider_module(module_id, sources)

# Try entry point first
try:
eps = importlib.metadata.entry_points(group="amplifier.modules")
Expand All @@ -111,6 +162,8 @@ def _load_provider_module(provider_id: str, *, source_path: Path | None = None)
module_name = _get_provider_module_name(provider_id)
try:
return importlib.import_module(module_name)
except ProviderSourceError:
raise
except ImportError as e:
raise ImportError(f"Could not load provider module '{provider_id}': {e}") from e

Expand Down Expand Up @@ -168,6 +221,8 @@ def load_provider_class(
logger.warning(f"No provider class found in module for '{provider_id}'")
return None

except ProviderSourceError:
raise
except ImportError as e:
if source_path is not None:
raise
Expand Down Expand Up @@ -412,6 +467,8 @@ def get_provider_info(
info = provider.get_info()
return info.model_dump() if hasattr(info, "model_dump") else vars(info)

except ProviderSourceError:
raise
except Exception as e:
if source_path is not None:
raise
Expand Down
64 changes: 32 additions & 32 deletions amplifier_app_cli/provider_manager.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Provider configuration management."""

import importlib.metadata
import logging
import subprocess
import sys
Expand All @@ -9,6 +10,7 @@
from .lib.home_env import uv_target_args
from .lib.settings import AppSettings
from .lib.settings import ScopeType
from .provider_loader import ProviderSourceError
from .provider_loader import get_provider_info
from .provider_sources import _get_ordered_providers
from .provider_sources import ensure_provider_installed
Expand Down Expand Up @@ -286,33 +288,20 @@ def list_providers(self) -> list[tuple[str, str, str]]:
Returns:
List of (module_id, display_name, description) tuples
"""
import asyncio

from amplifier_core.loader import ModuleLoader

providers: dict[str, tuple[str, str, str]] = {}

# Discover installed providers via entry points
loader = ModuleLoader()
modules = asyncio.run(loader.discover())

for module in modules:
if module.type == "provider":
# Try to get proper display_name from provider's get_info()
info = get_provider_info(module.id)
if info and "display_name" in info:
display_name = info["display_name"]
else:
# Fallback to module's name from entry point
display_name = module.name
providers[module.id] = (module.id, display_name, module.description)

# Always merge source-discovered providers (handles local file paths,
# user-added providers, and modules installed with --target flag)
source_providers = self._discover_providers_from_sources()
for module_id, provider_info in source_providers.items():
if module_id not in providers:
providers[module_id] = provider_info
# Select sources before any provider import. Full module discovery can
# execute installed packages and cache their children from another root.
providers = self._discover_providers_from_sources()
selected_sources = get_effective_provider_sources(self.config)
for ep in importlib.metadata.entry_points(group="amplifier.modules"):
module_id = ep.name
if not module_id.startswith("provider-") or module_id in selected_sources:
continue
info = get_provider_info(module_id)
providers[module_id] = (
module_id,
(info or {}).get("display_name", _get_provider_display_name(module_id)),
(info or {}).get("description", f"Provider: {module_id}"),
)

# Sort alphabetically by display name (second element of tuple)
return sorted(providers.values(), key=lambda p: p[1].lower())
Expand Down Expand Up @@ -346,8 +335,13 @@ def _discover_providers_from_sources(self) -> dict[str, tuple[str, str, str]]:
for module_id, source_uri in ordered_providers:
try:
# Resolve source to path (handles both git URLs and local paths)
source = source_from_uri(source_uri)
module_path = source.resolve()
try:
source = source_from_uri(source_uri)
module_path = source.resolve()
except Exception as exc:
raise ProviderSourceError(
f"Could not resolve the selected source for provider '{module_id}'"
) from exc

# Add to sys.path if not already there
path_str = str(module_path)
Expand All @@ -359,7 +353,7 @@ def _discover_providers_from_sources(self) -> dict[str, tuple[str, str, str]]:
importlib.invalidate_caches()

# Try to get provider info via direct import
info = get_provider_info(module_id)
info = get_provider_info(module_id, source_path=module_path)
if info:
display_name = info.get(
"display_name", _get_provider_display_name(module_id)
Expand All @@ -384,6 +378,10 @@ def _discover_providers_from_sources(self) -> dict[str, tuple[str, str, str]]:
f"Discovered provider from source (no info): {module_id}"
)

except ProviderSourceError:
# An origin conflict is not a missing SDK. Do not overwrite an
# installed provider or fall back to a different copy to hide it.
raise
except Exception as e:
# For local sources, try to install with deps (same as install_known_providers)
if is_local_path(source_uri):
Expand Down Expand Up @@ -420,7 +418,7 @@ def _discover_providers_from_sources(self) -> dict[str, tuple[str, str, str]]:
sys.path.insert(0, path_str)

# Retry getting provider info
info = get_provider_info(module_id)
info = get_provider_info(module_id, source_path=module_path)
if info:
display_name = info.get(
"display_name", _get_provider_display_name(module_id)
Expand Down Expand Up @@ -449,6 +447,8 @@ def _discover_providers_from_sources(self) -> dict[str, tuple[str, str, str]]:
f"Discovered local provider after install (no info): {module_id}"
)

except ProviderSourceError:
raise
except Exception as install_error:
# Install failed - show fallback with consistent description
logger.warning(
Expand Down Expand Up @@ -477,7 +477,7 @@ def _discover_providers_from_sources(self) -> dict[str, tuple[str, str, str]]:
if installed:
# Refresh Python's view of newly installed packages
importlib.invalidate_caches()
info = get_provider_info(module_id)
info = get_provider_info(module_id, source_path=module_path)
if info:
display_name = info.get(
"display_name", _get_provider_display_name(module_id)
Expand Down
21 changes: 11 additions & 10 deletions amplifier_app_cli/provider_sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,11 @@ def get_effective_provider_sources(
if config_manager:
# 1. Apply source overrides for known providers (sources.modules)
overrides = config_manager.get_module_sources()
for module_id in list(sources.keys()):
if module_id in overrides:
sources[module_id] = overrides[module_id]
for module_id, source in overrides.items():
if module_id.startswith("provider-") or module_id in sources:
sources[module_id] = source
logger.debug(
f"Using override source for {module_id}: {overrides[module_id]}"
f"Using override source for {module_id}: {source}"
)

# 2. Add user-added provider modules from settings
Expand Down Expand Up @@ -172,7 +172,7 @@ def get_effective_provider_sources(


def is_provider_module_installed(provider_id: str) -> bool:
"""Check whether a provider module is installed and importable.
"""Check whether a provider package is discoverable without importing it.

Args:
provider_id: Provider module ID (e.g., "provider-anthropic"). A bare
Expand Down Expand Up @@ -208,20 +208,21 @@ def is_provider_module_installed(provider_id: str) -> bool:
for ep in eps:
if ep.name == module_id:
try:
return importlib.util.find_spec(ep.module) is not None
# find_spec on a dotted name imports its parent. That can
# cache wheel submodules before the runtime source is chosen.
return importlib.util.find_spec(ep.module.split(".", 1)[0]) is not None
except (ImportError, AttributeError, ValueError):
# Parent package missing/broken -> treat as not installed.
return False
except Exception:
pass

# Fall back to direct import check when no entry point is registered.
# The conventional top-level package check is also import-free.
try:
provider_name = module_id.replace("provider-", "")
module_name = f"amplifier_module_provider_{provider_name.replace('-', '_')}"
importlib.import_module(module_name)
return True
except ImportError:
return importlib.util.find_spec(module_name) is not None
except (ImportError, AttributeError, ValueError):
return False


Expand Down
Loading
Loading