From 44d3fc7ec80432f68e8b4564b2b192c06f7d968b Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:21:42 -0700 Subject: [PATCH] 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. --- litellm/proxy/client/README.md | 2 +- litellm/proxy/client/cli/README.md | 2 +- litellm/proxy/client/cli/commands/auth.py | 24 +++++++++++++------ .../proxy/client/cli/commands/pkce_login.py | 16 ++++++++++--- .../proxy/client/cli/test_auth_commands.py | 24 ++++++++++++++++++- .../proxy/client/cli/test_pkce_login.py | 14 +++++++++++ 6 files changed, 69 insertions(+), 13 deletions(-) diff --git a/litellm/proxy/client/README.md b/litellm/proxy/client/README.md index 997c923de4d..bfe95dfcbd7 100644 --- a/litellm/proxy/client/README.md +++ b/litellm/proxy/client/README.md @@ -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 diff --git a/litellm/proxy/client/cli/README.md b/litellm/proxy/client/cli/README.md index faa015a2f77..fe417396317 100644 --- a/litellm/proxy/client/cli/README.md +++ b/litellm/proxy/client/cli/README.md @@ -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 diff --git a/litellm/proxy/client/cli/commands/auth.py b/litellm/proxy/client/cli/commands/auth.py index 73f69488499..d776a2c9418 100644 --- a/litellm/proxy/client/cli/commands/auth.py +++ b/litellm/proxy/client/cli/commands/auth.py @@ -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 diff --git a/litellm/proxy/client/cli/commands/pkce_login.py b/litellm/proxy/client/cli/commands/pkce_login.py index 16d2a7b05d6..a0db1ba2db7 100644 --- a/litellm/proxy/client/cli/commands/pkce_login.py +++ b/litellm/proxy/client/cli/commands/pkce_login.py @@ -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 diff --git a/tests/test_litellm/proxy/client/cli/test_auth_commands.py b/tests/test_litellm/proxy/client/cli/test_auth_commands.py index 454cf6f8df1..f618bf8ea61 100644 --- a/tests/test_litellm/proxy/client/cli/test_auth_commands.py +++ b/tests/test_litellm/proxy/client/cli/test_auth_commands.py @@ -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() diff --git a/tests/test_litellm/proxy/client/cli/test_pkce_login.py b/tests/test_litellm/proxy/client/cli/test_pkce_login.py index 1281de3634f..712c7e83236 100644 --- a/tests/test_litellm/proxy/client/cli/test_pkce_login.py +++ b/tests/test_litellm/proxy/client/cli/test_pkce_login.py @@ -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"})})