fix(cli): keep the browser launcher off stdout, check the role probe without a key, and point whoami at the automatic sign-in

This commit is contained in:
mateo-berri 2026-10-16 14:01:27 -07:00
parent 9449655e1f
commit 7140b49a97
5 changed files with 61 additions and 19 deletions

View file

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

View file

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

View file

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

View file

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

View file

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