Skip to content
111 changes: 109 additions & 2 deletions amplifier_app_cli/commands/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,22 @@
_config_claimed_env_vars,
_normalize_id,
_preserve_reserved_keys,
_run_provider_login,
_secret_env_var_for,
_secret_field_id_for,
_suggest_instance_env_var,
configure_provider,
)
from ..provider_loader import _try_instantiate_provider
from ..provider_loader import get_provider_info
from ..provider_loader import get_provider_models
from ..provider_loader import load_provider_class
from ..provider_manager import ProviderManager
from ..provider_manager import resolve_provider_entry
from ..provider_sources import ensure_provider_installed
from ..provider_sources import get_effective_provider_sources
from ..provider_sources import install_known_providers
from ..provider_sources import is_provider_module_installed
from ..ui.item_renderer import ItemRenderer
from ..ui.scope import (
is_scope_change_available,
Expand Down Expand Up @@ -283,8 +288,7 @@ def _prompt_credential_binding(
f"model/settings [dim](default)[/dim]"
)
console.print(
" [2] Separate credential -- its own env var (different "
"account/org/key)"
" [2] Separate credential -- its own env var (different account/org/key)"
)
choice = Prompt.ask(" Choice", choices=["1", "2"], default="1")

Expand Down Expand Up @@ -1183,6 +1187,109 @@ def provider_models(ctx: click.Context, provider_id: str | None) -> None:
console.print(table)


# ============================================================
# provider login
# ============================================================


@provider.command("login")
@click.argument("provider_id")
@click.pass_context
def provider_login(ctx: click.Context, provider_id: str) -> None:
"""Log in to an OAuth-capable provider.

Runs the same browser-login flow the configuration wizard offers for
a provider that supports it (declares an "auth:*" capability in its
get_info()). Useful to (re)authenticate later, or if login was
skipped/declined during `provider add` or `provider edit`.

Examples:
amplifier provider login openai-chatgpt
"""
module_id = _normalize_module_id(provider_id)
display = _display_name(module_id)

if not is_provider_module_installed(module_id):
console.print(f"[red]Provider '{display}' is not installed.[/red]")
console.print(
f"\nInstall it first with: [cyan]amplifier provider install {display}[/cyan]"
)
ctx.exit(1)

info = get_provider_info(module_id)
if info is None:
console.print(f"[red]Error: Could not load provider '{display}'.[/red]")
ctx.exit(1)

capabilities = info.get("capabilities") or []
if not any(str(c).startswith("auth:") for c in capabilities):
console.print(
f"[yellow]'{display}' uses API-key configuration -- see "
f"`amplifier provider edit {display}`.[/yellow]"
)
return

provider_class = load_provider_class(module_id)
if provider_class is None:
console.print(f"[red]Error: Could not load provider '{display}'.[/red]")
ctx.exit(1)

# Instantiate with the saved config for this instance when one
# 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)
if provider_instance is None:
console.print(f"[red]Error: Could not instantiate provider '{display}'.[/red]")
ctx.exit(1)

if not (
hasattr(provider_instance, "auth_status")
and hasattr(provider_instance, "login")
):
console.print(
f"[yellow]'{display}' uses API-key configuration -- see "
f"`amplifier provider edit {display}`.[/yellow]"
)
return

try:
status = provider_instance.auth_status()
except Exception as e:
console.print(
f"[red]Could not check login status: {escape_markup(str(e))}[/red]"
)
ctx.exit(1)

console.print(f"Current status: [bold]{escape_markup(str(status))}[/bold]")
if status == "authenticated":
console.print(f"[green]✓ Already logged in to {display}[/green]")
return

success = _run_provider_login(provider_instance)

try:
final_status = provider_instance.auth_status()
except Exception:
final_status = "unauthenticated" if not success else status

if success:
console.print(
f"[green]✓ Logged in to {display} "
f"({escape_markup(str(final_status))})[/green]"
)
else:
console.print(
f"[red]✗ Login failed for {display} "
f"({escape_markup(str(final_status))})[/red]"
)
ctx.exit(1)


# ============================================================
# Task 1: provider manage — interactive dashboard
# ============================================================
Expand Down
69 changes: 66 additions & 3 deletions amplifier_app_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,66 @@ def _ensure_utf8_output() -> None:
pass # Not a real Windows console (e.g. some CI/test environments)


def _configure_console_logging() -> None:
"""Install a minimal root logging handler if the process has none.

amplifier-app-cli never calls logging.basicConfig() / logging.config
.dictConfig() / Logger.addHandler() anywhere in the codebase (verified).
With zero handlers on the root logger, any module's
``logger.warning(..., exc_info=True)`` or ``logger.exception(...)``
falls through to Python's ``logging.lastResort`` handler, which dumps
the bare message AND the full traceback straight to stderr with no
formatting control. That is exactly how the owner's raw
AuthenticationError traceback leaked to the terminal during
`provider add openai-chatgpt`'s model fetch -- and it is not specific
to that one provider or that one call site; it is a structural gap
that leaks a traceback from *any* module that logs an exception.

Installs a single stderr ``StreamHandler`` at ``WARNING`` with a plain
``"%(message)s"`` formatter (no timestamp/logger-name noise -- this is
a CLI, not a service) and, unless verbose/debug output was requested,
a filter that clears ``exc_info``/``exc_text`` on each record so a
logged exception still prints its message but never dumps a raw
traceback into the user's terminal. Verbose/debug mode leaves
tracebacks intact for debugging.

Must be called from main() before ``_attach_llm_error_filter()``: that
function's primary path (attach to an existing stderr StreamHandler)
only works once a real handler exists -- see its own docstring.
Without this, it fell back to filtering at the root *logger* level,
which is inert for any record emitted by a named child logger (the
normal case): ``Logger.callHandlers()`` walks handlers up the
hierarchy and checks each handler's own filters, but a logger-level
filter is only consulted on the logger that originated the record.

A no-op if the root logger already has a handler (e.g. under pytest,
or if some future code path configures logging itself) -- this never
overrides an existing setup.
"""
root = logging.getLogger()
if root.handlers:
return

verbose_requested = any(
arg in ("--verbose", "-v", "--debug") for arg in sys.argv[1:]
)

handler = logging.StreamHandler(sys.stderr)
handler.setLevel(logging.WARNING)
handler.setFormatter(logging.Formatter("%(message)s"))

if not verbose_requested:

def _suppress_traceback(record: logging.LogRecord) -> bool:
record.exc_info = None
record.exc_text = None
return True

handler.addFilter(_suppress_traceback)

root.addHandler(handler)


def _attach_llm_error_filter() -> None:
"""Attach the LLM error filter to the stderr StreamHandler at runtime.

Expand Down Expand Up @@ -3538,10 +3598,12 @@ async def interactive_chat(
# tests miss it because they mock _create_prompt_session.
try:
prompt_session = _create_prompt_session(
get_active_mode=lambda: command_processor.session.coordinator.session_state.get(
"active_mode"
get_active_mode=lambda: (
command_processor.session.coordinator.session_state.get("active_mode")
),
get_pinned_provider=lambda: _pinned_provider_name(
command_processor.session
),
get_pinned_provider=lambda: _pinned_provider_name(command_processor.session),
)
except _TERMINAL_UNUSABLE_ERRORS as e:
_report_terminal_unusable(e, verbose=verbose)
Expand Down Expand Up @@ -4529,6 +4591,7 @@ def _goal_sigint_handler(signum, frame):
def main():
"""Main entry point."""
_ensure_utf8_output()
_configure_console_logging()
_attach_llm_error_filter()
cli()

Expand Down
Loading
Loading