feat(cli): sign in through the browser when a lite command gets a 401 without a key

When a command in the keys, models, model-groups, users, teams, or credentials groups gets a 401 and no key was passed with --api-key or LITELLM_PROXY_API_KEY, lite now runs the PKCE browser sign-in, stores the credential, and runs the command again once with the new key. All sign-in output goes to stderr, so stdout keeps only the command's output.

Before it opens a browser, lite checks whether the proxy accepts the rejected key on /v1/models (a 401 can be a role refusal for a valid key), tries a newer key another lite process stored, and refuses to replace a stored login that belongs to a different proxy. An explicit key that gets a 401 fails with a clean error and never opens a browser.
This commit is contained in:
mateo-berri 2026-10-16 12:12:50 -07:00
parent 339fd90836
commit 797f74efbc
10 changed files with 623 additions and 12 deletions

View file

@ -49,6 +49,20 @@ lite config unset hidden_commands # list everything again
Hidden commands drop out of both `lite --help` and the interactive shell's "Available commands" block, and stay runnable so existing scripts keep working
## Automatic Browser Sign-In for Agents
When a command that calls the proxy (`lite keys`, `lite models`, `lite model-groups`, `lite users`, `lite teams`, `lite credentials`) gets a 401 and you did not pass a key with `--api-key` or `LITELLM_PROXY_API_KEY`, `lite` signs you in through your browser and then runs the same command again with the new key. That covers a proxy you never logged in to and a stored login the proxy no longer accepts. It needs no terminal, so an AI agent that runs `lite` through its shell tool finishes the command on its own once you approve the sign-in in the browser:
```bash
lite --base-url https://your-proxy.example.com keys generate --models gpt-5.4-mini --key-alias experiment-1
```
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`
`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
## Global Options
- `--version`, `-v`: Print the LiteLLM Proxy client and server version and exit.

View file

