mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-05 07:37:50 +00:00
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:
parent
797f74efbc
commit
9449655e1f
4 changed files with 79 additions and 10 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
[
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue