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:
Yassin Kortam 2026-08-13 17:23:03 -07:00 committed by GitHub
parent 83e890bdde
commit 04f5dedf69
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 164 additions and 17 deletions

View file

@ -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.

View file

@ -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__ = [

View file

@ -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()}")

View file

@ -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()

View file

@ -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",

View file

@ -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")