diff --git a/litellm/proxy/client/cli/README.md b/litellm/proxy/client/cli/README.md index ff5343c0640..54e3ca5470c 100644 --- a/litellm/proxy/client/cli/README.md +++ b/litellm/proxy/client/cli/README.md @@ -59,7 +59,7 @@ lite --base-url https://your-proxy.example.com keys generate --models gpt-5.4-mi 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 or only a text-mode one such as `w3m` or `lynx`, 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` prints that 5-minute wait on stderr when the sign-in starts, so an agent whose shell tool stops commands sooner knows to run the command again with a longer timeout +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 accepts the key there but refuses the same check without a key, 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 or only a text-mode one such as `w3m` or `lynx`, 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` prints that 5-minute wait on stderr when the sign-in starts, so an agent whose shell tool stops commands sooner knows to run the command again with a longer timeout When the `CI` environment variable is set to anything but empty, `0`, or `false`, `lite` never opens a browser and the command fails at once with the same message. Set `CI=1` for a script or cron job that should fail instead of waiting for a sign-in diff --git a/litellm/proxy/client/cli/commands/auth.py b/litellm/proxy/client/cli/commands/auth.py index 05c9300fd61..8b23a5bcfa1 100644 --- a/litellm/proxy/client/cli/commands/auth.py +++ b/litellm/proxy/client/cli/commands/auth.py @@ -1089,7 +1089,10 @@ def whoami(ctx: click.Context): token_data: Final = load_token(vault=vault) if not token_data: - click.echo("Not authenticated. Run 'lite login' to authenticate.") + click.echo( + "Not authenticated. Run the command you need, such as 'lite keys generate', " + "and lite opens your browser to sign in. To sign in first from a terminal, run 'lite login'." + ) return key_readable: Final = token_data.get("key") is not None diff --git a/litellm/proxy/client/cli/commands/auto_sign_in.py b/litellm/proxy/client/cli/commands/auto_sign_in.py index e2bd6c6a999..aca26989057 100644 --- a/litellm/proxy/client/cli/commands/auto_sign_in.py +++ b/litellm/proxy/client/cli/commands/auto_sign_in.py @@ -1,4 +1,6 @@ import os +import subprocess +import sys import webbrowser from collections.abc import Callable, Mapping from dataclasses import dataclass @@ -26,11 +28,16 @@ def _runs_in_ci(environ: Mapping[str, str]) -> bool: return environ.get("CI", "").strip().lower() not in {"", "0", "false"} +def _open_browser_off_stdout(url: str) -> bool: + return subprocess.run((sys.executable, "-I", "-m", "webbrowser", url), stdout=2, check=False).returncode == 0 + + def pkce_browser_sign_in( base_url: str, echo: Callable[[str], None], find_browser: Callable[[], webbrowser.BaseBrowser] = webbrowser.get, environ: Mapping[str, str] = os.environ, + open_browser: Callable[[str], object] = _open_browser_off_stdout, ) -> PkceCredential | PkceFailure: if _runs_in_ci(environ): return PkceFailure("the CI environment variable is set, so lite does not open a browser") @@ -44,7 +51,7 @@ def pkce_browser_sign_in( f"lite is opening your browser to sign in and waits up to {LOGIN_TIMEOUT_SECONDS // 60} minutes " "for you to approve it. A shell or agent that stops commands sooner should run this one with a longer timeout." ) - return run_pkce_login(base_url, requests.Session(), echo=echo) + return run_pkce_login(base_url, requests.Session(), open_browser=open_browser, echo=echo) class _SignInContext(TypedDict): @@ -132,16 +139,18 @@ def _sign_in(ctx: click.Context, context: _SignInContext) -> str: return credential.access_token -def _proxy_accepts(base_url: str, api_key: str) -> bool: +def _lists_models(base_url: str, headers: Mapping[str, str]) -> bool: try: - response: Final = requests.get( - f"{base_url.rstrip('/')}/v1/models", headers={"Authorization": f"Bearer {api_key}"}, timeout=10 - ) + response: Final = requests.get(f"{base_url.rstrip('/')}/v1/models", headers=headers, timeout=10) except requests.RequestException: return False return response.ok +def _proxy_accepts(base_url: str, api_key: str) -> bool: + return _lists_models(base_url, {"Authorization": f"Bearer {api_key}"}) and not _lists_models(base_url, {}) + + 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("/"): @@ -153,9 +162,10 @@ def _rerun_after_sign_in(run: Callable[[click.Context], object], ctx: click.Cont 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): + accepted: Final = "your stored login" if context.get("api_key_from_token_file", False) else "this API 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." + f"The proxy at {base_url} accepts {accepted} but refused `{ctx.command_path}` with HTTP 401, " + "so it likely lacks the role this command needs. Set LITELLM_PROXY_API_KEY to a key that has it." ) if not context.get("api_key_from_token_file", False): raise click.ClickException( diff --git a/tests/unit/proxy/client/cli/test_auth_commands.py b/tests/unit/proxy/client/cli/test_auth_commands.py index 27edf9f3eb3..57e73522b5f 100644 --- a/tests/unit/proxy/client/cli/test_auth_commands.py +++ b/tests/unit/proxy/client/cli/test_auth_commands.py @@ -695,7 +695,8 @@ class TestWhoamiCommand: assert result.exit_code == 0 assert "Not authenticated" in result.output - assert "Run 'lite login'" in result.output + assert "lite opens your browser to sign in" in result.output + assert "run 'lite login'" in result.output def test_whoami_old_token(self): """Test whoami with old token showing warning""" diff --git a/tests/unit/proxy/client/cli/test_auto_sign_in.py b/tests/unit/proxy/client/cli/test_auto_sign_in.py index 70c2b6dc389..36bb5a31d3b 100644 --- a/tests/unit/proxy/client/cli/test_auto_sign_in.py +++ b/tests/unit/proxy/client/cli/test_auto_sign_in.py @@ -1,5 +1,4 @@ import json -import time import webbrowser from collections.abc import Callable, Sequence from pathlib import Path @@ -18,6 +17,8 @@ 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"}} +SIGNED_IN_AT: Final = 1_790_000_000.0 +EXPIRES_AT: Final = 4_102_444_800.0 class FakeBrowserSignIn: @@ -35,7 +36,7 @@ def _credential(access_token: str = SIGNED_IN_KEY) -> PkceCredential: return PkceCredential( access_token=access_token, refresh_token="refresh-token", - expires_at=time.time() + 3600, + expires_at=EXPIRES_AT, client_id="cli-client", token_endpoint=f"{BASE_URL}/v1/cli/token", revocation_endpoint=f"{BASE_URL}/v1/cli/revoke", @@ -54,7 +55,7 @@ def _classic_login(key: str) -> CliTokenData: "user_role": "cli", "auth_header_name": "Authorization", "jwt_token": "", - "timestamp": time.time(), + "timestamp": SIGNED_IN_AT, } @@ -76,8 +77,12 @@ 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 _key_check(requests_mock, status_code: int, public: bool = False): + def respond(request, context) -> dict[str, list[str]]: + context.status_code = status_code if public or "Authorization" in request.headers else 401 + return {"data": []} + + return requests_mock.get(f"{BASE_URL}/v1/models", json=respond) def test_a_401_without_a_login_signs_in_once_stores_the_key_and_finishes_the_command( @@ -398,9 +403,11 @@ def test_a_sibling_key_the_proxy_also_rejects_falls_through_to_one_sign_in(reque assert get_stored_api_key(BASE_URL, vault=vault) == SIGNED_IN_KEY -@pytest.mark.parametrize("explicit", [False, True], ids=["stored", "explicit"]) +@pytest.mark.parametrize( + ("explicit", "accepted"), [(False, "your stored login"), (True, "this API key")], 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 + requests_mock, secret_vault_factory, explicit, accepted ): vault = secret_vault_factory() save_token(_classic_login("sk-member"), vault=vault) @@ -413,14 +420,35 @@ def test_a_401_for_a_key_the_proxy_accepts_elsewhere_reports_a_missing_role_with 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 f"accepts {accepted} but refused `cli credentials list` with HTTP 401" in result.stderr + assert "Set LITELLM_PROXY_API_KEY to a key that has it" in result.stderr assert sign_in.calls == [] assert route.call_count == 1 - assert _bearers(key_check) == ["Bearer sk-member"] + assert _bearers(key_check) == ["Bearer sk-member", None] assert vault.writes == writes_before assert "sk-member" not in result.output +def test_a_public_models_route_never_passes_a_rejected_stored_login_off_as_a_missing_role( + requests_mock, secret_vault_factory +): + vault = secret_vault_factory() + save_token(_classic_login("sk-stale"), vault=vault) + _key_check(requests_mock, 200, public=True) + route = requests_mock.get( + f"{BASE_URL}/credentials", + [{"status_code": 401, "json": REJECTED}, {"status_code": 200, "json": {"credentials": []}}], + ) + sign_in = FakeBrowserSignIn(_credential()) + + result = _lite(["credentials", "list", "--format", "json"], vault, sign_in) + + assert result.exit_code == 0, result.output + assert sign_in.calls == [BASE_URL] + assert _bearers(route) == ["Bearer sk-stale", f"Bearer {SIGNED_IN_KEY}"] + assert get_stored_api_key(BASE_URL, vault=vault) == SIGNED_IN_KEY + + def test_help_never_signs_in(requests_mock, secret_vault_factory): sign_in = FakeBrowserSignIn(_credential())