diff --git a/litellm/proxy/client/cli/README.md b/litellm/proxy/client/cli/README.md index e1ec65136e5..ff5343c0640 100644 --- a/litellm/proxy/client/cli/README.md +++ b/litellm/proxy/client/cli/README.md @@ -59,9 +59,11 @@ 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, 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` +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 -`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 +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 + +`lite chat`, `lite encryption migrate`, `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 encryption migrate` is a one-time admin migration you run with an admin key, `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 diff --git a/litellm/proxy/client/cli/commands/auto_sign_in.py b/litellm/proxy/client/cli/commands/auto_sign_in.py index fa4eb4b506e..e2bd6c6a999 100644 --- a/litellm/proxy/client/cli/commands/auto_sign_in.py +++ b/litellm/proxy/client/cli/commands/auto_sign_in.py @@ -1,5 +1,6 @@ +import os import webbrowser -from collections.abc import Callable +from collections.abc import Callable, Mapping from dataclasses import dataclass from typing import Final, Literal, Protocol @@ -11,20 +12,38 @@ 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 +from .pkce_login import LOGIN_TIMEOUT_SECONDS, PkceCredential, PkceFailure, pkce_token_record, run_pkce_login class BrowserSignIn(Protocol): def __call__(self, base_url: str, echo: Callable[[str], None]) -> PkceCredential | PkceFailure: ... +_TEXT_MODE_BROWSERS: Final = frozenset({"www-browser", "links", "elinks", "lynx", "w3m"}) + + +def _runs_in_ci(environ: Mapping[str, str]) -> bool: + return environ.get("CI", "").strip().lower() not in {"", "0", "false"} + + def pkce_browser_sign_in( - base_url: str, echo: Callable[[str], None], find_browser: Callable[[], object] = webbrowser.get + base_url: str, + echo: Callable[[str], None], + find_browser: Callable[[], webbrowser.BaseBrowser] = webbrowser.get, + environ: Mapping[str, str] = os.environ, ) -> PkceCredential | PkceFailure: + if _runs_in_ci(environ): + return PkceFailure("the CI environment variable is set, so lite does not open a browser") try: - _ = find_browser() + browser: Final = find_browser() except webbrowser.Error: return PkceFailure("no web browser is available on this machine") + if browser.name in _TEXT_MODE_BROWSERS and "BROWSER" not in environ: + return PkceFailure("only a text-mode web browser is available on this machine") + echo( + 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) @@ -97,7 +116,7 @@ def _attempt_with_newer_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.") + _echo_to_stderr(f"The proxy at {base_url} needs a 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): diff --git a/litellm/proxy/common_utils/html_forms/native_client_consent.py b/litellm/proxy/common_utils/html_forms/native_client_consent.py index dac92c4e787..0cbedcd0960 100644 --- a/litellm/proxy/common_utils/html_forms/native_client_consent.py +++ b/litellm/proxy/common_utils/html_forms/native_client_consent.py @@ -59,7 +59,7 @@ button {{ flex: 1; padding: 10px; border-radius: 6px; font-size: 15px; cursor: p

Authorize CLI access

A command-line client at {escape(client_origin)} wants to call LiteLLM as {escape(user_id)}.

-

Approving issues it a personal credential that expires within {CLI_JWT_EXPIRATION_HOURS} hours. lite logout stops it from being renewed. Only approve if you started this sign-in yourself.

+

Approving issues it a personal credential that expires within {CLI_JWT_EXPIRATION_HOURS} hours. lite logout stops it from being renewed. Only approve if you started this sign-in, yourself or through an agent you are running.

{_team_field(teams)} 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 6312979d07c..70c2b6dc389 100644 --- a/tests/unit/proxy/client/cli/test_auto_sign_in.py +++ b/tests/unit/proxy/client/cli/test_auto_sign_in.py @@ -150,18 +150,66 @@ def test_a_login_for_another_proxy_is_never_replaced_by_an_automatic_sign_in(req def test_the_browser_sign_in_fails_at_once_on_a_machine_without_a_browser(requests_mock): - def no_browser() -> object: + def no_browser() -> webbrowser.BaseBrowser: raise webbrowser.Error("could not locate runnable browser") echoed: list[str] = [] - outcome = pkce_browser_sign_in(BASE_URL, echoed.append, find_browser=no_browser) + outcome = pkce_browser_sign_in(BASE_URL, echoed.append, find_browser=no_browser, environ={}) assert outcome == PkceFailure("no web browser is available on this machine") assert requests_mock.call_count == 0 assert echoed == [] +@pytest.mark.parametrize("ci", ["true", "1", "True"]) +def test_the_browser_sign_in_never_looks_for_a_browser_in_ci(requests_mock, ci): + def browser_lookup() -> webbrowser.BaseBrowser: + raise AssertionError("looked for a browser while CI is set") + + echoed: list[str] = [] + + outcome = pkce_browser_sign_in(BASE_URL, echoed.append, find_browser=browser_lookup, environ={"CI": ci}) + + assert outcome == PkceFailure("the CI environment variable is set, so lite does not open a browser") + assert requests_mock.call_count == 0 + assert echoed == [] + + +@pytest.mark.parametrize("name", ["www-browser", "links", "elinks", "lynx", "w3m"]) +def test_a_text_mode_browser_counts_as_no_browser(requests_mock, name): + echoed: list[str] = [] + + outcome = pkce_browser_sign_in( + BASE_URL, echoed.append, find_browser=lambda: webbrowser.GenericBrowser(name), environ={"TERM": "xterm"} + ) + + assert outcome == PkceFailure("only a text-mode web browser is available on this machine") + assert requests_mock.call_count == 0 + assert echoed == [] + + +@pytest.mark.parametrize( + "environ", + [{"BROWSER": "w3m"}, {"CI": "false", "BROWSER": "w3m"}, {"CI": "0", "BROWSER": "w3m"}], + ids=["browser-picks-it", "ci-false", "ci-zero"], +) +def test_a_browser_the_user_picked_starts_the_sign_in_and_names_how_long_it_waits(requests_mock, environ): + discovery = requests_mock.get(f"{BASE_URL}/.well-known/litellm-cli-auth", status_code=404) + echoed: list[str] = [] + + outcome = pkce_browser_sign_in( + BASE_URL, echoed.append, find_browser=lambda: webbrowser.GenericBrowser("w3m"), environ=environ + ) + + assert isinstance(outcome, PkceFailure) + assert discovery.call_count == 1 + assert echoed == [ + "lite is opening your browser to sign in and waits up to 5 minutes for you to approve it. " + "A shell or agent that stops commands sooner should run this one with a longer timeout." + ] + + @pytest.mark.parametrize( ("command", "method", "path", "body"), [