@ -849,14 +849,14 @@ def _finish_login(base_url: str, api_key: str, config_claude: bool, stored: Secr
show_commands()
def _replace_stored_token(record: CliTokenData, http: Http, vault: SecretVault) -> SecretSave:
def replace_stored_token(record: CliTokenData, http: Http, vault: SecretVault) -> SecretSave:
previous: Final = load_token(vault=vault)
stored: Final = save_token(record, vault=vault)
if previous is None or isinstance(stored, CredentialNotSaved):
return stored
revocation: Final = revoke_stored_credential(previous, http)
if revocation is not None:
click.echo(
_warn(
f"Could not revoke the previous login's refresh token on the proxy ({revocation.reason}); "
"it expires on its own."
)
@ -869,7 +869,7 @@ def _pkce_login(base_url: str, config_claude: bool, vault: SecretVault) -> None:
if isinstance(credential, PkceFailure):
click.echo(f"Authentication failed: {credential.reason}")
return
stored: Final = _replace_stored_token(pkce_token_record(base_url, credential), http, vault)
stored: Final = replace_stored_token(pkce_token_record(base_url, credential), http, vault)
_finish_login(base_url, credential.access_token, config_claude, stored)
@ -948,7 +948,7 @@ def login(ctx: click.Context, config_claude: bool, pkce: bool) -> None:
# Save token data. base_url is stored so we can verify origin
# before reusing the key on a subsequent CLI invocation.
stored: Final = _replace_stored_token(
stored: Final = replace_stored_token(
{
"base_url": base_url.rstrip("/"),
"key": api_key,

View file

@ -0,0 +1,181 @@
import webbrowser
from collections.abc import Callable
from dataclasses import dataclass
from typing import Final, Literal, Protocol
import click
import requests
from typing_extensions import NotRequired, ReadOnly, TypedDict
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
class BrowserSignIn(Protocol):
def __call__(self, base_url: str, echo: Callable[[str], None]) -> PkceCredential | PkceFailure: ...
def pkce_browser_sign_in(
base_url: str, echo: Callable[[str], None], find_browser: Callable[[], object] = webbrowser.get
) -> PkceCredential | PkceFailure:
try:
_ = find_browser()
except webbrowser.Error:
return PkceFailure("no web browser is available on this machine")
return run_pkce_login(base_url, requests.Session(), echo=echo)
class _SignInContext(TypedDict):
base_url: ReadOnly[str]
api_key: ReadOnly[NotRequired[str | None]]
api_key_from_token_file: ReadOnly[NotRequired[bool]]
browser_sign_in: ReadOnly[NotRequired[BrowserSignIn]]
class _SignInContextView(Protocol):
@property
def obj(self) -> _SignInContext | None: ...
class _ApiKeySlot(Protocol):
def __setitem__(self, key: Literal["api_key"], value: str, /) -> None: ...
class _ApiKeySlotView(Protocol):
@property
def obj(self) -> _ApiKeySlot: ...
def _sign_in_context(view: _SignInContextView) -> _SignInContext | None:
return view.obj
def _api_key_slot(view: _ApiKeySlotView) -> _ApiKeySlot:
return view.obj
@dataclass(frozen=True, slots=True)
class _Completed:
value: object
@dataclass(frozen=True, slots=True)
class _Unauthorized:
error: UnauthorizedError
def _attempt(run: Callable[[click.Context], object], ctx: click.Context) -> _Completed | _Unauthorized:
try:
return _Completed(run(ctx))
except UnauthorizedError as error:
return _Unauthorized(error)
def _attempt_with_key(
run: Callable[[click.Context], object], ctx: click.Context, api_key: str
) -> _Completed | _Unauthorized:
slot: Final = _api_key_slot(ctx)
slot["api_key"] = api_key
return _attempt(run, ctx)
def _echo_to_stderr(message: str) -> None:
click.echo(message, err=True)
def _attempt_with_newer_stored_key(
run: Callable[[click.Context], object], ctx: click.Context, base_url: str, rejected_key: str | None
) -> _Completed | _Unauthorized | None:
stored_key: Final = get_stored_api_key(expected_base_url=base_url, vault=context_secret_vault(ctx))
if stored_key is None or stored_key == rejected_key:
return None
return _attempt_with_key(run, ctx, 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.")
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):
raise click.ClickException(
f"Could not sign in to {base_url} through the browser: {credential.reason}. "
"Run `lite login` from a terminal, or set LITELLM_PROXY_API_KEY."
)
stored: Final = replace_stored_token(
pkce_token_record(base_url, credential), requests.Session(), context_secret_vault(ctx)
)
_echo_to_stderr(storage_notice(stored))
_echo_to_stderr(f"Signed in. Continuing `{ctx.command_path}`.")
return credential.access_token
def _proxy_accepts(base_url: str, api_key: str) -> bool:
try:
response: Final = requests.get(
f"{base_url.rstrip('/')}/v1/models", headers={"Authorization": f"Bearer {api_key}"}, timeout=10
)
except requests.RequestException:
return False
return response.ok
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("/"):
return None
return login.base_url
def _rerun_after_sign_in(run: Callable[[click.Context], object], ctx: click.Context, context: _SignInContext) -> object:
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):
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."
)
if not context.get("api_key_from_token_file", False):
raise click.ClickException(
f"The proxy at {base_url} rejected the API key from --api-key or LITELLM_PROXY_API_KEY. "
"Check that key, or unset both to sign in through your browser instead."
)
retried: Final = _attempt_with_newer_stored_key(run, ctx, base_url, rejected_key)
if isinstance(retried, _Completed):
return retried.value
other_proxy: Final = _login_for_another_proxy(ctx, base_url)
if other_proxy is not None:
raise click.ClickException(
f"The proxy at {base_url} needs a sign-in, but lite keeps one stored login and it is for {other_proxy}, "
f"so lite did not replace it on its own. Run `lite --base-url {base_url} login` to switch proxies, "
"or set LITELLM_PROXY_API_KEY."
)
match _attempt_with_key(run, ctx, _sign_in(ctx, context)):
case _Completed(value=value):
return value
case _Unauthorized():
raise click.ClickException(
f"The proxy at {base_url} refused `{ctx.command_path}` with HTTP 401 again after the browser sign-in, "
"so the signed-in user likely lacks the role this command needs. "
"Set LITELLM_PROXY_API_KEY to a key that has it."
)
class SignInOnUnauthorizedCommand(click.Command):
def invoke(self, ctx: click.Context) -> object:
run: Final = super().invoke
match _attempt(run, ctx):
case _Completed(value=value):
return value
case _Unauthorized(error=error):
context: Final = _sign_in_context(ctx)
if context is None:
raise error
return _rerun_after_sign_in(run, ctx, context)
class SignInOnUnauthorizedGroup(click.Group):
command_class = SignInOnUnauthorizedCommand

View file

@ -10,6 +10,7 @@ from typing_extensions import NotRequired, ReadOnly, TypedDict
from ...credentials import CredentialsManagementClient
from ._cli_context import cli_context_values
from .auto_sign_in import SignInOnUnauthorizedGroup
class _CredentialInfo(TypedDict):
@ -39,7 +40,7 @@ def _print_json(data: object) -> None:
rich.print_json(data=data)
@click.group()
@click.group(cls=SignInOnUnauthorizedGroup)
def credentials():
"""Manage credentials for the LiteLLM proxy server"""

View file

@ -11,6 +11,7 @@ from rich.table import Table
from typing_extensions import ReadOnly, TypedDict
from ...keys import KeysManagementClient
from .auto_sign_in import SignInOnUnauthorizedGroup
class _CliContext(TypedDict):
@ -37,7 +38,7 @@ def _cli_context(view: _CliContextView) -> _CliContext:
return view.obj
@click.group()
@click.group(cls=SignInOnUnauthorizedGroup)
def keys():
"""Manage API keys for the LiteLLM proxy server"""
@ -352,7 +353,7 @@ def _import_keys_to_destination(
return imported_count, failed_count
@keys.command(name="import")
@keys.command(name="import", cls=click.Command)
@click.option(
"--source-base-url",
required=True,

View file

@ -7,6 +7,7 @@ import rich.table
from ... import Client
from ._cli_context import cli_context_values
from .auto_sign_in import SignInOnUnauthorizedGroup
def create_client(ctx: click.Context) -> Client:
@ -19,7 +20,7 @@ def _rendered_field(group: Mapping[str, object], key: str, default: str) -> str:
return str(group.get(key, default))
@click.group(name="model-groups")
@click.group(name="model-groups", cls=SignInOnUnauthorizedGroup)
def model_groups() -> None:
"""Inspect model groups your key can access on the proxy"""

View file

@ -14,7 +14,9 @@ from typing_extensions import NotRequired, ReadOnly, TypedDict
# local imports
from ... import Client
from ...exceptions import UnauthorizedError
from ._cli_context import cli_context_values
from .auto_sign_in import SignInOnUnauthorizedGroup
if TYPE_CHECKING:
from rich.console import JustifyMethod
@ -103,7 +105,7 @@ def create_client(ctx: click.Context) -> Client:
return Client(base_url=context["base_url"], api_key=context["api_key"])
@click.group()
@click.group(cls=SignInOnUnauthorizedGroup)
def models() -> None:
"""Manage models on your LiteLLM proxy server"""
@ -449,6 +451,13 @@ def import_models(
model_params=model_info_obj.model_params,
model_info=model_info_obj.model_info,
)
except UnauthorizedError as error:
if added_models:
raise click.ClickException(
f"The proxy rejected the API key after {len(added_models)} of {len(filtered_model_list)} "
"models were sent, so the import stopped there."
) from error
raise
except Exception:
pass # For summary, ignore errors
added_models.append(model_info_obj)

View file

@ -10,8 +10,10 @@ from rich.table import Table
from typing_extensions import ReadOnly, TypedDict
from litellm.proxy.client import Client
from litellm.proxy.client.exceptions import UnauthorizedError
from ._cli_context import cli_context_values
from .auto_sign_in import SignInOnUnauthorizedGroup
class _TeamRow(TypedDict):
@ -29,7 +31,7 @@ class _ErrorBodyView(TypedDict):
body: ReadOnly[Mapping[str, object]]
@click.group()
@click.group(cls=SignInOnUnauthorizedGroup)
def teams():
"""Manage teams and team assignments"""
@ -94,6 +96,8 @@ def list(ctx: click.Context):
# Use list() for simpler response structure (returns array directly)
teams: Final = client.teams.list()
display_teams_table(teams)
except UnauthorizedError:
raise
except requests.exceptions.HTTPError as e:
click.echo(f"Error: HTTP {e.response.status_code}", err=True)
error_body: Final[_ErrorBodyView] = {"body": e.response.json()}
@ -119,6 +123,8 @@ def available(ctx: click.Context):
display_teams_table(teams)
else:
click.echo("No available teams to join.")
except UnauthorizedError:
raise
except requests.exceptions.HTTPError as e:
click.echo(f"Error: HTTP {e.response.status_code}", err=True)
error_body: Final[_ErrorBodyView] = {"body": e.response.json()}
@ -128,7 +134,7 @@ def available(ctx: click.Context):
raise click.Abort()
@teams.command()
@teams.command(cls=click.Command)
@click.option("--team-id", type=str, help="Team ID to assign the key to")
@click.pass_context
def assign_key(ctx: click.Context, team_id: str | None):

View file

@ -5,9 +5,10 @@ import rich
from ... import UsersManagementClient
from ._cli_context import cli_context_values
from .auto_sign_in import SignInOnUnauthorizedGroup
@click.group()
@click.group(cls=SignInOnUnauthorizedGroup)
def users():
"""Manage users on your LiteLLM proxy server"""

View file

@ -0,0 +1,397 @@
import json
import time
import webbrowser
from collections.abc import Callable, Sequence
from pathlib import Path
from typing import Final
import pytest
import requests
from click.testing import CliRunner, Result
from litellm.proxy.client.cli import cli
from litellm.proxy.client.cli.commands.auth import CliTokenData, get_stored_api_key, save_token
from litellm.proxy.client.cli.commands.auto_sign_in import pkce_browser_sign_in
from litellm.proxy.client.cli.commands.pkce_login import PkceCredential, PkceFailure, pkce_token_record
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"}}
class FakeBrowserSignIn:
def __init__(self, outcome: PkceCredential | PkceFailure) -> None:
self.outcome = outcome
self.calls: list[str] = []
def __call__(self, base_url: str, echo: Callable[[str], None]) -> PkceCredential | PkceFailure:
self.calls.append(base_url)
echo(f"Opening browser to: {base_url}/v1/cli/authorize?state=fake")
return self.outcome
def _credential(access_token: str = SIGNED_IN_KEY) -> PkceCredential:
return PkceCredential(
access_token=access_token,
refresh_token="refresh-token",
expires_at=time.time() + 3600,
client_id="cli-client",
token_endpoint=f"{BASE_URL}/v1/cli/token",
revocation_endpoint=f"{BASE_URL}/v1/cli/revoke",
resource=BASE_URL,
user_id="agent-user",
team_id=None,
)
def _classic_login(key: str) -> CliTokenData:
return {
"base_url": BASE_URL,
"key": key,
"user_id": "agent-user",
"user_email": "unknown",
"user_role": "cli",
"auth_header_name": "Authorization",
"jwt_token": "",
"timestamp": time.time(),
}
@pytest.fixture(autouse=True)
def no_key_in_the_environment(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("LITELLM_PROXY_API_KEY", raising=False)
monkeypatch.delenv("LITELLM_PROXY_URL", raising=False)
def _lite(args: Sequence[str], vault: object, sign_in: FakeBrowserSignIn, global_args: Sequence[str] = ()) -> Result:
return CliRunner().invoke(
cli,
[*global_args, "--base-url", BASE_URL, *args],
obj={"secret_vault": vault, "browser_sign_in": sign_in},
)
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 test_a_401_without_a_login_signs_in_once_stores_the_key_and_finishes_the_command(
requests_mock, secret_vault_factory
):
route = requests_mock.post(
f"{BASE_URL}/key/generate", [{"status_code": 401, "json": REJECTED}, {"status_code": 200, "json": GENERATED}]
)
vault = secret_vault_factory()
sign_in = FakeBrowserSignIn(_credential())
result = _lite(["keys", "generate", "--models", "gpt-5.4-mini", "--key-alias", "experiment-1"], vault, sign_in)
assert result.exit_code == 0, result.output
assert json.loads(result.stdout) == GENERATED
assert sign_in.calls == [BASE_URL]
assert _bearers(route) == [None, f"Bearer {SIGNED_IN_KEY}"]
assert get_stored_api_key(BASE_URL, vault=vault) == SIGNED_IN_KEY
assert f"The proxy at {BASE_URL} needs a sign-in" in result.stderr
assert f"Opening browser to: {BASE_URL}/v1/cli/authorize" in result.stderr
assert "Credential stored in your OS keychain." in result.stderr
assert "Continuing `cli keys generate`" in result.stderr
assert SIGNED_IN_KEY not in result.output
@pytest.mark.parametrize(
"key_check",
[{"status_code": 401}, {"status_code": 503}, {"exc": requests.exceptions.ConnectionError}],
ids=["rejected", "unavailable", "unreachable"],
)
def test_a_stored_login_the_proxy_rejects_is_replaced_by_one_sign_in_with_only_json_on_stdout(
requests_mock, secret_vault_factory, key_check
):
vault = secret_vault_factory()
save_token(pkce_token_record(BASE_URL, _credential("sk-stale")), vault=vault)
requests_mock.get(f"{BASE_URL}/v1/models", **key_check)
revoke = requests_mock.post(f"{BASE_URL}/v1/cli/revoke", status_code=500)
route = requests_mock.post(
f"{BASE_URL}/key/generate", [{"status_code": 401, "json": REJECTED}, {"status_code": 200, "json": GENERATED}]
)
sign_in = FakeBrowserSignIn(_credential())
result = _lite(["keys", "generate"], vault, sign_in)
assert result.exit_code == 0, result.output
assert json.loads(result.stdout) == GENERATED
assert sign_in.calls == [BASE_URL]
assert _bearers(route) == ["Bearer sk-stale", f"Bearer {SIGNED_IN_KEY}"]
assert revoke.call_count == 1
assert "Could not revoke the previous login's refresh token" in result.stderr
assert get_stored_api_key(BASE_URL, vault=vault) == SIGNED_IN_KEY
def test_a_login_for_another_proxy_is_never_replaced_by_an_automatic_sign_in(requests_mock, secret_vault_factory):
other_proxy = "https://other.example.com"
vault = secret_vault_factory()
save_token({**_classic_login("sk-other"), "base_url": other_proxy}, vault=vault)
route = requests_mock.post(f"{BASE_URL}/key/generate", status_code=401, json=REJECTED)
sign_in = FakeBrowserSignIn(_credential())
result = _lite(["keys", "generate"], vault, sign_in)
assert result.exit_code == 1
assert isinstance(result.exception, SystemExit)
assert f"lite keeps one stored login and it is for {other_proxy}" in result.stderr
assert f"Run `lite --base-url {BASE_URL} login`" in result.stderr
assert sign_in.calls == []
assert _bearers(route) == [None]
assert get_stored_api_key(other_proxy, vault=vault) == "sk-other"
def test_the_browser_sign_in_fails_at_once_on_a_machine_without_a_browser(requests_mock):
def no_browser() -> object:
raise webbrowser.Error("could not locate runnable browser")
echoed: list[str] = []
outcome = pkce_browser_sign_in(BASE_URL, echoed.append, find_browser=no_browser)
assert outcome == PkceFailure("no web browser is available on this machine")
assert requests_mock.call_count == 0
assert echoed == []
@pytest.mark.parametrize(
("command", "method", "path", "body"),
[
(["keys", "list", "--format", "json"], "GET", "/key/list", {"keys": []}),
(["models", "list", "--format", "json"], "GET", "/models", {"data": []}),
(["model-groups", "list", "--format", "json"], "GET", "/model_group/info", {"data": []}),
(["users", "list"], "GET", "/user/list", {"users": []}),
(["teams", "list"], "GET", "/team/list", []),
(["teams", "available"], "GET", "/team/available", []),
(["credentials", "list", "--format", "json"], "GET", "/credentials", {"credentials": []}),
],
)
def test_every_proxy_command_group_signs_in_on_a_401_and_reruns_with_the_new_key(
requests_mock, secret_vault_factory, command, method, path, body
):
route = requests_mock.register_uri(
method, f"{BASE_URL}{path}", [{"status_code": 401, "json": REJECTED}, {"status_code": 200, "json": body}]
)
sign_in = FakeBrowserSignIn(_credential())
result = _lite(command, secret_vault_factory(), sign_in)
assert result.exit_code == 0, result.output
assert sign_in.calls == [BASE_URL]
assert _bearers(route) == [None, f"Bearer {SIGNED_IN_KEY}"]
def test_models_import_signs_in_instead_of_reporting_models_it_never_added(
requests_mock, secret_vault_factory, tmp_path: Path
):
models_file = tmp_path / "models.yaml"
models_file.write_text(
"model_list:\n - model_name: gpt-5.4-mini\n litellm_params:\n model: openai/gpt-5.4-mini\n"
)
route = requests_mock.post(
f"{BASE_URL}/model/new", [{"status_code": 401, "json": REJECTED}, {"status_code": 200, "json": {}}]
)
sign_in = FakeBrowserSignIn(_credential())
result = _lite(["models", "import", str(models_file)], secret_vault_factory(), sign_in)
assert result.exit_code == 0, result.output
assert sign_in.calls == [BASE_URL]
assert _bearers(route) == [None, f"Bearer {SIGNED_IN_KEY}"]
def test_a_401_partway_through_models_import_stops_without_signing_in_or_adding_models_twice(
requests_mock, secret_vault_factory, tmp_path: Path
):
models_file = tmp_path / "models.yaml"
models_file.write_text(
"model_list:\n"
" - model_name: gpt-5.4-mini\n litellm_params:\n model: openai/gpt-5.4-mini\n"
" - model_name: gpt-5.4\n litellm_params:\n model: openai/gpt-5.4\n"
)
route = requests_mock.post(
f"{BASE_URL}/model/new", [{"status_code": 200, "json": {}}, {"status_code": 401, "json": REJECTED}]
)
sign_in = FakeBrowserSignIn(_credential())
result = _lite(["models", "import", str(models_file)], secret_vault_factory(), sign_in)
assert result.exit_code == 1
assert isinstance(result.exception, SystemExit)
assert "rejected the API key after 1 of 2 models were sent" in result.stderr
assert sign_in.calls == []
assert [request.json()["model_name"] for request in route.request_history] == ["gpt-5.4-mini", "gpt-5.4"]
def test_a_401_from_the_keys_import_source_never_signs_in_to_the_destination(requests_mock, secret_vault_factory):
source = "https://source.example.com"
requests_mock.get(f"{source}/key/list", status_code=401, json=REJECTED)
sign_in = FakeBrowserSignIn(_credential())
result = _lite(
["keys", "import", "--source-base-url", source, "--source-api-key", "sk-source"],
secret_vault_factory(),
sign_in,
)
assert result.exit_code != 0
assert sign_in.calls == []
assert {request.netloc for request in requests_mock.request_history} == {"source.example.com"}
@pytest.mark.parametrize(
("global_args", "env"),
[(["--api-key", "sk-explicit"], {}), ([], {"LITELLM_PROXY_API_KEY": "sk-explicit"})],
ids=["flag", "env"],
)
def test_a_rejected_explicit_key_fails_cleanly_without_opening_a_browser(
requests_mock, secret_vault_factory, monkeypatch: pytest.MonkeyPatch, global_args, env
):
for name, value in env.items():
monkeypatch.setenv(name, value)
route = requests_mock.post(f"{BASE_URL}/key/generate", status_code=401, json=REJECTED)
_key_check(requests_mock, 401)
vault = secret_vault_factory()
sign_in = FakeBrowserSignIn(_credential())
result = _lite(["keys", "generate"], vault, sign_in, global_args)
assert result.exit_code == 1
assert isinstance(result.exception, SystemExit)
assert "rejected the API key from --api-key or LITELLM_PROXY_API_KEY" in result.stderr
assert sign_in.calls == []
assert _bearers(route) == ["Bearer sk-explicit"]
assert vault.writes == []
def test_a_failed_sign_in_names_the_reason_and_points_at_lite_login(requests_mock, secret_vault_factory):
route = requests_mock.post(f"{BASE_URL}/key/generate", status_code=401, json=REJECTED)
vault = secret_vault_factory()
sign_in = FakeBrowserSignIn(PkceFailure("timed out waiting for the browser sign-in to finish"))
result = _lite(["keys", "generate"], vault, sign_in)
assert result.exit_code == 1
assert isinstance(result.exception, SystemExit)
assert "timed out waiting for the browser sign-in to finish" in result.stderr
assert "Run `lite login` from a terminal, or set LITELLM_PROXY_API_KEY." in result.stderr
assert sign_in.calls == [BASE_URL]
assert route.call_count == 1
assert get_stored_api_key(BASE_URL, vault=vault) is None
def test_a_signed_in_key_the_proxy_also_rejects_is_not_followed_by_another_sign_in(requests_mock, secret_vault_factory):
route = requests_mock.post(f"{BASE_URL}/key/generate", status_code=401, json=REJECTED)
sign_in = FakeBrowserSignIn(_credential())
result = _lite(["keys", "generate"], secret_vault_factory(), sign_in)
assert result.exit_code == 1
assert isinstance(result.exception, SystemExit)
assert "refused `cli keys generate` with HTTP 401 again after the browser sign-in" in result.stderr
assert sign_in.calls == [BASE_URL]
assert _bearers(route) == [None, f"Bearer {SIGNED_IN_KEY}"]
def _rejected_while_a_sibling_signs_in(vault):
def respond(request, context):
save_token(_classic_login("sk-sibling"), vault=vault)
context.status_code = 401
return REJECTED
return respond
def test_a_key_a_sibling_process_stored_meanwhile_is_used_before_any_browser(requests_mock, secret_vault_factory):
vault = secret_vault_factory()
save_token(_classic_login("sk-stale"), vault=vault)
_key_check(requests_mock, 401)
route = requests_mock.post(
f"{BASE_URL}/key/generate",
[{"json": _rejected_while_a_sibling_signs_in(vault)}, {"status_code": 200, "json": GENERATED}],
)
sign_in = FakeBrowserSignIn(_credential())
result = _lite(["keys", "generate"], vault, sign_in)
assert result.exit_code == 0, result.output
assert json.loads(result.stdout) == GENERATED
assert sign_in.calls == []
assert _bearers(route) == ["Bearer sk-stale", "Bearer sk-sibling"]
def test_a_sibling_key_the_proxy_also_rejects_falls_through_to_one_sign_in(requests_mock, secret_vault_factory):
vault = secret_vault_factory()
save_token(_classic_login("sk-stale"), vault=vault)
_key_check(requests_mock, 401)
route = requests_mock.post(
f"{BASE_URL}/key/generate",
[
{"json": _rejected_while_a_sibling_signs_in(vault)},
{"status_code": 401, "json": REJECTED},
{"status_code": 200, "json": GENERATED},
],
)
sign_in = FakeBrowserSignIn(_credential())
result = _lite(["keys", "generate"], vault, sign_in)
assert result.exit_code == 0, result.output
assert sign_in.calls == [BASE_URL]
assert _bearers(route) == ["Bearer sk-stale", "Bearer sk-sibling", f"Bearer {SIGNED_IN_KEY}"]
assert get_stored_api_key(BASE_URL, vault=vault) == SIGNED_IN_KEY
@pytest.mark.parametrize("explicit", [False, True], 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
):
vault = secret_vault_factory()
save_token(_classic_login("sk-member"), vault=vault)
route = requests_mock.get(f"{BASE_URL}/credentials", status_code=401, json=REJECTED)
key_check = _key_check(requests_mock, 200)
sign_in = FakeBrowserSignIn(_credential())
writes_before = list(vault.writes)
result = _lite(["credentials", "list"], vault, sign_in, ["--api-key", "sk-member"] if explicit else [])
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 sign_in.calls == []
assert route.call_count == 1
assert _bearers(key_check) == ["Bearer sk-member"]
assert vault.writes == writes_before
assert "sk-member" not in result.output
def test_help_never_signs_in(requests_mock, secret_vault_factory):
sign_in = FakeBrowserSignIn(_credential())
result = _lite(["keys", "generate", "--help"], secret_vault_factory(), sign_in)
assert result.exit_code == 0
assert "Generate a new API key" in result.stdout
assert sign_in.calls == []
assert requests_mock.call_count == 0
@pytest.mark.parametrize("status", [403, 500])
def test_an_error_other_than_401_never_signs_in(requests_mock, secret_vault_factory, status):
route = requests_mock.post(f"{BASE_URL}/key/generate", status_code=status, json={"error": "refused"})
sign_in = FakeBrowserSignIn(_credential())
result = _lite(["keys", "generate"], secret_vault_factory(), sign_in)
assert result.exit_code == 1
assert f"Error: HTTP {status}" in result.stderr
assert sign_in.calls == []
assert route.call_count == 1