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
30 changes: 26 additions & 4 deletions amplifier_app_cli/commands/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -1223,8 +1223,32 @@ def provider_login(ctx: click.Context, provider_id: str) -> None:
Examples:
amplifier provider login openai-chatgpt
"""
module_id = _normalize_module_id(provider_id)
display = _display_name(module_id)
# Resolve an exact saved instance before treating the argument as a module
# name. Login can replace credentials, so a module-only match must never
# choose among several accounts by priority or list order.
settings = _get_settings()
providers = settings.get_provider_overrides()
matches = [
entry
for entry in providers
if entry.get("id")
and _normalize_id(entry["id"]) == _normalize_id(provider_id)
]
if not matches:
requested_module = _normalize_module_id(provider_id)
matches = [
entry for entry in providers
if _normalize_module_id(entry.get("module", "")) == requested_module
]
if len(matches) > 1:
raise click.ClickException(
"More than one configured provider matches this module. "
"Choose an exact instance ID from `amplifier provider list`."
)
entry = matches[0] if matches else None
module_id = _normalize_module_id(entry["module"] if entry else provider_id)
display = entry.get("id") if entry else None
display = display or _display_name(module_id)

if not is_provider_module_installed(module_id):
console.print(f"[red]Provider '{display}' is not installed.[/red]")
Expand Down Expand Up @@ -1255,8 +1279,6 @@ def provider_login(ctx: click.Context, provider_id: str) -> None:
# exists, so login uses the same connection values the wizard/runtime
# would -- empty dict (not an error) when the provider has never been
# configured yet.
settings = _get_settings()
entry = _find_provider_entry(settings.get_provider_overrides(), provider_id)
stored_config = entry.get("config", {}) if entry else {}

provider_instance = _try_instantiate_provider(provider_class, stored_config)
Expand Down
88 changes: 43 additions & 45 deletions amplifier_app_cli/provider_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@
import asyncio
import importlib
import importlib.metadata
import inspect
import logging
import os
import sys
from copy import deepcopy
from pathlib import Path
from typing import TYPE_CHECKING
from typing import Any
from typing import TYPE_CHECKING, Any

from .provider_diagnostics import invoke_list_models

Expand Down Expand Up @@ -70,9 +71,7 @@ def _load_provider_module_from_source_path(provider_id: str, source_path: Path)
return module


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

Tries entry points first, then direct import.
Expand Down Expand Up @@ -295,7 +294,7 @@ def _try_instantiate_provider(
provider_class: type,
collected_config: dict[str, Any] | None = None,
) -> Any | None:
"""Try to instantiate a provider class with various constructor signatures.
"""Instantiate a provider using its declared constructor signature.

Different providers have different constructor requirements:
- Standard: (api_key, config) - Anthropic, OpenAI
Expand All @@ -308,7 +307,8 @@ def _try_instantiate_provider(
collected_config: Optional config values collected from user (base_url, host, etc.)

Returns:
Provider instance or None if all attempts fail
Provider instance, or None when its signature cannot accept the config.
Provider constructor validation/runtime errors propagate unchanged.
"""
collected_config = collected_config or {}

Expand All @@ -324,49 +324,47 @@ def _try_instantiate_provider(
host = _resolve_env_placeholder(raw_host) or "http://localhost:11434"
api_key = _resolve_env_placeholder(raw_api_key) or ""

# Common exceptions to catch during instantiation attempts:
# - TypeError: wrong argument signature
# - ValueError: invalid argument values
# - RuntimeError: some providers raise this for missing dependencies (e.g., old azure-openai)
instantiation_errors = (TypeError, ValueError, RuntimeError)

# Approach 1: Standard (api_key, config) - Anthropic, OpenAI
try:
return provider_class(api_key=api_key, config={})
except instantiation_errors:
pass

# Approach 2: Azure-style (keyword-only base_url with api_key)
# Bind before construction: a TypeError *inside* a valid constructor is
# a provider failure, not permission to retry with different/default
# settings. In particular, an invalid account path or auth mode must
# never fall through to a no-argument constructor for another account.
try:
return provider_class(base_url=base_url, api_key=api_key, config={})
except instantiation_errors:
pass

# Approach 3: VLLM-style (base_url without api_key)
try:
return provider_class(base_url=base_url, config={})
except instantiation_errors:
pass
signature = inspect.signature(provider_class)
except (TypeError, ValueError):
logger.debug("Provider constructor signature is unavailable")
return None

# Approach 4: Ollama-style (host, config)
try:
return provider_class(host=host, config={})
except instantiation_errors:
pass
parameters = signature.parameters
accepts_kwargs = any(
parameter.kind == inspect.Parameter.VAR_KEYWORD
for parameter in parameters.values()
)
kwargs: dict[str, Any] = {}
if "config" in parameters or accepts_kwargs:
# Some constructors normalize nested config in place. Discovery and
# login must not mutate the caller's saved configuration or the
# values the wizard later persists.
kwargs["config"] = deepcopy(collected_config)
elif collected_config:
# Metadata-only, no-argument facades remain usable for get_info(),
# but cannot act as a configured provider for login/model discovery.
return None

# Approach 5: Just config
try:
return provider_class(config={})
except instantiation_errors:
pass
# Include optional connection arguments as well as required ones. Trying
# api_key+config first used to skip an optional host/base_url, silently
# selecting a local/default server even after the user chose another.
for name, value in (("api_key", api_key), ("base_url", base_url), ("host", host)):
if name in parameters:
kwargs[name] = value

# Approach 6: No args
try:
return provider_class()
except instantiation_errors:
pass

return None
signature.bind(**kwargs)
except TypeError:
# A required coordinator or unknown constructor argument cannot be
# supplied by lightweight discovery. Let the caller report that the
# provider cannot be instantiated without a mounted session.
return None
return provider_class(**kwargs)


def get_provider_info(
Expand Down
Loading
Loading