From 797f74efbc6f8692d7cb555f84c575618f02f398 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:12:50 -0700 Subject: [PATCH] feat(cli): sign in through the browser when a lite command gets a 401 without a key When a command in the keys, models, model-groups, users, teams, or credentials groups gets a 401 and no key was passed with --api-key or LITELLM_PROXY_API_KEY, lite now runs the PKCE browser sign-in, stores the credential, and runs the command again once with the new key. All sign-in output goes to stderr, so stdout keeps only the command's output. Before it opens a browser, lite checks whether the proxy accepts the rejected key on /v1/models (a 401 can be a role refusal for a valid key), tries a newer key another lite process stored, and refuses to replace a stored login that belongs to a different proxy. An explicit key that gets a 401 fails with a clean error and never opens a browser. --- litellm/proxy/client/cli/README.md | 14 + litellm/proxy/client/cli/commands/auth.py | 8 +- .../proxy/client/cli/commands/auto_sign_in.py | 181 ++++++++ .../proxy/client/cli/commands/credentials.py | 3 +- litellm/proxy/client/cli/commands/keys.py | 5 +- .../proxy/client/cli/commands/model_groups.py | 3 +- litellm/proxy/client/cli/commands/models.py | 11 +- litellm/proxy/client/cli/commands/teams.py | 10 +- litellm/proxy/client/cli/commands/users.py | 3 +- .../proxy/client/cli/test_auto_sign_in.py | 397 ++++++++++++++++++ 10 files changed, 623 insertions(+), 12 deletions(-) create mode 100644 litellm/proxy/client/cli/commands/auto_sign_in.py create mode 100644 tests/unit/proxy/client/cli/test_auto_sign_in.py diff --git a/litellm/proxy/client/cli/README.md b/litellm/proxy/client/cli/README.md index 842705ad5a7..e1ec65136e5 100644 --- a/litellm/proxy/client/cli/README.md +++ b/litellm/proxy/client/cli/README.md @@ -49,6 +49,20 @@ lite config unset hidden_commands # list everything again Hidden commands drop out of both `lite --help` and the interactive shell's "Available commands" block, and stay runnable so existing scripts keep working +## Automatic Browser Sign-In for Agents + +When a command that calls the proxy (`lite keys`, `lite models`, `lite model-groups`, `lite users`, `lite teams`, `lite credentials`) gets a 401 and you did not pass a key with `--api-key` or `LITELLM_PROXY_API_KEY`, `lite` signs you in through your browser and then runs the same command again with the new key. That covers a proxy you never logged in to and a stored login the proxy no longer accepts. It needs no terminal, so an AI agent that runs `lite` through its shell tool finishes the command on its own once you approve the sign-in in the browser: + +```bash +lite --base-url https://your-proxy.example.com keys generate --models gpt-5.4-mini --key-alias experiment-1 +``` + +The sign-in is the browser flow of `lite login --pkce`, so you pick your team on the consent page, the credential is stored where `lite login` stores it, and its refresh token renews the key on its own from then on. Every line about the sign-in goes to stderr, so stdout carries only the command's own output, such as the JSON from `lite keys generate`. If another `lite` process stored a newer login for the same proxy in the meantime, that key is tried first and no browser opens. `lite` keeps one stored login, so when that login is for a different proxy, the command fails and tells you to run `lite --base-url login` to switch, instead of replacing and revoking the other proxy's login on its own + +Nothing signs in ahead of time. A proxy that accepts the request without a key never opens a browser, and neither does `--help`. The proxy also answers 401 when a valid key lacks the role a command needs, such as a non-admin key running `lite credentials list`, so before it signs in, `lite` checks the rejected key against `/v1/models`. When the proxy answers that check with a success, the command fails with an error that says the key likely lacks the role, and no browser opens. A key you passed yourself is never swapped out, and a 401 for it fails with an error that names `--api-key` and `LITELLM_PROXY_API_KEY`. When the browser sign-in cannot finish (the machine has no web browser, the proxy does not serve `/.well-known/litellm-cli-auth`, you deny the request, or nobody approves it within 5 minutes), the command fails and tells you to run `lite login` from a terminal or set `LITELLM_PROXY_API_KEY` + +`lite chat`, `lite http request`, `lite keys import`, and `lite teams assign-key` do not sign in on their own. `lite chat` and `lite teams assign-key` are interactive, `lite keys import` also talks to a second proxy, and `lite http request` cannot tell a 401 from the proxy apart from a provider's 401 that the proxy passes through + ## Global Options - `--version`, `-v`: Print the LiteLLM Proxy client and server version and exit. diff --git a/litellm/proxy/client/cli/commands/auth.py b/litellm/proxy/client/cli/commands/auth.py index ad54cab6460..05c9300fd61 100644 --- a/litellm/proxy/client/cli/commands/auth.py +++ b/litellm/proxy/client/cli/commands/auth.py @@ -849,14 +849,14 @@ def _finish_login(base_url: str, api_key: str, config_claude: bool, stored: Secr show_commands() -def _replace_stored_token(record: CliTokenData, http: Http, vault: SecretVault) -> SecretSave: +def replace_stored_token(record: CliTokenData, http: Http, vault: SecretVault) -> SecretSave: previous: Final = load_token(vault=vault) stored: Final = save_token(record, vault=vault) if previous is None or isinstance(stored, CredentialNotSaved): return stored revocation: Final = revoke_stored_credential(previous, http) if revocation is not None: - click.echo( + _warn( f"Could not revoke the previous login's refresh token on the proxy ({revocation.reason}); " "it expires on its own." ) @@ -869,7 +869,7 @@ def _pkce_login(base_url: str, config_claude: bool, vault: SecretVault) -> None: if isinstance(credential, PkceFailure): click.echo(f"Authentication failed: {credential.reason}") return - stored: Final = _replace_stored_token(pkce_token_record(base_url, credential), http, vault) + stored: Final = replace_stored_token(pkce_token_record(base_url, credential), http, vault) _finish_login(base_url, credential.access_token, config_claude, stored) @@ -948,7 +948,7 @@ def login(ctx: click.Context, config_claude: bool, pkce: bool) -> None: # Save token data. base_url is stored so we can verify origin # before reusing the key on a subsequent CLI invocation. - stored: Final = _replace_stored_token( + stored: Final = replace_stored_token( { "base_url": base_url.rstrip("/"), "key": api_key, diff --git a/litellm/proxy/client/cli/commands/auto_sign_in.py b/litellm/proxy/client/cli/commands/auto_sign_in.py new file mode 100644 index 00000000000..fa4eb4b506e --- /dev/null +++ b/litellm/proxy/client/cli/commands/auto_sign_in.py @@ -0,0 +1,181 @@ +import webbrowser +from collections.abc import Callable +from dataclasses import dataclass +from typing import Final, Literal, Protocol + +import click +import requests +from typing_extensions import NotRequired, ReadOnly, TypedDict + +from litellm.litellm_core_utils.cli_token_utils import load_cli_token + +from ...exceptions import UnauthorizedError +from .auth import context_secret_vault, get_stored_api_key, replace_stored_token, storage_notice +from .pkce_login import PkceCredential, PkceFailure, pkce_token_record, run_pkce_login + + +class BrowserSignIn(Protocol): + def __call__(self, base_url: str, echo: Callable[[str], None]) -> PkceCredential | PkceFailure: ... + + +def pkce_browser_sign_in( + base_url: str, echo: Callable[[str], None], find_browser: Callable[[], object] = webbrowser.get +) -> PkceCredential | PkceFailure: + try: + _ = find_browser() + except webbrowser.Error: + return PkceFailure("no web browser is available on this machine") + return run_pkce_login(base_url, requests.Session(), echo=echo) + + +class _SignInContext(TypedDict): + base_url: ReadOnly[str] + api_key: ReadOnly[NotRequired[str | None]] + api_key_from_token_file: ReadOnly[NotRequired[bool]] + browser_sign_in: ReadOnly[NotRequired[BrowserSignIn]] + + +class _SignInContextView(Protocol): + @property + def obj(self) -> _SignInContext | None: ... + + +class _ApiKeySlot(Protocol): + def __setitem__(self, key: Literal["api_key"], value: str, /) -> None: ... + + +class _ApiKeySlotView(Protocol): + @property + def obj(self) -> _ApiKeySlot: ... + + +def _sign_in_context(view: _SignInContextView) -> _SignInContext | None: + return view.obj + + +def _api_key_slot(view: _ApiKeySlotView) -> _ApiKeySlot: + return view.obj + + +@dataclass(frozen=True, slots=True) +class _Completed: + value: object + + +@dataclass(frozen=True, slots=True) +class _Unauthorized: + error: UnauthorizedError + + +def _attempt(run: Callable[[click.Context], object], ctx: click.Context) -> _Completed | _Unauthorized: + try: + return _Completed(run(ctx)) + except UnauthorizedError as error: + return _Unauthorized(error) + + +def _attempt_with_key( + run: Callable[[click.Context], object], ctx: click.Context, api_key: str +) -> _Completed | _Unauthorized: + slot: Final = _api_key_slot(ctx) + slot["api_key"] = api_key + return _attempt(run, ctx) + + +def _echo_to_stderr(message: str) -> None: + click.echo(message, err=True) + + +def _attempt_with_newer_stored_key( + run: Callable[[click.Context], object], ctx: click.Context, base_url: str, rejected_key: str | None +) -> _Completed | _Unauthorized | None: + stored_key: Final = get_stored_api_key(expected_base_url=base_url, vault=context_secret_vault(ctx)) + if stored_key is None or stored_key == rejected_key: + return None + return _attempt_with_key(run, ctx, stored_key) + + +def _sign_in(ctx: click.Context, context: _SignInContext) -> str: + base_url: Final = context["base_url"] + _echo_to_stderr(f"The proxy at {base_url} needs a sign-in, so lite is opening your browser to sign in.") + sign_in: Final = context.get("browser_sign_in") or pkce_browser_sign_in + credential: Final = sign_in(base_url, _echo_to_stderr) + if isinstance(credential, PkceFailure): + raise click.ClickException( + f"Could not sign in to {base_url} through the browser: {credential.reason}. " + "Run `lite login` from a terminal, or set LITELLM_PROXY_API_KEY." + ) + stored: Final = replace_stored_token( + pkce_token_record(base_url, credential), requests.Session(), context_secret_vault(ctx) + ) + _echo_to_stderr(storage_notice(stored)) + _echo_to_stderr(f"Signed in. Continuing `{ctx.command_path}`.") + return credential.access_token + + +def _proxy_accepts(base_url: str, api_key: str) -> bool: + try: + response: Final = requests.get( + f"{base_url.rstrip('/')}/v1/models", headers={"Authorization": f"Bearer {api_key}"}, timeout=10 + ) + except requests.RequestException: + return False + return response.ok + + +def _login_for_another_proxy(ctx: click.Context, base_url: str) -> str | None: + login: Final = load_cli_token(vault=context_secret_vault(ctx)) + if login is None or not login.base_url or login.base_url == base_url.rstrip("/"): + return None + return login.base_url + + +def _rerun_after_sign_in(run: Callable[[click.Context], object], ctx: click.Context, context: _SignInContext) -> object: + base_url: Final = context["base_url"] + rejected_key: Final = context.get("api_key") + if rejected_key is not None and _proxy_accepts(base_url, rejected_key): + raise click.ClickException( + f"The proxy at {base_url} accepts this API key but refused `{ctx.command_path}` with HTTP 401, " + "so the key likely lacks the role this command needs." + ) + if not context.get("api_key_from_token_file", False): + raise click.ClickException( + f"The proxy at {base_url} rejected the API key from --api-key or LITELLM_PROXY_API_KEY. " + "Check that key, or unset both to sign in through your browser instead." + ) + retried: Final = _attempt_with_newer_stored_key(run, ctx, base_url, rejected_key) + if isinstance(retried, _Completed): + return retried.value + other_proxy: Final = _login_for_another_proxy(ctx, base_url) + if other_proxy is not None: + raise click.ClickException( + f"The proxy at {base_url} needs a sign-in, but lite keeps one stored login and it is for {other_proxy}, " + f"so lite did not replace it on its own. Run `lite --base-url {base_url} login` to switch proxies, " + "or set LITELLM_PROXY_API_KEY." + ) + match _attempt_with_key(run, ctx, _sign_in(ctx, context)): + case _Completed(value=value): + return value + case _Unauthorized(): + raise click.ClickException( + f"The proxy at {base_url} refused `{ctx.command_path}` with HTTP 401 again after the browser sign-in, " + "so the signed-in user likely lacks the role this command needs. " + "Set LITELLM_PROXY_API_KEY to a key that has it." + ) + + +class SignInOnUnauthorizedCommand(click.Command): + def invoke(self, ctx: click.Context) -> object: + run: Final = super().invoke + match _attempt(run, ctx): + case _Completed(value=value): + return value + case _Unauthorized(error=error): + context: Final = _sign_in_context(ctx) + if context is None: + raise error + return _rerun_after_sign_in(run, ctx, context) + + +class SignInOnUnauthorizedGroup(click.Group): + command_class = SignInOnUnauthorizedCommand diff --git a/litellm/proxy/client/cli/commands/credentials.py b/litellm/proxy/client/cli/commands/credentials.py index 2028bdb5599..a970f46909e 100644 --- a/litellm/proxy/client/cli/commands/credentials.py +++ b/litellm/proxy/client/cli/commands/credentials.py @@ -10,6 +10,7 @@ from typing_extensions import NotRequired, ReadOnly, TypedDict from ...credentials import CredentialsManagementClient from ._cli_context import cli_context_values +from .auto_sign_in import SignInOnUnauthorizedGroup class _CredentialInfo(TypedDict): @@ -39,7 +40,7 @@ def _print_json(data: object) -> None: rich.print_json(data=data) -@click.group() +@click.group(cls=SignInOnUnauthorizedGroup) def credentials(): """Manage credentials for the LiteLLM proxy server""" diff --git a/litellm/proxy/client/cli/commands/keys.py b/litellm/proxy/client/cli/commands/keys.py index 558c9139f5b..1033ec8f6d4 100644 --- a/litellm/proxy/client/cli/commands/keys.py +++ b/litellm/proxy/client/cli/commands/keys.py @@ -11,6 +11,7 @@ from rich.table import Table from typing_extensions import ReadOnly, TypedDict from ...keys import KeysManagementClient +from .auto_sign_in import SignInOnUnauthorizedGroup class _CliContext(TypedDict): @@ -37,7 +38,7 @@ def _cli_context(view: _CliContextView) -> _CliContext: return view.obj -@click.group() +@click.group(cls=SignInOnUnauthorizedGroup) def keys(): """Manage API keys for the LiteLLM proxy server""" @@ -352,7 +353,7 @@ def _import_keys_to_destination( return imported_count, failed_count -@keys.command(name="import") +@keys.command(name="import", cls=click.Command) @click.option( "--source-base-url", required=True, diff --git a/litellm/proxy/client/cli/commands/model_groups.py b/litellm/proxy/client/cli/commands/model_groups.py index 367c2063b6b..baa7c59ae3a 100644 --- a/litellm/proxy/client/cli/commands/model_groups.py +++ b/litellm/proxy/client/cli/commands/model_groups.py @@ -7,6 +7,7 @@ import rich.table from ... import Client from ._cli_context import cli_context_values +from .auto_sign_in import SignInOnUnauthorizedGroup def create_client(ctx: click.Context) -> Client: @@ -19,7 +20,7 @@ def _rendered_field(group: Mapping[str, object], key: str, default: str) -> str: return str(group.get(key, default)) -@click.group(name="model-groups") +@click.group(name="model-groups", cls=SignInOnUnauthorizedGroup) def model_groups() -> None: """Inspect model groups your key can access on the proxy""" diff --git a/litellm/proxy/client/cli/commands/models.py b/litellm/proxy/client/cli/commands/models.py index ac7d5ee45ea..63128c44f38 100644 --- a/litellm/proxy/client/cli/commands/models.py +++ b/litellm/proxy/client/cli/commands/models.py @@ -14,7 +14,9 @@ from typing_extensions import NotRequired, ReadOnly, TypedDict # local imports from ... import Client +from ...exceptions import UnauthorizedError from ._cli_context import cli_context_values +from .auto_sign_in import SignInOnUnauthorizedGroup if TYPE_CHECKING: from rich.console import JustifyMethod @@ -103,7 +105,7 @@ def create_client(ctx: click.Context) -> Client: return Client(base_url=context["base_url"], api_key=context["api_key"]) -@click.group() +@click.group(cls=SignInOnUnauthorizedGroup) def models() -> None: """Manage models on your LiteLLM proxy server""" @@ -449,6 +451,13 @@ def import_models( model_params=model_info_obj.model_params, model_info=model_info_obj.model_info, ) + except UnauthorizedError as error: + if added_models: + raise click.ClickException( + f"The proxy rejected the API key after {len(added_models)} of {len(filtered_model_list)} " + "models were sent, so the import stopped there." + ) from error + raise except Exception: pass # For summary, ignore errors added_models.append(model_info_obj) diff --git a/litellm/proxy/client/cli/commands/teams.py b/litellm/proxy/client/cli/commands/teams.py index 1a941786f19..2fcf4c638c8 100644 --- a/litellm/proxy/client/cli/commands/teams.py +++ b/litellm/proxy/client/cli/commands/teams.py @@ -10,8 +10,10 @@ from rich.table import Table from typing_extensions import ReadOnly, TypedDict from litellm.proxy.client import Client +from litellm.proxy.client.exceptions import UnauthorizedError from ._cli_context import cli_context_values +from .auto_sign_in import SignInOnUnauthorizedGroup class _TeamRow(TypedDict): @@ -29,7 +31,7 @@ class _ErrorBodyView(TypedDict): body: ReadOnly[Mapping[str, object]] -@click.group() +@click.group(cls=SignInOnUnauthorizedGroup) def teams(): """Manage teams and team assignments""" @@ -94,6 +96,8 @@ def list(ctx: click.Context): # Use list() for simpler response structure (returns array directly) teams: Final = client.teams.list() display_teams_table(teams) + except UnauthorizedError: + raise except requests.exceptions.HTTPError as e: click.echo(f"Error: HTTP {e.response.status_code}", err=True) error_body: Final[_ErrorBodyView] = {"body": e.response.json()} @@ -119,6 +123,8 @@ def available(ctx: click.Context): display_teams_table(teams) else: click.echo("No available teams to join.") + except UnauthorizedError: + raise except requests.exceptions.HTTPError as e: click.echo(f"Error: HTTP {e.response.status_code}", err=True) error_body: Final[_ErrorBodyView] = {"body": e.response.json()} @@ -128,7 +134,7 @@ def available(ctx: click.Context): raise click.Abort() -@teams.command() +@teams.command(cls=click.Command) @click.option("--team-id", type=str, help="Team ID to assign the key to") @click.pass_context def assign_key(ctx: click.Context, team_id: str | None): diff --git a/litellm/proxy/client/cli/commands/users.py b/litellm/proxy/client/cli/commands/users.py index a5ebefd1c4a..0d96f15a975 100644 --- a/litellm/proxy/client/cli/commands/users.py +++ b/litellm/proxy/client/cli/commands/users.py @@ -5,9 +5,10 @@ import rich from ... import UsersManagementClient from ._cli_context import cli_context_values +from .auto_sign_in import SignInOnUnauthorizedGroup -@click.group() +@click.group(cls=SignInOnUnauthorizedGroup) def users(): """Manage users on your LiteLLM proxy server""" diff --git a/tests/unit/proxy/client/cli/test_auto_sign_in.py b/tests/unit/proxy/client/cli/test_auto_sign_in.py new file mode 100644 index 00000000000..6312979d07c --- /dev/null +++ b/tests/unit/proxy/client/cli/test_auto_sign_in.py @@ -0,0 +1,397 @@ +import json +import time +import webbrowser +from collections.abc import Callable, Sequence +from pathlib import Path +from typing import Final + +import pytest +import requests +from click.testing import CliRunner, Result + +from litellm.proxy.client.cli import cli +from litellm.proxy.client.cli.commands.auth import CliTokenData, get_stored_api_key, save_token +from litellm.proxy.client.cli.commands.auto_sign_in import pkce_browser_sign_in +from litellm.proxy.client.cli.commands.pkce_login import PkceCredential, PkceFailure, pkce_token_record + +BASE_URL: Final = "https://proxy.example.com" +SIGNED_IN_KEY: Final = "sk-signed-in" +GENERATED: Final = {"key": "sk-generated", "key_alias": "experiment-1"} +REJECTED: Final = {"error": {"message": "Authentication Error, No api key passed in.", "type": "auth_error"}} + + +class FakeBrowserSignIn: + def __init__(self, outcome: PkceCredential | PkceFailure) -> None: + self.outcome = outcome + self.calls: list[str] = [] + + def __call__(self, base_url: str, echo: Callable[[str], None]) -> PkceCredential | PkceFailure: + self.calls.append(base_url) + echo(f"Opening browser to: {base_url}/v1/cli/authorize?state=fake") + return self.outcome + + +def _credential(access_token: str = SIGNED_IN_KEY) -> PkceCredential: + return PkceCredential( + access_token=access_token, + refresh_token="refresh-token", + expires_at=time.time() + 3600, + client_id="cli-client", + token_endpoint=f"{BASE_URL}/v1/cli/token", + revocation_endpoint=f"{BASE_URL}/v1/cli/revoke", + resource=BASE_URL, + user_id="agent-user", + team_id=None, + ) + + +def _classic_login(key: str) -> CliTokenData: + return { + "base_url": BASE_URL, + "key": key, + "user_id": "agent-user", + "user_email": "unknown", + "user_role": "cli", + "auth_header_name": "Authorization", + "jwt_token": "", + "timestamp": time.time(), + } + + +@pytest.fixture(autouse=True) +def no_key_in_the_environment(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("LITELLM_PROXY_API_KEY", raising=False) + monkeypatch.delenv("LITELLM_PROXY_URL", raising=False) + + +def _lite(args: Sequence[str], vault: object, sign_in: FakeBrowserSignIn, global_args: Sequence[str] = ()) -> Result: + return CliRunner().invoke( + cli, + [*global_args, "--base-url", BASE_URL, *args], + obj={"secret_vault": vault, "browser_sign_in": sign_in}, + ) + + +def _bearers(route) -> list[str | None]: + return [request.headers.get("Authorization") for request in route.request_history] + + +def _key_check(requests_mock, status_code: int): + return requests_mock.get(f"{BASE_URL}/v1/models", status_code=status_code, json={"data": []}) + + +def test_a_401_without_a_login_signs_in_once_stores_the_key_and_finishes_the_command( + requests_mock, secret_vault_factory +): + route = requests_mock.post( + f"{BASE_URL}/key/generate", [{"status_code": 401, "json": REJECTED}, {"status_code": 200, "json": GENERATED}] + ) + vault = secret_vault_factory() + sign_in = FakeBrowserSignIn(_credential()) + + result = _lite(["keys", "generate", "--models", "gpt-5.4-mini", "--key-alias", "experiment-1"], vault, sign_in) + + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == GENERATED + assert sign_in.calls == [BASE_URL] + assert _bearers(route) == [None, f"Bearer {SIGNED_IN_KEY}"] + assert get_stored_api_key(BASE_URL, vault=vault) == SIGNED_IN_KEY + assert f"The proxy at {BASE_URL} needs a sign-in" in result.stderr + assert f"Opening browser to: {BASE_URL}/v1/cli/authorize" in result.stderr + assert "Credential stored in your OS keychain." in result.stderr + assert "Continuing `cli keys generate`" in result.stderr + assert SIGNED_IN_KEY not in result.output + + +@pytest.mark.parametrize( + "key_check", + [{"status_code": 401}, {"status_code": 503}, {"exc": requests.exceptions.ConnectionError}], + ids=["rejected", "unavailable", "unreachable"], +) +def test_a_stored_login_the_proxy_rejects_is_replaced_by_one_sign_in_with_only_json_on_stdout( + requests_mock, secret_vault_factory, key_check +): + vault = secret_vault_factory() + save_token(pkce_token_record(BASE_URL, _credential("sk-stale")), vault=vault) + requests_mock.get(f"{BASE_URL}/v1/models", **key_check) + revoke = requests_mock.post(f"{BASE_URL}/v1/cli/revoke", status_code=500) + route = requests_mock.post( + f"{BASE_URL}/key/generate", [{"status_code": 401, "json": REJECTED}, {"status_code": 200, "json": GENERATED}] + ) + sign_in = FakeBrowserSignIn(_credential()) + + result = _lite(["keys", "generate"], vault, sign_in) + + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == GENERATED + assert sign_in.calls == [BASE_URL] + assert _bearers(route) == ["Bearer sk-stale", f"Bearer {SIGNED_IN_KEY}"] + assert revoke.call_count == 1 + assert "Could not revoke the previous login's refresh token" in result.stderr + assert get_stored_api_key(BASE_URL, vault=vault) == SIGNED_IN_KEY + + +def test_a_login_for_another_proxy_is_never_replaced_by_an_automatic_sign_in(requests_mock, secret_vault_factory): + other_proxy = "https://other.example.com" + vault = secret_vault_factory() + save_token({**_classic_login("sk-other"), "base_url": other_proxy}, vault=vault) + route = requests_mock.post(f"{BASE_URL}/key/generate", status_code=401, json=REJECTED) + sign_in = FakeBrowserSignIn(_credential()) + + result = _lite(["keys", "generate"], vault, sign_in) + + assert result.exit_code == 1 + assert isinstance(result.exception, SystemExit) + assert f"lite keeps one stored login and it is for {other_proxy}" in result.stderr + assert f"Run `lite --base-url {BASE_URL} login`" in result.stderr + assert sign_in.calls == [] + assert _bearers(route) == [None] + assert get_stored_api_key(other_proxy, vault=vault) == "sk-other" + + +def test_the_browser_sign_in_fails_at_once_on_a_machine_without_a_browser(requests_mock): + def no_browser() -> object: + raise webbrowser.Error("could not locate runnable browser") + + echoed: list[str] = [] + + outcome = pkce_browser_sign_in(BASE_URL, echoed.append, find_browser=no_browser) + + assert outcome == PkceFailure("no web browser is available on this machine") + assert requests_mock.call_count == 0 + assert echoed == [] + + +@pytest.mark.parametrize( + ("command", "method", "path", "body"), + [ + (["keys", "list", "--format", "json"], "GET", "/key/list", {"keys": []}), + (["models", "list", "--format", "json"], "GET", "/models", {"data": []}), + (["model-groups", "list", "--format", "json"], "GET", "/model_group/info", {"data": []}), + (["users", "list"], "GET", "/user/list", {"users": []}), + (["teams", "list"], "GET", "/team/list", []), + (["teams", "available"], "GET", "/team/available", []), + (["credentials", "list", "--format", "json"], "GET", "/credentials", {"credentials": []}), + ], +) +def test_every_proxy_command_group_signs_in_on_a_401_and_reruns_with_the_new_key( + requests_mock, secret_vault_factory, command, method, path, body +): + route = requests_mock.register_uri( + method, f"{BASE_URL}{path}", [{"status_code": 401, "json": REJECTED}, {"status_code": 200, "json": body}] + ) + sign_in = FakeBrowserSignIn(_credential()) + + result = _lite(command, secret_vault_factory(), sign_in) + + assert result.exit_code == 0, result.output + assert sign_in.calls == [BASE_URL] + assert _bearers(route) == [None, f"Bearer {SIGNED_IN_KEY}"] + + +def test_models_import_signs_in_instead_of_reporting_models_it_never_added( + requests_mock, secret_vault_factory, tmp_path: Path +): + models_file = tmp_path / "models.yaml" + models_file.write_text( + "model_list:\n - model_name: gpt-5.4-mini\n litellm_params:\n model: openai/gpt-5.4-mini\n" + ) + route = requests_mock.post( + f"{BASE_URL}/model/new", [{"status_code": 401, "json": REJECTED}, {"status_code": 200, "json": {}}] + ) + sign_in = FakeBrowserSignIn(_credential()) + + result = _lite(["models", "import", str(models_file)], secret_vault_factory(), sign_in) + + assert result.exit_code == 0, result.output + assert sign_in.calls == [BASE_URL] + assert _bearers(route) == [None, f"Bearer {SIGNED_IN_KEY}"] + + +def test_a_401_partway_through_models_import_stops_without_signing_in_or_adding_models_twice( + requests_mock, secret_vault_factory, tmp_path: Path +): + models_file = tmp_path / "models.yaml" + models_file.write_text( + "model_list:\n" + " - model_name: gpt-5.4-mini\n litellm_params:\n model: openai/gpt-5.4-mini\n" + " - model_name: gpt-5.4\n litellm_params:\n model: openai/gpt-5.4\n" + ) + route = requests_mock.post( + f"{BASE_URL}/model/new", [{"status_code": 200, "json": {}}, {"status_code": 401, "json": REJECTED}] + ) + sign_in = FakeBrowserSignIn(_credential()) + + result = _lite(["models", "import", str(models_file)], secret_vault_factory(), sign_in) + + assert result.exit_code == 1 + assert isinstance(result.exception, SystemExit) + assert "rejected the API key after 1 of 2 models were sent" in result.stderr + assert sign_in.calls == [] + assert [request.json()["model_name"] for request in route.request_history] == ["gpt-5.4-mini", "gpt-5.4"] + + +def test_a_401_from_the_keys_import_source_never_signs_in_to_the_destination(requests_mock, secret_vault_factory): + source = "https://source.example.com" + requests_mock.get(f"{source}/key/list", status_code=401, json=REJECTED) + sign_in = FakeBrowserSignIn(_credential()) + + result = _lite( + ["keys", "import", "--source-base-url", source, "--source-api-key", "sk-source"], + secret_vault_factory(), + sign_in, + ) + + assert result.exit_code != 0 + assert sign_in.calls == [] + assert {request.netloc for request in requests_mock.request_history} == {"source.example.com"} + + +@pytest.mark.parametrize( + ("global_args", "env"), + [(["--api-key", "sk-explicit"], {}), ([], {"LITELLM_PROXY_API_KEY": "sk-explicit"})], + ids=["flag", "env"], +) +def test_a_rejected_explicit_key_fails_cleanly_without_opening_a_browser( + requests_mock, secret_vault_factory, monkeypatch: pytest.MonkeyPatch, global_args, env +): + for name, value in env.items(): + monkeypatch.setenv(name, value) + route = requests_mock.post(f"{BASE_URL}/key/generate", status_code=401, json=REJECTED) + _key_check(requests_mock, 401) + vault = secret_vault_factory() + sign_in = FakeBrowserSignIn(_credential()) + + result = _lite(["keys", "generate"], vault, sign_in, global_args) + + assert result.exit_code == 1 + assert isinstance(result.exception, SystemExit) + assert "rejected the API key from --api-key or LITELLM_PROXY_API_KEY" in result.stderr + assert sign_in.calls == [] + assert _bearers(route) == ["Bearer sk-explicit"] + assert vault.writes == [] + + +def test_a_failed_sign_in_names_the_reason_and_points_at_lite_login(requests_mock, secret_vault_factory): + route = requests_mock.post(f"{BASE_URL}/key/generate", status_code=401, json=REJECTED) + vault = secret_vault_factory() + sign_in = FakeBrowserSignIn(PkceFailure("timed out waiting for the browser sign-in to finish")) + + result = _lite(["keys", "generate"], vault, sign_in) + + assert result.exit_code == 1 + assert isinstance(result.exception, SystemExit) + assert "timed out waiting for the browser sign-in to finish" in result.stderr + assert "Run `lite login` from a terminal, or set LITELLM_PROXY_API_KEY." in result.stderr + assert sign_in.calls == [BASE_URL] + assert route.call_count == 1 + assert get_stored_api_key(BASE_URL, vault=vault) is None + + +def test_a_signed_in_key_the_proxy_also_rejects_is_not_followed_by_another_sign_in(requests_mock, secret_vault_factory): + route = requests_mock.post(f"{BASE_URL}/key/generate", status_code=401, json=REJECTED) + sign_in = FakeBrowserSignIn(_credential()) + + result = _lite(["keys", "generate"], secret_vault_factory(), sign_in) + + assert result.exit_code == 1 + assert isinstance(result.exception, SystemExit) + assert "refused `cli keys generate` with HTTP 401 again after the browser sign-in" in result.stderr + assert sign_in.calls == [BASE_URL] + assert _bearers(route) == [None, f"Bearer {SIGNED_IN_KEY}"] + + +def _rejected_while_a_sibling_signs_in(vault): + def respond(request, context): + save_token(_classic_login("sk-sibling"), vault=vault) + context.status_code = 401 + return REJECTED + + return respond + + +def test_a_key_a_sibling_process_stored_meanwhile_is_used_before_any_browser(requests_mock, secret_vault_factory): + vault = secret_vault_factory() + save_token(_classic_login("sk-stale"), vault=vault) + _key_check(requests_mock, 401) + route = requests_mock.post( + f"{BASE_URL}/key/generate", + [{"json": _rejected_while_a_sibling_signs_in(vault)}, {"status_code": 200, "json": GENERATED}], + ) + sign_in = FakeBrowserSignIn(_credential()) + + result = _lite(["keys", "generate"], vault, sign_in) + + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == GENERATED + assert sign_in.calls == [] + assert _bearers(route) == ["Bearer sk-stale", "Bearer sk-sibling"] + + +def test_a_sibling_key_the_proxy_also_rejects_falls_through_to_one_sign_in(requests_mock, secret_vault_factory): + vault = secret_vault_factory() + save_token(_classic_login("sk-stale"), vault=vault) + _key_check(requests_mock, 401) + route = requests_mock.post( + f"{BASE_URL}/key/generate", + [ + {"json": _rejected_while_a_sibling_signs_in(vault)}, + {"status_code": 401, "json": REJECTED}, + {"status_code": 200, "json": GENERATED}, + ], + ) + sign_in = FakeBrowserSignIn(_credential()) + + result = _lite(["keys", "generate"], vault, sign_in) + + assert result.exit_code == 0, result.output + assert sign_in.calls == [BASE_URL] + assert _bearers(route) == ["Bearer sk-stale", "Bearer sk-sibling", f"Bearer {SIGNED_IN_KEY}"] + assert get_stored_api_key(BASE_URL, vault=vault) == SIGNED_IN_KEY + + +@pytest.mark.parametrize("explicit", [False, True], ids=["stored", "explicit"]) +def test_a_401_for_a_key_the_proxy_accepts_elsewhere_reports_a_missing_role_without_a_browser( + requests_mock, secret_vault_factory, explicit +): + vault = secret_vault_factory() + save_token(_classic_login("sk-member"), vault=vault) + route = requests_mock.get(f"{BASE_URL}/credentials", status_code=401, json=REJECTED) + key_check = _key_check(requests_mock, 200) + sign_in = FakeBrowserSignIn(_credential()) + writes_before = list(vault.writes) + + result = _lite(["credentials", "list"], vault, sign_in, ["--api-key", "sk-member"] if explicit else []) + + assert result.exit_code == 1 + assert isinstance(result.exception, SystemExit) + assert "accepts this API key but refused `cli credentials list` with HTTP 401" in result.stderr + assert sign_in.calls == [] + assert route.call_count == 1 + assert _bearers(key_check) == ["Bearer sk-member"] + assert vault.writes == writes_before + assert "sk-member" not in result.output + + +def test_help_never_signs_in(requests_mock, secret_vault_factory): + sign_in = FakeBrowserSignIn(_credential()) + + result = _lite(["keys", "generate", "--help"], secret_vault_factory(), sign_in) + + assert result.exit_code == 0 + assert "Generate a new API key" in result.stdout + assert sign_in.calls == [] + assert requests_mock.call_count == 0 + + +@pytest.mark.parametrize("status", [403, 500]) +def test_an_error_other_than_401_never_signs_in(requests_mock, secret_vault_factory, status): + route = requests_mock.post(f"{BASE_URL}/key/generate", status_code=status, json={"error": "refused"}) + sign_in = FakeBrowserSignIn(_credential()) + + result = _lite(["keys", "generate"], secret_vault_factory(), sign_in) + + assert result.exit_code == 1 + assert f"Error: HTTP {status}" in result.stderr + assert sign_in.calls == [] + assert route.call_count == 1