fix(cli): name lite login --pkce and the refresh failure when a key cannot be renewed

A PKCE credential whose renewal is refused (for example after lite logout ran
on another copy of it) used to fail lite auth print-token with the classic
'Token expired. Run lite login again' hint and no reason, while lite whoami
already named lite login --pkce. fresh_api_key now reports why a renewal
failed through a warn callback whenever no sibling rotation rescued it, the
CLI prints that reason on stderr, and the expiry hint names the command that
produced the credential. Both READMEs document the admin revocation semantics
and the Redis precondition for refresh single use on several workers.
This commit is contained in:
mateo-berri 2026-08-20 07:21:42 -07:00
parent 39bdacb3a6
commit 44d3fc7ec8
6 changed files with 69 additions and 13 deletions

View file

@ -378,7 +378,7 @@ Authentication tokens are stored in `~/.litellm/token.json` with restricted file
}
```
The stored credential is a short-lived, per-session agent token, not a managed virtual key. It is scoped to the user and team you logged in as and inherits their models and budgets; spend is tracked against the shared team and user budgets rather than a separate per-session cap, so multiple logins or several concurrent agents all draw down the same allowance. It is short-lived by design (default 24h, configurable via `LITELLM_CLI_JWT_EXPIRATION_HOURS`); re-run `lite login` to refresh it and pick up your latest team and user settings. `lite auth print-token` (usable as Claude Code's `apiKeyHelper`) prints it while fresh and fails once it expires -- there is no silent renewal. It is accepted on a default deployment without `EXPERIMENTAL_UI_LOGIN`, does not appear in the Keys UI, and cannot be rotated or revoked mid-session. A credential from `lite login --pkce` is the exception: it carries a refresh token, so the CLI renews the key shortly before it expires and `lite logout` revokes the refresh token on the proxy (see [Browser sign-in with PKCE](https://docs.litellm.ai/docs/proxy/cli_sso#browser-sign-in-with-pkce)). For a long-lived, rotatable, Keys-UI-visible credential, create a dedicated virtual key in the dashboard and pass it via `--api-key` or `LITELLM_PROXY_API_KEY`.
The stored credential is a short-lived, per-session agent token, not a managed virtual key. It is scoped to the user and team you logged in as and inherits their models and budgets; spend is tracked against the shared team and user budgets rather than a separate per-session cap, so multiple logins or several concurrent agents all draw down the same allowance. It is short-lived by design (default 24h, configurable via `LITELLM_CLI_JWT_EXPIRATION_HOURS`); re-run `lite login` to refresh it and pick up your latest team and user settings. `lite auth print-token` (usable as Claude Code's `apiKeyHelper`) prints it while fresh and fails once it expires -- there is no silent renewal. It is accepted on a default deployment without `EXPERIMENTAL_UI_LOGIN`, does not appear in the Keys UI, and cannot be rotated or revoked mid-session. A credential from `lite login --pkce` is the exception: it carries a refresh token, so the CLI renews the key shortly before it expires and `lite logout` revokes the refresh token on the proxy (see [Browser sign-in with PKCE](https://docs.litellm.ai/docs/proxy/cli_sso#browser-sign-in-with-pkce)). Only the holder can end a `--pkce` session early, with `lite logout`; an admin has no button for it, but every renewal re-reads the user on the proxy, so deactivating the user or removing them from the team makes the next renewal fail and the key runs out within `LITELLM_CLI_JWT_EXPIRATION_HOURS`. On a proxy with more than one worker or replica, configure Redis (`litellm_settings.cache` with Redis `cache_params`, or `general_settings.coordination_redis`) so a refresh token stays single-use and `lite logout` holds on every worker; without Redis each worker keeps its own record. For a long-lived, rotatable, Keys-UI-visible credential, create a dedicated virtual key in the dashboard and pass it via `--api-key` or `LITELLM_PROXY_API_KEY`.
### Usage

View file

@ -501,7 +501,7 @@ To pin the model, pass the agent's own model flag (for example `lite claude --mo
The token minted by `lite login` is a short-lived, per-session agent credential, not a managed virtual key. It is scoped to the user and team you authenticated as, inherits that user's and team's models and budgets, and is enforced on the proxy exactly like a virtual key on the same team (guardrails, routing, logging, spend). Spend is tracked against the shared team and user budgets, so running several agents (or logging in more than once) does not hand each session its own separate budget; they all draw down the same team/user allowance. There is no separate per-session cap, so sustained agent use is not capped at a small chat-session limit.
The credential is short-lived by design (default 24h, configurable via `LITELLM_CLI_JWT_EXPIRATION_HOURS`); run `lite login` again to refresh it, which also re-reads your latest team and user settings. It does not appear in the Keys UI and cannot be rotated or revoked mid-session. `lite auth print-token` (usable as Claude Code's `apiKeyHelper`) prints it while it's still fresh and fails once it expires -- there is no silent renewal, so a long-running session needs a fresh `lite login` once a day. `lite claude`, `lite codex`, and `lite opencode` work with it on a default deployment; `EXPERIMENTAL_UI_LOGIN` is not required. `lite login --pkce` is the exception to the daily re-login: it signs in through your system browser with OAuth authorization code and PKCE and stores a refresh token next to the key, so every `lite` command and `lite auth print-token` renew the key on their own shortly before it expires, `lite whoami` shows when the current key expires, and `lite logout` revokes the refresh token on the proxy (it needs a proxy that serves `/.well-known/litellm-cli-auth`; see [Browser sign-in with PKCE](https://docs.litellm.ai/docs/proxy/cli_sso#browser-sign-in-with-pkce)). If you need a long-lived, rotatable key that shows up in the Keys UI, create a dedicated virtual key in the dashboard and pass it via `--api-key` or `LITELLM_PROXY_API_KEY` instead.
The credential is short-lived by design (default 24h, configurable via `LITELLM_CLI_JWT_EXPIRATION_HOURS`); run `lite login` again to refresh it, which also re-reads your latest team and user settings. It does not appear in the Keys UI and cannot be rotated or revoked mid-session. `lite auth print-token` (usable as Claude Code's `apiKeyHelper`) prints it while it's still fresh and fails once it expires -- there is no silent renewal, so a long-running session needs a fresh `lite login` once a day. `lite claude`, `lite codex`, and `lite opencode` work with it on a default deployment; `EXPERIMENTAL_UI_LOGIN` is not required. `lite login --pkce` is the exception to the daily re-login: it signs in through your system browser with OAuth authorization code and PKCE and stores a refresh token next to the key, so every `lite` command and `lite auth print-token` renew the key on their own shortly before it expires, `lite whoami` shows when the current key expires, and `lite logout` revokes the refresh token on the proxy (it needs a proxy that serves `/.well-known/litellm-cli-auth`; see [Browser sign-in with PKCE](https://docs.litellm.ai/docs/proxy/cli_sso#browser-sign-in-with-pkce)). When a renewal is refused, for example after a `lite logout` run from another copy of the credential, the command prints why on stderr and, once the key has run out, tells you to run `lite login --pkce` again. Only the holder can end a `--pkce` session early, with `lite logout`; an admin has no button for it, but every renewal re-reads the user on the proxy, so deactivating the user or removing them from the team makes the next renewal fail and the key runs out within `LITELLM_CLI_JWT_EXPIRATION_HOURS`. On a proxy with more than one worker or replica, configure Redis (`litellm_settings.cache` with Redis `cache_params`, or `general_settings.coordination_redis`) so a refresh token stays single-use and `lite logout` holds on every worker; without Redis each worker keeps its own record. If you need a long-lived, rotatable key that shows up in the Keys UI, create a dedicated virtual key in the dashboard and pass it via `--api-key` or `LITELLM_PROXY_API_KEY` instead.
### Route Every Claude Code Session Through the Proxy

View file

@ -135,7 +135,15 @@ def get_stored_api_key(expected_base_url: str | None = None) -> str | None:
return None
if expected_base_url is not None and token_data.get("base_url") != expected_base_url.rstrip("/"):
return None
return fresh_api_key(token_data, save_token, requests.Session(), reload=load_token)
return fresh_api_key(token_data, save_token, requests.Session(), reload=load_token, warn=_warn)
def _warn(message: str) -> None:
click.echo(message, err=True)
def _login_command(renews: bool) -> str:
return "lite login --pkce" if renews else "lite login"
# Team selection utilities
@ -809,8 +817,9 @@ def print_token(ctx: click.Context):
Designed to be used as Claude Code's `apiKeyHelper`
(https://docs.claude.com/en/docs/claude-code/settings): stdout must
contain only the token, so all diagnostics go to stderr. The token
expires after `LITELLM_CLI_JWT_EXPIRATION_HOURS` (default 24h); once
expired, run `lite login` again.
expires after `LITELLM_CLI_JWT_EXPIRATION_HOURS` (default 24h); a
`lite login --pkce` token renews itself here first, and once a token
has expired for good, run the same `lite login` command again.
"""
token_data: Final = load_token()
if not token_data:
@ -828,13 +837,14 @@ def print_token(ctx: click.Context):
click.echo("Not authenticated for this server. Run 'lite login'.", err=True)
sys.exit(1)
if not is_cli_token_fresh(token_data) and "refresh_token" not in token_data:
renews: Final = "refresh_token" in token_data
if not is_cli_token_fresh(token_data) and not renews:
click.echo("Token expired. Run 'lite login' again.", err=True)
sys.exit(1)
api_key: Final = fresh_api_key(token_data, save_token, requests.Session(), reload=load_token)
api_key: Final = fresh_api_key(token_data, save_token, requests.Session(), reload=load_token, warn=_warn)
if not api_key:
click.echo("Token expired. Run 'lite login' again.", err=True)
click.echo(f"Key expired. Run '{_login_command(renews)}' again.", err=True)
sys.exit(1)
click.echo(api_key)
@ -871,7 +881,7 @@ def whoami():
def _key_expiry_line(expires_at: float, renews: bool) -> str:
remaining_hours: Final = (expires_at - time.time()) / 3600
if remaining_hours <= 0:
return f"Key expired. Run '{'lite login --pkce' if renews else 'lite login'}' again"
return f"Key expired. Run '{_login_command(renews)}' again"
status: Final = f"Key expires in: {remaining_hours:.1f} hours"
return f"{status}, renewed on next use" if renews else status

View file

@ -476,6 +476,10 @@ def pkce_token_record(base_url: str, credential: PkceCredential) -> CliTokenData
return record
def _ignore_warning(_message: str) -> None:
return None
def fresh_api_key(
token_data: Mapping[str, object],
save: Callable[[CliTokenData], None],
@ -483,6 +487,7 @@ def fresh_api_key(
*,
reload: Callable[[], Mapping[str, object] | None],
now: Callable[[], float] = time.time,
warn: Callable[[str], None] = _ignore_warning,
) -> str | None:
"""The stored key, refreshed first when it is about to expire and a refresh token is
on file. The refresh fires at the same moment ``is_cli_token_fresh`` stops calling the
@ -490,8 +495,10 @@ def fresh_api_key(
with itself. The rotated pair is saved before the new key is returned, so a crash after
this point never strands the CLI with a burned refresh token. A refresh that fails
reads the record again, because a sibling ``lite`` process may have rotated the pair
first, in which case the key it saved for this same proxy is the live one. A record without
``expires_at`` (the classic ``lite login`` credential) is returned as stored."""
first, in which case the key it saved for this same proxy is the live one; when no sibling
did, the reason the proxy gave goes to ``warn`` so a revoked or refused refresh token is
never a silent failure. A record without ``expires_at`` (the classic ``lite login``
credential) is returned as stored."""
key: Final = token_data.get("key")
if not isinstance(key, str) or not key:
return None
@ -506,7 +513,10 @@ def fresh_api_key(
return still_valid
refreshed: Final = refresh_credential(*refresh_inputs, http=http, now=now)
if isinstance(refreshed, PkceFailure):
return _key_rotated_by_a_sibling(reload(), token_data, now()) or still_valid
sibling_key: Final = _key_rotated_by_a_sibling(reload(), token_data, now())
if sibling_key is None:
warn(f"Could not renew the key: {refreshed.reason}")
return sibling_key or still_valid
base_url: Final = token_data.get("base_url")
save(pkce_token_record(base_url if isinstance(base_url, str) else "", refreshed))
return refreshed.access_token

View file

@ -1520,7 +1520,9 @@ class TestPkcePrintToken:
assert result.exit_code == 1
assert result.stdout == ""
assert "Token expired. Run 'lite login' again." in result.output
assert "Could not renew the key: token request failed with 400: invalid_grant" in result.output
assert "Key expired. Run 'lite login --pkce' again." in result.output
assert "Run 'lite login' again" not in result.output
save.assert_not_called()
def test_print_token_for_an_expired_classic_token_makes_no_request(self):
@ -1557,3 +1559,23 @@ class TestGetStoredApiKeyRefresh:
assert save.call_count == 1
assert len(_FakeSession.instances) == 1
def test_get_stored_api_key_reports_a_refused_renewal_on_stderr_and_keeps_the_valid_key(self, capsys):
_FakeSession.instances.clear()
class _RefusingSession(_FakeSession):
def __init__(self):
super().__init__()
self.response = _FakeHttpResponse(503, {"error": "temporarily_unavailable"})
with (
patch("litellm.proxy.client.cli.commands.auth.load_token", return_value=_pkce_record()),
patch("litellm.proxy.client.cli.commands.auth.save_token") as save,
patch("litellm.proxy.client.cli.commands.auth.requests.Session", _RefusingSession),
):
assert get_stored_api_key(PKCE_BASE_URL) == "sk-cli-old"
captured = capsys.readouterr()
assert captured.out == ""
assert captured.err == "Could not renew the key: token request failed with 503: temporarily_unavailable\n"
save.assert_not_called()

View file

@ -619,6 +619,20 @@ def test_fresh_api_key_falls_back_to_the_old_key_only_while_it_is_still_valid():
assert saved == []
def test_fresh_api_key_reports_why_a_renewal_failed_unless_a_sibling_rotated_first():
failing = _FakeHttp({("POST", f"{BASE}/token"): _FakeResponse(503, {"error": "temporarily_unavailable"})})
rotated = {**STORED, "key": "sk-cli-sibling", "refresh_token": "llm_srefresh_sibling", "expires_at": 1_003_600.0}
warnings = []
assert _fresh(STORED, lambda _: None, failing, now=lambda: 990_000.0, warn=warnings.append) == "sk-cli-old"
assert warnings == []
assert _fresh(STORED, lambda _: None, failing, now=lambda: 999_950.0, warn=warnings.append) == "sk-cli-old"
assert _fresh(STORED, lambda _: None, failing, now=lambda: 1_000_001.0, warn=warnings.append) is None
assert warnings == ["Could not renew the key: token request failed with 503: temporarily_unavailable"] * 2
sibling = _fresh(STORED, lambda _: None, failing, reload=lambda: rotated, now=lambda: 1_000_001.0, warn=warnings.append)
assert sibling == "sk-cli-sibling"
assert len(warnings) == 2
def test_fresh_api_key_uses_a_sibling_rotation_when_its_own_refresh_loses_the_race():
rotated = {**STORED, "key": "sk-cli-sibling", "refresh_token": "llm_srefresh_sibling", "expires_at": 1_003_600.0}
http = _FakeHttp({("POST", f"{BASE}/token"): _FakeResponse(400, {"error": "invalid_grant"})})