fix(cli): skip the browser sign-in in CI and with text-mode browsers, and name how long it waits

This commit is contained in:
mateo-berri 2026-10-16 13:10:14 -07:00
parent 797f74efbc
commit 9449655e1f
4 changed files with 79 additions and 10 deletions

View file

@ -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 <proxy> 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

View file

@ -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):

View file

@ -59,7 +59,7 @@ button {{ flex: 1; padding: 10px; border-radius: 6px; font-size: 15px; cursor: p
<div class="container">
<h1>Authorize CLI access</h1>
<p>A command-line client at <code>{escape(client_origin)}</code> wants to call LiteLLM as <strong>{escape(user_id)}</strong>.</p>
<p>Approving issues it a personal credential that expires within {CLI_JWT_EXPIRATION_HOURS} hours. <code>lite logout</code> stops it from being renewed. Only approve if you started this sign-in yourself.</p>
<p>Approving issues it a personal credential that expires within {CLI_JWT_EXPIRATION_HOURS} hours. <code>lite logout</code> stops it from being renewed. Only approve if you started this sign-in, yourself or through an agent you are running.</p>
<form method="post" action="{escape(complete_url)}">
<input type="hidden" name="flow" value="{escape(flow_handle)}">
{_team_field(teams)}

View file

@ -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"),
[