mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
feat(cli): make the hidden lite command list configurable (#36816)
* fix(cli): hide codex and opencode from the lite command listings They stay registered and invokable, so existing `lite codex` users keep working; they just no longer show up in `lite --help` or the interactive shell's command list. * feat(cli): make the hidden lite command list configurable codex and opencode are supported, so hardcoding them as hidden was wrong. Let deployments curate their own listing with `lite config set hidden_commands codex,opencode` instead; nothing is hidden by default and hidden commands stay invokable. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
83e890bdde
commit
04f5dedf69
6 changed files with 164 additions and 17 deletions
|
|
@ -36,6 +36,17 @@ The base URL is resolved in this order of precedence:
|
|||
3. `base_url` from `~/.litellm/config.json`
|
||||
4. `http://localhost:4000`
|
||||
|
||||
### 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:
|
||||
|
||||
```bash
|
||||
lite config set hidden_commands codex,opencode
|
||||
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
|
||||
|
||||
## Global Options
|
||||
|
||||
- `--version`, `-v`: Print the LiteLLM Proxy client and server version and exit.
|
||||
|
|
|
|||
|
|
@ -366,9 +366,9 @@ def _make_agent_command(binary: str, display_name: str) -> click.Command:
|
|||
return _command
|
||||
|
||||
|
||||
def agent_commands() -> list[click.Command]:
|
||||
def agent_commands() -> tuple[click.Command, ...]:
|
||||
"""Build one top-level command per known agent, e.g. `lite claude`."""
|
||||
return [_make_agent_command(binary, name) for binary, (name, _profiles) in _KNOWN_AGENTS.items()]
|
||||
return tuple(_make_agent_command(binary, name) for binary, (name, _profiles) in _KNOWN_AGENTS.items())
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Callable, Mapping
|
||||
from pathlib import Path
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
|
@ -11,7 +12,7 @@ from pydantic import TypeAdapter
|
|||
|
||||
from .private_json import write_private_json
|
||||
|
||||
ALLOWED_CONFIG_KEYS: Final[tuple[str, ...]] = ("base_url",)
|
||||
HIDDEN_COMMANDS_KEY: Final = "hidden_commands"
|
||||
|
||||
_config_adapter: Final[TypeAdapter[Mapping[str, str]]] = TypeAdapter(Mapping[str, str])
|
||||
|
||||
|
|
@ -49,6 +50,48 @@ def get_config_value(key: str) -> str | None:
|
|||
return load_config().get(key)
|
||||
|
||||
|
||||
def parse_hidden_commands(raw: str | None) -> frozenset[str]:
|
||||
"""Split a stored `hidden_commands` value, e.g. "codex, opencode"."""
|
||||
return frozenset(name.strip() for name in (raw or "").split(",") if name.strip())
|
||||
|
||||
|
||||
def hidden_command_names() -> frozenset[str]:
|
||||
"""Top-level commands the operator chose to keep out of `lite`'s listings."""
|
||||
return parse_hidden_commands(get_config_value(HIDDEN_COMMANDS_KEY))
|
||||
|
||||
|
||||
def _normalize_base_url(value: str) -> str:
|
||||
parsed: Final = urlparse(value)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||
raise click.UsageError("base_url must be a full http:// or https:// URL including a host")
|
||||
if "?" in value or "#" in value:
|
||||
raise click.UsageError("base_url must not include a query string or fragment")
|
||||
return value.rstrip("/")
|
||||
|
||||
|
||||
def _normalize_hidden_commands(value: str) -> str:
|
||||
names: Final = parse_hidden_commands(value)
|
||||
if not names:
|
||||
raise click.UsageError(
|
||||
f"{HIDDEN_COMMANDS_KEY} must be a comma-separated list of command names, e.g. "
|
||||
f"`lite config set {HIDDEN_COMMANDS_KEY} codex,opencode`. To list everything again, "
|
||||
f"run `lite config unset {HIDDEN_COMMANDS_KEY}`"
|
||||
)
|
||||
if any(" " in name for name in names):
|
||||
raise click.UsageError(f"{HIDDEN_COMMANDS_KEY} entries must be single command names, without spaces")
|
||||
return ",".join(sorted(names))
|
||||
|
||||
|
||||
_NORMALIZERS: Final[Mapping[str, Callable[[str], str]]] = MappingProxyType(
|
||||
{
|
||||
"base_url": _normalize_base_url,
|
||||
HIDDEN_COMMANDS_KEY: _normalize_hidden_commands,
|
||||
}
|
||||
)
|
||||
|
||||
ALLOWED_CONFIG_KEYS: Final[tuple[str, ...]] = tuple(_NORMALIZERS)
|
||||
|
||||
|
||||
@click.group(name="config")
|
||||
def config_commands() -> None:
|
||||
"""Manage persistent CLI configuration (~/.litellm/config.json)"""
|
||||
|
|
@ -59,17 +102,11 @@ def config_commands() -> None:
|
|||
@click.argument("value")
|
||||
def set_config(key: str, value: str) -> None:
|
||||
"""Set a config KEY to VALUE (e.g. `lite config set base_url https://your-proxy.example.com`)"""
|
||||
if key not in ALLOWED_CONFIG_KEYS:
|
||||
normalizer: Final = _NORMALIZERS.get(key)
|
||||
if normalizer is None:
|
||||
raise click.UsageError(f"Unknown config key '{key}'. Allowed keys: {', '.join(ALLOWED_CONFIG_KEYS)}")
|
||||
|
||||
if key == "base_url":
|
||||
parsed: Final = urlparse(value)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||
raise click.UsageError("base_url must be a full http:// or https:// URL including a host")
|
||||
if "?" in value or "#" in value:
|
||||
raise click.UsageError("base_url must not include a query string or fragment")
|
||||
|
||||
normalized_value: Final = value.rstrip("/")
|
||||
normalized_value: Final = normalizer(value)
|
||||
save_config({**load_config(), key: normalized_value})
|
||||
click.echo(f"Set {key} = {normalized_value} in {get_config_file_path()}")
|
||||
|
||||
|
|
|
|||
|
|
@ -74,8 +74,9 @@ def styled_prompt():
|
|||
|
||||
|
||||
def show_commands():
|
||||
"""Display available commands."""
|
||||
"""Display available commands, minus any the operator chose to hide."""
|
||||
from .commands.agents import agent_commands
|
||||
from .commands.config import hidden_command_names
|
||||
|
||||
commands = [
|
||||
("login", "Authenticate with the LiteLLM proxy server"),
|
||||
|
|
@ -96,9 +97,12 @@ def show_commands():
|
|||
("quit", "Exit the interactive session"),
|
||||
]
|
||||
|
||||
hidden: Final = hidden_command_names()
|
||||
|
||||
click.echo("Available commands:")
|
||||
for cmd, description in commands:
|
||||
click.echo(f" {cmd:<20} {description}")
|
||||
if cmd not in hidden:
|
||||
click.echo(f" {cmd:<20} {description}")
|
||||
click.echo()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from .commands.agents import agent_commands
|
|||
from .commands.auth import auth_group, get_stored_api_key, login, logout, whoami
|
||||
from .commands.autoroute.commands import autoroute_group
|
||||
from .commands.chat import chat
|
||||
from .commands.config import config_commands, get_config_value
|
||||
from .commands.config import config_commands, get_config_value, hidden_command_names
|
||||
from .commands.credentials import credentials
|
||||
from .commands.encryption import encryption
|
||||
from .commands.http import http
|
||||
|
|
@ -43,7 +43,21 @@ def print_version(base_url: str, api_key: str | None):
|
|||
click.echo(f"Could not retrieve server version: {e}")
|
||||
|
||||
|
||||
@click.group(invoke_without_command=True)
|
||||
class HideConfiguredCommandsGroup(click.Group):
|
||||
"""Group that omits operator-hidden commands from listings, still running them.
|
||||
|
||||
Deployments hand `lite` to users who should only see a curated subset of
|
||||
commands (`lite config set hidden_commands codex,opencode`). Filtering the
|
||||
listing rather than dropping the commands keeps anyone's existing scripts
|
||||
working.
|
||||
"""
|
||||
|
||||
def list_commands(self, ctx: click.Context) -> list[str]:
|
||||
hidden: Final = hidden_command_names()
|
||||
return [name for name in super().list_commands(ctx) if name not in hidden]
|
||||
|
||||
|
||||
@click.group(cls=HideConfiguredCommandsGroup, invoke_without_command=True)
|
||||
@click.option(
|
||||
"--version",
|
||||
"-v",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import os
|
|||
import stat
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
|
@ -18,6 +19,7 @@ from litellm.proxy.client.cli.commands.config import (
|
|||
save_config,
|
||||
)
|
||||
from litellm.proxy.client.cli.commands.private_json import write_private_json
|
||||
from litellm.proxy.client.cli.interface import show_commands
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -179,6 +181,85 @@ class TestConfigUnset:
|
|||
assert "not set" in result.output.lower()
|
||||
|
||||
|
||||
class TestHiddenCommands:
|
||||
"""`hidden_commands` lets a deployment curate what `lite` advertises.
|
||||
|
||||
Two listings exist and both must honor it: click's own `--help` table and the
|
||||
hand-rolled block the interactive shell prints.
|
||||
"""
|
||||
|
||||
def test_nothing_is_hidden_by_default(self, cli_runner, isolated_home):
|
||||
result = cli_runner.invoke(cli, ["--help"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "codex" in result.output
|
||||
assert "opencode" in result.output
|
||||
|
||||
def test_configured_commands_drop_out_of_help(self, cli_runner, isolated_home):
|
||||
assert cli_runner.invoke(cli, ["config", "set", "hidden_commands", "codex,opencode"]).exit_code == 0
|
||||
|
||||
result = cli_runner.invoke(cli, ["--help"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "claude" in result.output
|
||||
assert "codex" not in result.output
|
||||
assert "opencode" not in result.output
|
||||
|
||||
def test_configured_commands_drop_out_of_interactive_listing(self, capsys, isolated_home):
|
||||
save_config({"hidden_commands": "codex,keys"})
|
||||
|
||||
show_commands()
|
||||
listing = capsys.readouterr().out
|
||||
|
||||
assert "claude" in listing
|
||||
assert "codex" not in listing
|
||||
assert "keys" not in listing
|
||||
assert "teams" in listing
|
||||
|
||||
def test_hidden_commands_are_still_invokable(self, cli_runner, isolated_home):
|
||||
"""Hiding is about the listing only; anyone already scripting the command keeps working."""
|
||||
save_config({"hidden_commands": "codex"})
|
||||
|
||||
with patch("litellm.proxy.client.cli.commands.agents.run_agent") as run_agent_mock:
|
||||
result = cli_runner.invoke(
|
||||
cli,
|
||||
["--base-url", "http://localhost:4000", "--api-key", "sk-key", "codex", "exec", "do a thing"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
_base_url, _api_key, command = run_agent_mock.call_args.args
|
||||
assert list(command) == ["codex", "exec", "do a thing"]
|
||||
|
||||
def test_unset_brings_the_commands_back(self, cli_runner, isolated_home):
|
||||
assert cli_runner.invoke(cli, ["config", "set", "hidden_commands", "codex"]).exit_code == 0
|
||||
assert cli_runner.invoke(cli, ["config", "unset", "hidden_commands"]).exit_code == 0
|
||||
|
||||
assert "codex" in cli_runner.invoke(cli, ["--help"]).output
|
||||
|
||||
def test_set_normalizes_whitespace_and_ordering(self, cli_runner, isolated_home):
|
||||
result = cli_runner.invoke(cli, ["config", "set", "hidden_commands", " opencode , codex ,"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert json.loads(_config_path(isolated_home).read_text()) == {"hidden_commands": "codex,opencode"}
|
||||
|
||||
@pytest.mark.parametrize("value", ["", " ", ",", " , "])
|
||||
def test_set_empty_list_rejected(self, cli_runner, isolated_home, value):
|
||||
"""An empty value would silently hide nothing; point users at `config unset` instead."""
|
||||
result = cli_runner.invoke(cli, ["config", "set", "hidden_commands", value])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "unset" in result.output
|
||||
assert not _config_path(isolated_home).exists()
|
||||
|
||||
def test_set_space_separated_list_rejected(self, cli_runner, isolated_home):
|
||||
"""`lite config set hidden_commands "codex opencode"` would hide neither."""
|
||||
result = cli_runner.invoke(cli, ["config", "set", "hidden_commands", "codex opencode"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "without spaces" in result.output
|
||||
assert not _config_path(isolated_home).exists()
|
||||
|
||||
|
||||
class TestConfigHelpers:
|
||||
def test_get_config_file_path_under_home(self, isolated_home):
|
||||
assert get_config_file_path() == str(isolated_home / ".litellm" / "config.json")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue