mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +00:00
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:
parent
9449655e1f
commit
7140b49a97
5 changed files with 61 additions and 19 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue