diff --git a/litellm/proxy/client/cli/README.md b/litellm/proxy/client/cli/README.md index 8c05264c6e6..842705ad5a7 100644 --- a/litellm/proxy/client/cli/README.md +++ b/litellm/proxy/client/cli/README.md @@ -36,6 +36,8 @@ The base URL is resolved in this order of precedence: 3. `base_url` from `~/.litellm/config.json` 4. `http://localhost:4000` +The interactive shell you get from a bare `lite` puts one more step before `http://localhost:4000`: the server you last signed in to with `lite login`, unless you pass `--api-key` or set `LITELLM_PROXY_API_KEY`, since that key was not issued for it. One-shot commands such as `lite keys delete` never pick up that server on their own. Inside the shell, a `lite ...` line runs on the shell's server, with any key it names, unless it also names `--base-url`. + ### Hiding commands from the listings Deployments that hand `lite` to end users often want to advertise only part of it. Store the commands to keep out of the listings, comma separated: diff --git a/litellm/proxy/client/cli/commands/auth.py b/litellm/proxy/client/cli/commands/auth.py index 9e2e6966ed7..3c99f402054 100644 --- a/litellm/proxy/client/cli/commands/auth.py +++ b/litellm/proxy/client/cli/commands/auth.py @@ -191,26 +191,26 @@ def storage_notice(outcome: SecretSave) -> str: ) -def keychain_unreadable_notice(vault: SecretVault) -> str: +def keychain_unreadable_notice(vault: SecretVault, login_command: str = "lite login") -> str: """Explain why the secret half of a stored login cannot be produced, and what fixes it""" match vault.read(): case KeyringNotInstalled(): return ( "Your credential is in your OS keychain, which this install cannot read without the " - f"keyring package. Install it with: {KEYRING_INSTALL_HINT}, or run 'lite login' to start over." + f"keyring package. Install it with: {KEYRING_INSTALL_HINT}, or run '{login_command}' to start over." ) case KeyringDisabled(): return ( f"Your credential is in your OS keychain, which {DISABLE_KEYRING_ENV_VAR} is blocking. " - "Unset it, or run 'lite login' to start over." + f"Unset it, or run '{login_command}' to start over." ) case KeyringUnreachable(): return ( "Your credential is in your OS keychain, which could not be read. Unlock it, or run " - "'lite login' to start over." + f"'{login_command}' to start over." ) case SecretFound() | SecretMissing(): - return "Your credential could not be read from your OS keychain. Run 'lite login' to start over." + return f"Your credential could not be read from your OS keychain. Run '{login_command}' to start over." def context_secret_vault(ctx: click.Context) -> SecretVault: diff --git a/litellm/proxy/client/cli/interface.py b/litellm/proxy/client/cli/interface.py index b4f44240adb..08abd2febda 100644 --- a/litellm/proxy/client/cli/interface.py +++ b/litellm/proxy/client/cli/interface.py @@ -1,16 +1,23 @@ # stdlib imports +import functools import os -import sys -from typing import Final +import re +from typing import Final, TypeAlias # third party imports import click from litellm._logging import verbose_logger +from .commands.auth import context_secret_vault -def styled_prompt(): - """Create a styled blue box prompt for user input.""" +ShellScope: TypeAlias = tuple[str, ...] +SHELL_WORD: Final = re.compile(r"""(?P[\w-]+=)?(?P"[^"]*"|'[^']*')(?P\S*)|\S+""") +OPENING_QUOTE: Final = re.compile(r"""(?:[\w-]+=)?["']""") + + +def styled_prompt(scope: ShellScope = ()): + """Create a styled blue box prompt for user input, labelled with the group the shell is in.""" # Get terminal height to ensure we have enough space try: @@ -40,7 +47,7 @@ def styled_prompt(): # Create styled elements left_border: Final = click.style(vertical, fg="blue", bold=True) right_border: Final = click.style(vertical, fg="blue", bold=True) - prompt_text: Final = click.style("> ", fg="cyan", bold=True) + prompt_text: Final = click.style(" ".join((*scope, "> ")), fg="cyan", bold=True) # Display the complete box structure first to reserve space click.echo(click.style(top_line, fg="blue", bold=True)) @@ -139,35 +146,60 @@ def handle_special_commands(user_input: str) -> bool: return False -def execute_command(user_input: str, ctx: click.Context): - """Parse and execute a command.""" - # Parse command and arguments - parts: Final = user_input.split() - command: Final = parts[0] - args: Final = parts[1:] if len(parts) > 1 else [] +def _subgroup(group: click.Group | None, name: str) -> click.Group | None: + if group is None: + return None + child: Final = group.commands.get(name) + return child if isinstance(child, click.Group) else None - # Import cli here to avoid circular import - from . import main - cli: Final = main.cli +def _group_at(group: click.Group, path: ShellScope) -> click.Group | None: + """The group `path` names under `group`, or None when it names a plain command or nothing""" + return functools.reduce(_subgroup, path, group) - # Check if command exists - if command not in cli.commands: - click.echo(f"Unknown command: {command}") - click.echo("Type 'help' to see available commands.") - return - # Execute the command +def split_shell_line(line: str) -> ShellScope: + """Split on spaces and unwrap a quote that opens a word or follows its leading `name=`, keeping the rest as typed""" + words: Final = tuple(SHELL_WORD.finditer(line)) + if any(word["quoted"] is None and OPENING_QUOTE.match(word.group()) for word in words): + raise ValueError("No closing quotation") + return tuple( + word.group() if word["quoted"] is None else f"{word['prefix'] or ''}{word['quoted'][1:-1]}{word['rest']}" + for word in words + ) + + +def resolve_command_path(words: ShellScope, scope: ShellScope, cli: click.Group) -> ShellScope: + """The full command path a line names: from the top after a leading `lite`, else inside the shell's group""" + if words[0] == "lite": + return words[1:] + scope_group: Final = _group_at(cli, scope) + if scope and scope_group is not None and (words[0] in scope_group.commands or words[0].startswith("-")): + return (*scope, *words) + return words + + +def scope_after(path: ShellScope, scope: ShellScope, cli: click.Group) -> ShellScope: + """A group typed alone becomes the scope, a command run inside the scope keeps it, anything else leaves it""" + group: Final = _group_at(cli, path) + if path and group is not None and not group.invoke_without_command: + return path + return scope if path[: len(scope)] == scope else () + + +def execute_command(path: ShellScope, ctx: click.Context, cli: click.Group) -> None: + """Run a resolved command path as `lite` would, so its usage and errors name `lite `.""" try: - # Create a new argument list for click to parse - sys.argv = ["lite"] + [command] + args - - # Get the command object and invoke it - cmd: Final = cli.commands[command] - - # Create a new context for the subcommand + if path[0].startswith("-"): + cli.main( + ["--base-url", ctx.obj["base_url"], *path], + prog_name="lite", + obj={"secret_vault": context_secret_vault(ctx)}, + standalone_mode=False, + ) + return with ctx.scope(): - cmd.main(args, parent=ctx, standalone_mode=False) + cli.commands[path[0]].main(list(path[1:]), prog_name=path[0], parent=ctx, standalone_mode=False) except click.ClickException as e: e.show() @@ -180,9 +212,33 @@ def execute_command(user_input: str, ctx: click.Context): click.echo(f"Error executing command: {e}") -def interactive_shell(ctx: click.Context): +def run_shell_line(user_input: str, scope: ShellScope, ctx: click.Context, cli: click.Group) -> ShellScope: + """Run one line typed at the prompt and return the scope the next prompt starts in.""" + try: + words: Final = split_shell_line(user_input) + except ValueError as e: + click.echo(f"Could not read that command: {e}") + return scope + if not words: + return scope + + path: Final = resolve_command_path(words, scope, cli) + if not path: + show_commands() + return () + if path[0] not in cli.commands and not path[0].startswith("-"): + click.echo(f"Unknown command: {path[0]}") + click.echo("Type 'help' to see available commands.") + return scope + + execute_command(path, ctx, cli) + return scope_after(path, scope, cli) + + +def interactive_shell(ctx: click.Context, cli: click.Group): """Run the interactive shell.""" setup_shell(ctx) + scope: ShellScope = () # rebind-ok: a group typed alone stays the scope for the prompts after it while True: try: @@ -190,7 +246,7 @@ def interactive_shell(ctx: click.Context): click.echo("\n") # Extra spacing # Show styled prompt - user_input = styled_prompt() + user_input = styled_prompt(scope) if not user_input: continue @@ -199,10 +255,10 @@ def interactive_shell(ctx: click.Context): if handle_special_commands(user_input): if user_input.lower() in ["exit", "quit"]: break + scope = () continue - # Execute regular commands - execute_command(user_input, ctx) + scope = run_shell_line(user_input, scope, ctx, cli) except (KeyboardInterrupt, EOFError): click.echo("\nGoodbye!") diff --git a/litellm/proxy/client/cli/main.py b/litellm/proxy/client/cli/main.py index cbbf4247624..6af2887f14f 100644 --- a/litellm/proxy/client/cli/main.py +++ b/litellm/proxy/client/cli/main.py @@ -6,6 +6,7 @@ from typing import Final, Protocol import click from litellm._version import version as litellm_version +from litellm.litellm_core_utils.cli_token_utils import load_cli_token from litellm.proxy.client.health import HealthManagementClient from .commands.agents import agent_commands @@ -14,6 +15,7 @@ from .commands.auth import ( auth_group, context_secret_vault, get_stored_api_key, + keychain_unreadable_notice, login, logout, whoami, @@ -89,7 +91,7 @@ class HideConfiguredCommandsGroup(click.Group): envvar="LITELLM_PROXY_URL", show_envvar=True, default=None, - show_default="base_url from `lite config`, else http://localhost:4000", + show_default="base_url from `lite config`, else the `lite login` server for a bare `lite` with no key, else http://localhost:4000", help="Base URL of the LiteLLM proxy server", ) @click.option( @@ -105,10 +107,27 @@ def cli(ctx: click.Context, show_version: bool, base_url: str | None, api_key: s stored_base_url: Final = get_config_value("base_url") base_url_provided: Final = base_url is not None + shell: Final = ctx.invoked_subcommand is None and not show_version + login: Final = ( + load_cli_token(vault=context_secret_vault(ctx)) + if shell and base_url is None and not stored_base_url and api_key is None + else None + ) + login_base_url: Final = login.base_url if login is not None and login.key else None + if login is not None and login.base_url and not login.key: + click.secho( + keychain_unreadable_notice( + context_secret_vault(ctx), login_command=f"lite --base-url {login.base_url} login" + ), + fg="yellow", + err=True, + ) # Normalize once here so every downstream command (login, agents, http, ...) can safely # do f"{base_url}/some/path" without producing a double slash. - base_url = ((stored_base_url or "http://localhost:4000") if base_url is None else base_url).rstrip("/") + base_url = ( + (stored_base_url or login_base_url or "http://localhost:4000") if base_url is None else base_url + ).rstrip("/") # If no API key provided via flag or environment variable, try to load from saved token. # Pass base_url so we only use the stored key when it was issued for this server. @@ -137,7 +156,7 @@ def cli(ctx: click.Context, show_version: bool, base_url: str | None, api_key: s # If no subcommand was invoked, start interactive mode if ctx.invoked_subcommand is None: - interactive_shell(ctx) + interactive_shell(ctx, cli) @cli.command() diff --git a/tests/unit/proxy/client/cli/test_global_options.py b/tests/unit/proxy/client/cli/test_global_options.py index 96b200b3983..3b9f6820b69 100644 --- a/tests/unit/proxy/client/cli/test_global_options.py +++ b/tests/unit/proxy/client/cli/test_global_options.py @@ -151,6 +151,110 @@ def test_default_base_url_unchanged_without_config_file(cli_runner, isolated_hom assert "LiteLLM Proxy Server URL: http://localhost:4000" in result.output +def _write_login_token(home: Path, base_url: str, key: str | None) -> None: + token_dir = home / ".litellm" + token_dir.mkdir(exist_ok=True) + secret: Final = {} if key is None else {"key": key} + (token_dir / "token.json").write_text(json.dumps({"base_url": base_url, "user_id": "u-1", **secret})) + + +LOGIN_SERVER: Final = "https://login-gateway.example.com" +SHELL_VERSION_THEN_QUIT: Final = "version\nquit\n" + + +def test_bare_lite_connects_to_the_server_it_logged_in_to(cli_runner, isolated_home, requests_mock, secret_vault_factory): + _write_login_token(isolated_home, LOGIN_SERVER, "sk-from-login") + requests_mock.get(f"{LOGIN_SERVER}/health/readiness", json={"litellm_version": "1.2.3"}) + context: Final[dict[str, object]] = {"secret_vault": secret_vault_factory()} + + result = cli_runner.invoke(cli, [], obj=context, input=SHELL_VERSION_THEN_QUIT) + + assert f"Connected to LiteLLM server: {LOGIN_SERVER}" in result.output + assert f"LiteLLM Proxy Server URL: {LOGIN_SERVER}" in result.output + assert requests_mock.last_request.headers.get("Authorization") == "Bearer sk-from-login" + assert context["base_url_explicit"] is False + + +def test_a_one_shot_command_never_goes_to_the_logged_in_server( + cli_runner, isolated_home, requests_mock, secret_vault_factory +): + _write_login_token(isolated_home, LOGIN_SERVER, "sk-from-login") + requests_mock.get("http://localhost:4000/health/readiness", json={"litellm_version": "1.2.3"}) + + result = cli_runner.invoke(cli, ["version"], obj={"secret_vault": secret_vault_factory()}) + + assert result.exit_code == 0 + assert "LiteLLM Proxy Server URL: http://localhost:4000" in result.output + assert [request.url for request in requests_mock.request_history] == ["http://localhost:4000/health/readiness"] + + +@pytest.mark.parametrize( + ("args", "env", "config", "expected_base_url"), + [ + (["--base-url", "https://flag-proxy.example.com"], {}, {}, "https://flag-proxy.example.com"), + ([], {"LITELLM_PROXY_URL": "https://env-proxy.example.com"}, {}, "https://env-proxy.example.com"), + ([], {}, {"base_url": "https://config-proxy.example.com"}, "https://config-proxy.example.com"), + ], +) +def test_a_named_server_beats_the_logged_in_one_and_never_gets_its_key( + cli_runner, isolated_home, monkeypatch, requests_mock, secret_vault_factory, args, env, config, expected_base_url +): + _write_login_token(isolated_home, LOGIN_SERVER, "sk-from-login") + _write_config_file(isolated_home, config) + for name, value in env.items(): + monkeypatch.setenv(name, value) + requests_mock.get(f"{expected_base_url}/health/readiness", json={"litellm_version": "1.2.3"}) + + result = cli_runner.invoke(cli, args, obj={"secret_vault": secret_vault_factory()}, input=SHELL_VERSION_THEN_QUIT) + + assert f"Connected to LiteLLM server: {expected_base_url}" in result.output + assert f"LiteLLM Proxy Server URL: {expected_base_url}" in result.output + assert requests_mock.last_request.headers.get("Authorization") is None + + +@pytest.mark.parametrize( + ("args", "env"), + [ + (["--api-key", "sk-named-key"], {}), + ([], {"LITELLM_PROXY_API_KEY": "sk-named-key"}), + ], +) +def test_a_key_given_without_a_server_never_goes_to_the_logged_in_one( + cli_runner, isolated_home, monkeypatch, requests_mock, secret_vault_factory, args, env +): + _write_login_token(isolated_home, LOGIN_SERVER, "sk-from-login") + for name, value in env.items(): + monkeypatch.setenv(name, value) + requests_mock.get("http://localhost:4000/health/readiness", json={"litellm_version": "1.2.3"}) + + result = cli_runner.invoke(cli, args, obj={"secret_vault": secret_vault_factory()}, input=SHELL_VERSION_THEN_QUIT) + + assert "Connected to LiteLLM server: http://localhost:4000" in result.output + assert [request.url for request in requests_mock.request_history] == ["http://localhost:4000/health/readiness"] + assert requests_mock.last_request.headers.get("Authorization") == "Bearer sk-named-key" + + +def test_bare_lite_says_why_it_stays_on_localhost_when_the_keychain_is_locked( + cli_runner, isolated_home, secret_vault_factory +): + _write_login_token(isolated_home, LOGIN_SERVER, None) + + result = cli_runner.invoke(cli, [], obj={"secret_vault": secret_vault_factory(available=False)}, input="quit\n") + + assert "Connected to LiteLLM server: http://localhost:4000" in result.output + assert f"could not be read. Unlock it, or run 'lite --base-url {LOGIN_SERVER} login'" in result.output + + +def test_lite_goes_back_to_localhost_after_logout_without_a_keychain(cli_runner, isolated_home, secret_vault_factory): + _write_login_token(isolated_home, LOGIN_SERVER, "sk-from-login") + no_keychain: Final = secret_vault_factory(available=False) + cli_runner.invoke(cli, ["logout"], obj={"secret_vault": no_keychain}) + + result = cli_runner.invoke(cli, [], obj={"secret_vault": no_keychain}, input="quit\n") + + assert "Connected to LiteLLM server: http://localhost:4000" in result.output + + def test_corrupt_config_file_falls_back_to_default(cli_runner, isolated_home): """A corrupt config file must never crash the CLI. Exactly one warning proves the config file is read once per invocation, not once per lookup.""" diff --git a/tests/unit/proxy/client/cli/test_interface.py b/tests/unit/proxy/client/cli/test_interface.py new file mode 100644 index 00000000000..afbdd8bc3cf --- /dev/null +++ b/tests/unit/proxy/client/cli/test_interface.py @@ -0,0 +1,183 @@ +from collections.abc import Mapping +from typing import Final + +import click +import pytest +from click.testing import CliRunner, Result + +from litellm.proxy.client.cli import cli +from litellm.proxy.client.cli.interface import run_shell_line + +GATEWAY: Final = "https://gateway.example.com" + + +def _shell(*lines: str, env: Mapping[str, str] | None = None) -> Result: + """Type each line into a bare `lite` shell against the gateway, then quit""" + typed: Final = "".join(f"{line}\n" for line in (*lines, "quit")) + return CliRunner(env=env).invoke( + cli, ["--base-url", GATEWAY, "--api-key", "sk-shell"], input=typed, prog_name="lite" + ) + + +@pytest.fixture +def credentials_endpoint(requests_mock): + return requests_mock.get( + f"{GATEWAY}/credentials", + json={"credentials": [{"credential_name": "shared-openai", "credential_info": {}}]}, + ) + + +def test_a_group_typed_alone_prints_its_own_usage_and_opens_a_prompt_inside_it(credentials_endpoint): + result: Final = _shell("credentials", "list") + + assert "Usage: lite credentials [OPTIONS] COMMAND [ARGS]..." in result.output + assert "lite lite" not in result.output + assert "credentials > " in result.output + assert "Unknown command" not in result.output + assert credentials_endpoint.call_count == 1 + assert credentials_endpoint.last_request.headers["Authorization"] == "Bearer sk-shell" + + +@pytest.mark.parametrize( + "lines", + [ + ("credentials list",), + ("lite credentials list",), + ("credentials", "list"), + ("credentials", "credentials list"), + ("credentials", "lite credentials list"), + ], +) +def test_every_spelling_of_a_group_subcommand_runs_it(lines, credentials_endpoint): + result: Final = _shell(*lines) + + assert "Unknown command" not in result.output + assert "shared-openai" in result.output + assert credentials_endpoint.call_count == 1 + + +@pytest.mark.parametrize( + "create_line", + [ + """create shell-made --info '{"custom_llm_provider": "openai"}' --values '{"api_key": "YWJjZA==", "organization": "b3Jn"}'""", + """create shell-made --info {"custom_llm_provider":"openai"} --values {"api_key":"YWJjZA==","organization":"b3Jn"}""", + """create shell-made --info={"custom_llm_provider":"openai"} --values={"api_key":"YWJjZA==","organization":"b3Jn"}""", + """create shell-made --info='{"custom_llm_provider": "openai"}' --values='{"api_key": "YWJjZA==", "organization": "b3Jn"}'""", + ], +) +def test_quoted_and_compact_json_arguments_reach_the_gateway_as_typed(requests_mock, create_line): + create: Final = requests_mock.post(f"{GATEWAY}/credentials", json={"credential_name": "shell-made"}) + + _shell("credentials", create_line) + + assert create.call_count == 1 + assert create.last_request.json() == { + "credential_name": "shell-made", + "credential_info": {"custom_llm_provider": "openai"}, + "credential_values": {"api_key": "YWJjZA==", "organization": "b3Jn"}, + } + + +def test_an_unclosed_quote_runs_nothing_and_keeps_the_group(requests_mock, credentials_endpoint): + create: Final = requests_mock.post(f"{GATEWAY}/credentials", json={}) + + result: Final = _shell("credentials", "create shell-made --info '{", "list") + + assert "Could not read that command" in result.output + assert create.call_count == 0 + assert credentials_endpoint.call_count == 1 + + +def test_an_unknown_command_inside_a_group_keeps_the_group(credentials_endpoint): + result: Final = _shell("credentials", "frobnicate", "list") + + assert "Unknown command: frobnicate" in result.output + assert credentials_endpoint.call_count == 1 + + +@pytest.mark.parametrize("leaving_line", ["models list", "help", "lite"]) +def test_a_top_level_line_inside_a_group_leaves_the_group(leaving_line, requests_mock, credentials_endpoint): + requests_mock.get(f"{GATEWAY}/models", json={"data": []}) + + result: Final = _shell("credentials", leaving_line, "list") + + assert "Unknown command: list" in result.output + assert credentials_endpoint.call_count == 0 + + +def test_help_inside_a_group_shows_that_group_and_keeps_it(credentials_endpoint): + result: Final = _shell("credentials", "--help", "list") + + assert "Unknown command" not in result.output + assert result.output.count("Usage: lite credentials [OPTIONS] COMMAND [ARGS]...") == 2 + assert credentials_endpoint.call_count == 1 + + +def test_lite_with_its_own_options_inside_the_shell_runs_against_the_server_it_names( + requests_mock, credentials_endpoint +): + other: Final = requests_mock.get( + "https://other-gateway.example.com/credentials", json={"credentials": [{"credential_name": "other-openai"}]} + ) + + result: Final = _shell("lite --base-url https://other-gateway.example.com --api-key sk-other credentials list") + + assert "other-openai" in result.output + assert other.last_request.headers["Authorization"] == "Bearer sk-other" + assert credentials_endpoint.call_count == 0 + + +def test_a_lite_line_that_names_only_a_key_stays_on_the_shell_server_over_litellm_proxy_url(credentials_endpoint): + result: Final = _shell( + "lite --api-key sk-typed credentials list", env={"LITELLM_PROXY_URL": "https://elsewhere.example.com"} + ) + + assert "shared-openai" in result.output + assert credentials_endpoint.last_request.headers["Authorization"] == "Bearer sk-typed" + + +def test_a_group_that_runs_on_its_own_does_not_become_the_scope(capsys): + @click.group() + def root() -> None: + pass + + @root.group(invoke_without_command=True) + def wizard() -> None: + click.echo("wizard ran") + + @wizard.command() + def undo() -> None: + pass + + scope: Final = run_shell_line("wizard", (), click.Context(root, info_name="lite", obj={}), root) + + assert scope == () + assert "wizard ran" in capsys.readouterr().out + + +def test_a_line_splits_on_spaces_and_unwraps_quotes_that_open_a_word_or_follow_an_equals_sign(capsys): + @click.group() + def root() -> None: + pass + + @root.command() + @click.argument("words", nargs=-1) + def echo(words: tuple[str, ...]) -> None: + click.echo("\n".join(words)) + + run_shell_line( + r"""echo C:\Users\me\notes.txt "two words" '{"a": 1}' info='{"a": 1}' {"b":2} it's issue#1""", + (), + click.Context(root, info_name="lite", obj={}), + root, + ) + + assert capsys.readouterr().out.splitlines() == [ + r"C:\Users\me\notes.txt", + "two words", + '{"a": 1}', + 'info={"a": 1}', + '{"b":2}', + "it's", + "issue#1", + ]