mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
* test(proxy): move auth, hooks, policy_engine and client tests into tests/unit/proxy Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): stub HIBP through respx by disabling the aiohttp transport Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): share the httpx transport fixture across proxy unit tests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): restore proxy globals without a missing-value sentinel Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): package moved dirs and stub the login breach check at the HTTP boundary Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): isolate the mcp server manager per test Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: yuneng <yuneng@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
362 lines
15 KiB
Python
362 lines
15 KiB
Python
import json
|
|
import stat
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from click.testing import CliRunner
|
|
|
|
|
|
|
|
from litellm.proxy.client.cli import cli
|
|
from litellm.proxy.client.cli.commands.config import (
|
|
get_config_file_path,
|
|
get_config_value,
|
|
load_config,
|
|
save_config,
|
|
)
|
|
from litellm.litellm_core_utils.private_json import write_private_json
|
|
from litellm.proxy.client.cli.interface import show_commands
|
|
|
|
|
|
@pytest.fixture
|
|
def cli_runner():
|
|
return CliRunner()
|
|
|
|
|
|
@pytest.fixture
|
|
def isolated_home(monkeypatch, tmp_path):
|
|
"""Point HOME at tmp_path so tests never touch the developer's real ~/.litellm."""
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
|
monkeypatch.delenv("LITELLM_PROXY_URL", raising=False)
|
|
monkeypatch.delenv("LITELLM_PROXY_API_KEY", raising=False)
|
|
return tmp_path
|
|
|
|
|
|
def _config_path(home: Path) -> Path:
|
|
return home / ".litellm" / "config.json"
|
|
|
|
|
|
def _raise_home_unresolvable() -> str:
|
|
raise RuntimeError("Could not determine home directory.")
|
|
|
|
|
|
class TestConfigSet:
|
|
@pytest.mark.parametrize(
|
|
"value",
|
|
["https://your-proxy.example.com", "http://your-proxy.example.com:8080"],
|
|
)
|
|
def test_set_stores_value_with_owner_only_permissions(self, cli_runner, isolated_home, value):
|
|
result = cli_runner.invoke(cli, ["config", "set", "base_url", value])
|
|
|
|
assert result.exit_code == 0
|
|
config_file = _config_path(isolated_home)
|
|
assert json.loads(config_file.read_text()) == {"base_url": value}
|
|
assert stat.S_IMODE(config_file.stat().st_mode) == 0o600
|
|
assert str(config_file) in result.output
|
|
|
|
def test_set_strips_trailing_slash(self, cli_runner, isolated_home):
|
|
"""Downstream commands join paths onto base_url; a stored trailing
|
|
slash would produce double slashes in every request URL."""
|
|
result = cli_runner.invoke(cli, ["config", "set", "base_url", "https://your-proxy.example.com/"])
|
|
|
|
assert result.exit_code == 0
|
|
assert json.loads(_config_path(isolated_home).read_text()) == {"base_url": "https://your-proxy.example.com"}
|
|
|
|
def test_set_unknown_key_rejected_and_names_allowed_keys(self, cli_runner, isolated_home):
|
|
result = cli_runner.invoke(cli, ["config", "set", "api_key", "sk-secret"])
|
|
|
|
assert result.exit_code != 0
|
|
assert "base_url" in result.output
|
|
assert not _config_path(isolated_home).exists()
|
|
|
|
@pytest.mark.parametrize("value", ["your-proxy.example.com", "ftp://your-proxy.example.com"])
|
|
def test_set_base_url_without_http_scheme_rejected(self, cli_runner, isolated_home, value):
|
|
result = cli_runner.invoke(cli, ["config", "set", "base_url", value])
|
|
|
|
assert result.exit_code != 0
|
|
assert "http" in result.output
|
|
assert not _config_path(isolated_home).exists()
|
|
|
|
@pytest.mark.parametrize("value", ["https://", "http://", "https:///some-path"])
|
|
def test_set_base_url_without_host_rejected(self, cli_runner, isolated_home, value):
|
|
"""rstrip("/") would otherwise persist a bare "https:" that breaks every later request."""
|
|
result = cli_runner.invoke(cli, ["config", "set", "base_url", value])
|
|
|
|
assert result.exit_code != 0
|
|
assert not _config_path(isolated_home).exists()
|
|
|
|
@pytest.mark.parametrize(
|
|
"value",
|
|
[
|
|
"https://proxy.example.com?env=prod",
|
|
"https://proxy.example.com#prod",
|
|
"https://proxy.example.com/?",
|
|
"https://proxy.example.com/#",
|
|
],
|
|
)
|
|
def test_set_base_url_with_query_or_fragment_rejected(self, cli_runner, isolated_home, value):
|
|
"""Downstream commands join paths onto base_url; a stored query string or
|
|
fragment would silently corrupt every request URL built from it. Bare
|
|
trailing '?' / '#' parse as EMPTY query/fragment yet still break every
|
|
joined path, so rejection must key off the raw characters."""
|
|
result = cli_runner.invoke(cli, ["config", "set", "base_url", value])
|
|
|
|
assert result.exit_code != 0
|
|
assert "query" in result.output or "fragment" in result.output
|
|
assert not _config_path(isolated_home).exists()
|
|
|
|
def test_set_base_url_with_path_prefix_accepted(self, cli_runner, isolated_home):
|
|
"""Proxies are commonly served under a path prefix; the query/fragment
|
|
rejection must not over-reach into legitimate paths."""
|
|
result = cli_runner.invoke(cli, ["config", "set", "base_url", "https://proxy.example.com/litellm"])
|
|
|
|
assert result.exit_code == 0
|
|
assert json.loads(_config_path(isolated_home).read_text()) == {"base_url": "https://proxy.example.com/litellm"}
|
|
|
|
def test_set_leaves_no_temp_files_behind(self, cli_runner, isolated_home):
|
|
"""The atomic write goes through a .tmp-* sibling; it must be renamed away,
|
|
never abandoned next to the config."""
|
|
result = cli_runner.invoke(cli, ["config", "set", "base_url", "https://your-proxy.example.com"])
|
|
|
|
assert result.exit_code == 0
|
|
config_file = _config_path(isolated_home)
|
|
assert stat.S_IMODE(config_file.stat().st_mode) == 0o600
|
|
assert list(config_file.parent.glob(".tmp-*")) == []
|
|
|
|
|
|
class TestConfigGet:
|
|
def test_get_prints_only_the_value(self, cli_runner, isolated_home):
|
|
"""stdout must be exactly the value so scripts can do URL=$(lite config get base_url)."""
|
|
set_result = cli_runner.invoke(cli, ["config", "set", "base_url", "https://your-proxy.example.com"])
|
|
assert set_result.exit_code == 0
|
|
|
|
result = cli_runner.invoke(cli, ["config", "get", "base_url"])
|
|
|
|
assert result.exit_code == 0
|
|
assert result.stdout.strip() == "https://your-proxy.example.com"
|
|
|
|
def test_get_unset_key_exits_one_with_stderr_message(self, cli_runner, isolated_home):
|
|
result = cli_runner.invoke(cli, ["config", "get", "base_url"])
|
|
|
|
assert result.exit_code == 1
|
|
assert result.stdout.strip() == ""
|
|
assert result.stderr != ""
|
|
|
|
def test_get_without_key_lists_entries(self, cli_runner, isolated_home):
|
|
set_result = cli_runner.invoke(cli, ["config", "set", "base_url", "https://your-proxy.example.com"])
|
|
assert set_result.exit_code == 0
|
|
|
|
result = cli_runner.invoke(cli, ["config", "get"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "base_url = https://your-proxy.example.com" in result.output
|
|
|
|
def test_get_without_key_when_nothing_set(self, cli_runner, isolated_home):
|
|
result = cli_runner.invoke(cli, ["config", "get"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "no config" in result.output.lower()
|
|
|
|
|
|
class TestConfigUnset:
|
|
def test_unset_removes_key_from_file(self, cli_runner, isolated_home):
|
|
set_result = cli_runner.invoke(cli, ["config", "set", "base_url", "https://your-proxy.example.com"])
|
|
assert set_result.exit_code == 0
|
|
|
|
result = cli_runner.invoke(cli, ["config", "unset", "base_url"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "base_url" not in load_config()
|
|
assert cli_runner.invoke(cli, ["config", "get", "base_url"]).exit_code == 1
|
|
|
|
def test_unset_missing_key_is_idempotent(self, cli_runner, isolated_home):
|
|
result = cli_runner.invoke(cli, ["config", "unset", "base_url"])
|
|
|
|
assert result.exit_code == 0
|
|
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")
|
|
|
|
def test_load_config_missing_file_returns_empty(self, isolated_home):
|
|
assert load_config() == {}
|
|
|
|
def test_home_unresolvable_does_not_crash_cli(self, cli_runner, isolated_home, monkeypatch):
|
|
"""Path.home() raises RuntimeError in HOME-less containers; invocations that
|
|
never needed the home dir (--api-key supplied) must keep working."""
|
|
monkeypatch.setattr(
|
|
"litellm.proxy.client.cli.commands.config.get_config_file_path",
|
|
_raise_home_unresolvable,
|
|
)
|
|
|
|
assert load_config() == {}
|
|
|
|
result = cli_runner.invoke(cli, ["--api-key", "sk-test", "config", "get"])
|
|
assert result.exit_code == 0
|
|
assert "(no config set)" in result.output
|
|
|
|
@pytest.mark.parametrize(
|
|
"content",
|
|
[
|
|
"{not json",
|
|
'{"base_url": 123}',
|
|
'["https://your-proxy.example.com"]',
|
|
'"https://your-proxy.example.com"',
|
|
],
|
|
)
|
|
def test_load_config_invalid_content_returns_empty(self, isolated_home, content):
|
|
"""A corrupt or wrongly-shaped config file must degrade to defaults, never crash the CLI."""
|
|
config_file = _config_path(isolated_home)
|
|
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
config_file.write_text(content)
|
|
|
|
assert load_config() == {}
|
|
|
|
def test_load_config_invalid_utf8_returns_empty(self, isolated_home):
|
|
"""json.load raises UnicodeDecodeError (a ValueError but not a JSONDecodeError)
|
|
on undecodable bytes; before catching ValueError this crashed every CLI
|
|
invocation, including the `config set` needed to repair the file."""
|
|
config_file = _config_path(isolated_home)
|
|
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
config_file.write_bytes(b"\xff\xfe{}")
|
|
|
|
assert load_config() == {}
|
|
|
|
def test_save_config_round_trip_creates_dir_and_restricts_permissions(self, isolated_home):
|
|
save_config({"base_url": "https://your-proxy.example.com"})
|
|
|
|
assert load_config() == {"base_url": "https://your-proxy.example.com"}
|
|
assert stat.S_IMODE(_config_path(isolated_home).stat().st_mode) == 0o600
|
|
|
|
def test_get_config_value_unset_then_set(self, isolated_home):
|
|
assert get_config_value("base_url") is None
|
|
|
|
save_config({"base_url": "https://your-proxy.example.com"})
|
|
|
|
assert get_config_value("base_url") == "https://your-proxy.example.com"
|
|
|
|
def test_corrupt_config_file_warns_on_stderr_but_command_succeeds(self, cli_runner, isolated_home):
|
|
"""Silently ignoring a broken config file leaves users debugging why their
|
|
stored base_url stopped applying; the CLI must keep working but say why."""
|
|
config_file = _config_path(isolated_home)
|
|
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
config_file.write_text("{not json")
|
|
|
|
result = cli_runner.invoke(cli, ["config", "get"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "Warning: ignoring invalid config file" in result.stderr
|
|
|
|
|
|
class TestWritePrivateJson:
|
|
def test_failed_write_preserves_previous_file_and_removes_temp(self, tmp_path):
|
|
"""json.dump can fail partway through serializing; writing to a temp file
|
|
and renaming keeps the previous file intact through a crash mid-write."""
|
|
target = tmp_path / "config.json"
|
|
original = '{"base_url": "https://original.example.com"}'
|
|
target.write_text(original)
|
|
|
|
with pytest.raises(TypeError):
|
|
write_private_json(str(target), {"bad": object()})
|
|
|
|
assert target.read_text() == original
|
|
assert list(tmp_path.glob(".tmp-*")) == []
|
|
|
|
def test_interrupted_write_removes_temp_file(self, tmp_path, monkeypatch):
|
|
"""Ctrl-C is BaseException, which `except Exception` misses; an interrupt
|
|
mid-write must not abandon a .tmp-* file next to the config forever."""
|
|
|
|
def _interrupt(*args: object, **kwargs: object) -> None:
|
|
raise KeyboardInterrupt()
|
|
|
|
monkeypatch.setattr("litellm.litellm_core_utils.private_json.json.dump", _interrupt)
|
|
target = tmp_path / "config.json"
|
|
|
|
with pytest.raises(KeyboardInterrupt):
|
|
write_private_json(str(target), {"base_url": "https://your-proxy.example.com"})
|
|
|
|
assert not target.exists()
|
|
assert list(tmp_path.glob(".tmp-*")) == []
|