feat(cli): read base_url from persistent config file (#35015)

* feat(cli): read base_url from persistent config file

Adds a lite config command group (set/get/unset) backed by
~/.litellm/config.json so users no longer need to export
LITELLM_PROXY_URL in every shell session. Resolution order is
--base-url flag, then LITELLM_PROXY_URL, then the config file,
then the localhost default. A config-file base_url counts as an
explicit server choice for lite auth print-token, matching the
env var semantics it replaces.

* fix(cli): harden config persistence after review feedback

Rejects base_url values containing a query string or fragment,
including bare trailing ? or # which parse as empty but still
corrupt every joined request URL. Writes config.json and token.json
atomically through a shared write_private_json helper (0600 at
creation, fsync, os.replace) so an interrupted save can no longer
truncate the file or leave it world-readable. Warns on stderr when
an existing config file is invalid instead of silently ignoring it,
including invalid UTF-8. Resolves the eager --version flag through
the same env, config file, default chain as every other command,
and reads the config file once per invocation so base_url and
base_url_explicit always come from the same snapshot.

* fix(cli): resolve --version after option parsing

The eager --version callback ran before --base-url and --api-key were
parsed, so it could not see an explicitly named server. Combined with
the env fallback added for config-file support, that sent the resolved
API key to whichever server the config file pointed at even when the
user named a different one on the command line. Making the flag a
normal option and handling it in the group callback gives the version
request the same flag, env, config, default precedence as every other
command, and lets the stored-token lookup stay origin-checked.
This commit is contained in:
ryan-crabbe-berri 2026-07-29 18:25:05 -07:00 • committed by GitHub
parent 6fe1e73699
commit 581f5c319e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 726 additions and 49 deletions

View file

@ -10,11 +10,32 @@ uv tool install 'litellm[proxy]'
## Configuration
The CLI can be configured using environment variables or command-line options:
The CLI can be configured using environment variables, command-line options, or a persistent config file:
- `LITELLM_PROXY_URL`: Base URL of the LiteLLM proxy server (default: http://localhost:4000)
- `LITELLM_PROXY_API_KEY`: API key for authentication
To stop exporting `LITELLM_PROXY_URL` in every shell session, store the proxy URL once in `~/.litellm/config.json`:
```bash
lite config set base_url https://your-proxy.example.com
```
Manage the stored config with:
```bash
lite config get base_url # print the stored value
lite config get # print all stored config
lite config unset base_url # remove the stored value
```
The base URL is resolved in this order of precedence:
1. `--base-url` command-line option
2. `LITELLM_PROXY_URL` environment variable
3. `base_url` from `~/.litellm/config.json`
4. `http://localhost:4000`
## Global Options
- `--version`, `-v`: Print the LiteLLM Proxy client and server version and exit.
@ -581,6 +602,8 @@ The CLI respects the following environment variables:
- `LITELLM_PROXY_URL`: Base URL of the proxy server
- `LITELLM_PROXY_API_KEY`: API key for authentication
`LITELLM_PROXY_URL` takes precedence over a `base_url` stored via `lite config set`, and the `--base-url` option overrides both. See the Configuration section for the full precedence order.
## Examples
1. List all models in table format:

View file

@ -15,6 +15,8 @@ from rich.table import Table
from litellm.constants import CLI_JWT_EXPIRATION_HOURS
from litellm.litellm_core_utils.cli_token_utils import is_cli_token_fresh
from .private_json import write_private_json
# Token storage utilities
def get_token_file_path() -> str:
@ -27,11 +29,7 @@ def get_token_file_path() -> str:
def save_token(token_data: Dict[str, Any]) -> None:
"""Save token data to file"""
token_file = get_token_file_path()
with open(token_file, "w") as f:
json.dump(token_data, f, indent=2)
# Set file permissions to be readable only by owner
os.chmod(token_file, 0o600)
write_private_json(get_token_file_path(), token_data)
def load_token() -> Optional[Dict[str, Any]]:

View file

@ -0,0 +1,108 @@
import json
import os
import sys
from collections.abc import Mapping
from pathlib import Path
from urllib.parse import urlparse
import click
from pydantic import TypeAdapter
from .private_json import write_private_json
ALLOWED_CONFIG_KEYS: tuple[str, ...] = ("base_url",)
_config_adapter: TypeAdapter[Mapping[str, str]] = TypeAdapter(Mapping[str, str])
def get_config_file_path() -> str:
"""Get the path to the persistent CLI config file"""
home_dir = Path.home()
config_dir = home_dir / ".litellm"
return str(config_dir / "config.json")
def load_config() -> Mapping[str, str]:
"""Load CLI config from file; returns {} if missing or unreadable"""
try:
config_file = get_config_file_path()
except RuntimeError:
return {}
if not os.path.exists(config_file):
return {}
try:
with open(config_file, "r") as f:
return _config_adapter.validate_python(json.load(f))
except (OSError, ValueError) as e:
click.echo(f"Warning: ignoring invalid config file {config_file}: {e}", err=True)
return {}
def save_config(config: Mapping[str, str]) -> None:
"""Save CLI config to file"""
write_private_json(get_config_file_path(), config)
def get_config_value(key: str) -> str | None:
"""Get a single value from the persistent CLI config"""
return load_config().get(key)
@click.group(name="config")
def config_commands() -> None:
"""Manage persistent CLI configuration (~/.litellm/config.json)"""
@config_commands.command(name="set")
@click.argument("key")
@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:
raise click.UsageError(f"Unknown config key '{key}'. Allowed keys: {', '.join(ALLOWED_CONFIG_KEYS)}")
if key == "base_url":
parsed = 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 = value.rstrip("/")
save_config({**load_config(), key: normalized_value})
click.echo(f"Set {key} = {normalized_value} in {get_config_file_path()}")
@config_commands.command(name="get")
@click.argument("key", required=False)
def get_config(key: str | None) -> None:
"""Print the value of KEY, or all stored config when KEY is omitted"""
config = load_config()
if key is not None:
value = config.get(key)
if value is None:
click.echo(f"{key} is not set", err=True)
sys.exit(1)
click.echo(value)
return
if not config:
click.echo("(no config set)")
return
for entry_key, entry_value in config.items():
click.echo(f"{entry_key} = {entry_value}")
@config_commands.command(name="unset")
@click.argument("key")
def unset_config(key: str) -> None:
"""Remove KEY from the config file"""
config = load_config()
if key not in config:
click.echo(f"{key} was not set")
return
save_config({k: v for k, v in config.items() if k != key})
click.echo(f"Removed {key} from {get_config_file_path()}")

View file

@ -0,0 +1,20 @@
import json
import os
import tempfile
from collections.abc import Mapping
from pathlib import Path
def write_private_json(path: str, data: Mapping[str, object]) -> None:
"""Atomically write JSON to path with owner-only permissions (0600)"""
parent = Path(path).parent
parent.mkdir(parents=True, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(dir=str(parent), prefix=".tmp-", suffix=".json")
try:
with os.fdopen(fd, "w") as f:
json.dump(data, f, indent=2)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, path)
finally:
Path(tmp_path).unlink(missing_ok=True)

View file

@ -11,6 +11,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.credentials import credentials
from .commands.encryption import encryption
from .commands.http import http
@ -45,27 +46,16 @@ def print_version(base_url: str, api_key: Optional[str]):
@click.option(
"--version",
"-v",
"show_version",
is_flag=True,
is_eager=True,
expose_value=False,
help="Show the LiteLLM Proxy CLI and server version and exit.",
callback=lambda ctx, param, value: (
(
print_version(
ctx.params.get("base_url") or "http://localhost:4000",
ctx.params.get("api_key"),
)
or ctx.exit()
)
if value and not ctx.resilient_parsing
else None
),
)
@click.option(
"--base-url",
envvar="LITELLM_PROXY_URL",
show_envvar=True,
default="http://localhost:4000",
default=None,
show_default="base_url from `lite config`, else http://localhost:4000",
help="Base URL of the LiteLLM proxy server",
)
@click.option(
@ -75,13 +65,16 @@ def print_version(base_url: str, api_key: Optional[str]):
help="API key for authentication",
)
@click.pass_context
def cli(ctx: click.Context, base_url: str, api_key: Optional[str]) -> None:
def cli(ctx: click.Context, show_version: bool, base_url: str | None, api_key: Optional[str]) -> None:
"""LiteLLM Proxy CLI - Manage your LiteLLM proxy server"""
ctx.ensure_object(dict)
stored_base_url = get_config_value("base_url")
base_url_provided = base_url is not None
# 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 = base_url.rstrip("/")
base_url = ((stored_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.
@ -94,8 +87,13 @@ def cli(ctx: click.Context, base_url: str, api_key: Optional[str]) -> None:
# apiKeyHelper is invoked bare (no flags) -- commands that must work
# unattended (print-token) need to tell "user didn't say" apart from
# "user said localhost:4000 on purpose" so they can fall back to
# whatever server the stored token was actually issued for.
ctx.obj["base_url_explicit"] = ctx.get_parameter_source("base_url") != click.core.ParameterSource.DEFAULT
# whatever server the stored token was actually issued for. A base_url
# saved via `lite config set` counts as the user saying it.
ctx.obj["base_url_explicit"] = base_url_provided or bool(stored_base_url)
if show_version:
print_version(base_url, api_key)
ctx.exit()
# If no subcommand was invoked, start interactive mode
if ctx.invoked_subcommand is None:
@ -141,6 +139,7 @@ cli.add_command(down)
cli.add_command(model_groups)
# Add the autoroute command group (QA auto-routing against your real proxy)
cli.add_command(autoroute_group, name="autoroute")
cli.add_command(config_commands)
if __name__ == "__main__":

View file

@ -1,5 +1,6 @@
import json
import os
import stat
import sys
import time
from pathlib import Path
@ -12,6 +13,7 @@ import pytest
from click.testing import CliRunner
from litellm.constants import CLI_JWT_EXPIRATION_HOURS
from litellm.proxy.client.cli import cli
from litellm.proxy.client.cli.commands.auth import (
clear_token,
get_stored_api_key,
@ -201,31 +203,22 @@ class TestTokenUtilities:
mock_mkdir.assert_called_once_with(exist_ok=True)
def test_save_token(self):
def test_save_token(self, tmp_path):
"""Test saving token data to file"""
token_data = {
"key": "test-key",
"user_id": "test-user",
"timestamp": 1234567890,
}
token_file = tmp_path / "token.json"
with (
patch("builtins.open", mock_open()) as mock_file,
patch("litellm.proxy.client.cli.commands.auth.get_token_file_path") as mock_path,
patch("os.chmod") as mock_chmod,
):
mock_path.return_value = "/test/path/token.json"
with patch("litellm.proxy.client.cli.commands.auth.get_token_file_path") as mock_path:
mock_path.return_value = str(token_file)
save_token(token_data)
mock_file.assert_called_once_with("/test/path/token.json", "w")
mock_file().write.assert_called()
mock_chmod.assert_called_once_with("/test/path/token.json", 0o600)
# Verify JSON content was written correctly
written_content = "".join(call[0][0] for call in mock_file().write.call_args_list)
parsed_content = json.loads(written_content)
assert parsed_content == token_data
assert json.loads(token_file.read_text()) == token_data
assert stat.S_IMODE(token_file.stat().st_mode) == 0o600
def test_load_token_success(self):
"""Test loading token data from file successfully"""
@ -808,7 +801,8 @@ class TestPrintTokenCommand:
since there is no explicit target to check it against. `--base-url`/
`LITELLM_PROXY_URL` only enforces the match when a caller explicitly
passes it (tracked via ctx.obj["base_url_explicit"], set by the `cli`
group from click's ParameterSource).
group from click's ParameterSource); a base_url saved via
`lite config set` counts as explicit too.
"""
def setup_method(self):
@ -928,3 +922,110 @@ class TestPrintTokenCommand:
assert "sk-stale-key" not in result.output
assert "lite login" in result.output
mock_post.assert_not_called()
def _write_home_json(home: Path, filename: str, payload: dict[str, object]) -> None:
litellm_dir = home / ".litellm"
litellm_dir.mkdir(exist_ok=True)
(litellm_dir / filename).write_text(json.dumps(payload))
class TestPrintTokenWithConfigFile:
"""A config-file base_url is a drop-in replacement for exporting
LITELLM_PROXY_URL, so print-token must treat it as an explicit server
choice: a token minted for a different proxy is never handed out."""
@pytest.fixture
def isolated_home(self, monkeypatch, tmp_path):
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 test_config_base_url_mismatch_fails_closed(self, isolated_home):
_write_home_json(
isolated_home,
"token.json",
{"base_url": "https://server-a.example.com", "key": "sk-issued-for-a", "timestamp": time.time()},
)
_write_home_json(isolated_home, "config.json", {"base_url": "https://server-b.example.com"})
result = CliRunner().invoke(cli, ["auth", "print-token"])
assert result.exit_code == 1
assert "sk-issued-for-a" not in result.output
assert "Not authenticated for this server" in result.output
def test_config_base_url_match_prints_token(self, isolated_home):
_write_home_json(
isolated_home,
"token.json",
{"base_url": "https://server-a.example.com", "key": "sk-issued-for-a", "timestamp": time.time()},
)
_write_home_json(isolated_home, "config.json", {"base_url": "https://server-a.example.com"})
result = CliRunner().invoke(cli, ["auth", "print-token"])
assert result.exit_code == 0
assert result.stdout.strip() == "sk-issued-for-a"
def test_empty_config_base_url_treated_as_unset(self, isolated_home):
"""A hand-edited config.json with base_url "" must behave like no config at all:
base_url falls back to the default AND explicitness stays False."""
_write_home_json(
isolated_home,
"token.json",
{"base_url": "https://server-a.example.com", "key": "sk-issued-for-a", "timestamp": time.time()},
)
_write_home_json(isolated_home, "config.json", {"base_url": ""})
result = CliRunner().invoke(cli, ["auth", "print-token"])
assert result.exit_code == 0
assert result.stdout.strip() == "sk-issued-for-a"
def test_bare_invocation_without_config_file_unchanged(self, isolated_home):
"""No config file means base_url_explicit stays False, so the stored
token's own server is trusted (pre-config behavior must not regress)."""
_write_home_json(
isolated_home,
"token.json",
{"base_url": "https://server-a.example.com", "key": "sk-issued-for-a", "timestamp": time.time()},
)
result = CliRunner().invoke(cli, ["auth", "print-token"])
assert result.exit_code == 0
assert result.stdout.strip() == "sk-issued-for-a"
class TestSaveTokenPrivateWrite:
"""token.json holds the real API key: it must never be world-readable at any
instant, and a failed write must not destroy the previously stored token."""
@pytest.fixture
def isolated_home(self, monkeypatch, tmp_path):
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 test_save_token_owner_only_permissions_and_no_temp_leftovers(self, isolated_home):
save_token({"key": "sk-secret", "user_id": "u-1", "timestamp": 1234567890})
token_file = isolated_home / ".litellm" / "token.json"
assert json.loads(token_file.read_text()) == {"key": "sk-secret", "user_id": "u-1", "timestamp": 1234567890}
assert stat.S_IMODE(token_file.stat().st_mode) == 0o600
assert list(token_file.parent.glob(".tmp-*")) == []
def test_save_token_failure_mid_write_preserves_existing_token(self, isolated_home):
_write_home_json(isolated_home, "token.json", {"key": "sk-original", "timestamp": 1234567890})
token_file = isolated_home / ".litellm" / "token.json"
with pytest.raises(TypeError):
save_token({"key": object()})
assert json.loads(token_file.read_text()) == {"key": "sk-original", "timestamp": 1234567890}
assert list(token_file.parent.glob(".tmp-*")) == []

View file

@ -0,0 +1,284 @@
import json
import os
import stat
import sys
from pathlib import Path
import pytest
from click.testing import CliRunner
sys.path.insert(0, os.path.abspath("../../.."))
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.proxy.client.cli.commands.private_json import write_private_json
@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 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.proxy.client.cli.commands.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-*")) == []

View file

@ -1,4 +1,5 @@
# stdlib imports
import json
import os
import sys
from pathlib import Path
@ -7,9 +8,7 @@ from unittest.mock import Mock, patch
import pytest
from click.testing import CliRunner
sys.path.insert(
0, os.path.abspath("../../..")
) # Adds the parent directory to the system path
sys.path.insert(0, os.path.abspath("../../..")) # Adds the parent directory to the system path
import litellm.proxy.client.cli
@ -71,13 +70,9 @@ def test_base_url_trailing_slash_normalized(cli_runner):
) as mock_post,
patch("requests.get", side_effect=ValueError("stop after start request")),
):
cli_runner.invoke(
cli, ["--base-url", "https://gateway.litellm-sandbox.ai/", "login"]
)
cli_runner.invoke(cli, ["--base-url", "https://gateway.litellm-sandbox.ai/", "login"])
mock_post.assert_called_once_with(
"https://gateway.litellm-sandbox.ai/sso/cli/start", timeout=10
)
mock_post.assert_called_once_with("https://gateway.litellm-sandbox.ai/sso/cli/start", timeout=10)
def test_cli_version_command(cli_runner):
@ -94,3 +89,152 @@ def test_cli_version_command(cli_runner):
assert f"LiteLLM Proxy CLI Version: {litellm_version}" in result.output
assert "LiteLLM Proxy Server URL: http://localhost:4000" in result.output
assert "LiteLLM Proxy Server Version: 1.2.3" in result.output
@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 _write_config_file(home: Path, config: dict[str, str]) -> None:
config_dir = home / ".litellm"
config_dir.mkdir(exist_ok=True)
(config_dir / "config.json").write_text(json.dumps(config))
def _invoke_version(cli_runner: CliRunner, *args: str):
with patch(
"litellm.proxy.client.health.HealthManagementClient.get_server_version",
return_value="1.2.3",
):
return cli_runner.invoke(cli, [*args, "version"])
def test_base_url_read_from_config_file(cli_runner, isolated_home):
"""base_url precedence: flag > env > config file > default."""
_write_config_file(isolated_home, {"base_url": "https://config-proxy.example.com"})
result = _invoke_version(cli_runner)
assert result.exit_code == 0
assert "LiteLLM Proxy Server URL: https://config-proxy.example.com" in result.output
def test_env_var_beats_config_file_base_url(cli_runner, isolated_home, monkeypatch):
_write_config_file(isolated_home, {"base_url": "https://config-proxy.example.com"})
monkeypatch.setenv("LITELLM_PROXY_URL", "http://env-proxy.example.com:5000")
result = _invoke_version(cli_runner)
assert result.exit_code == 0
assert "LiteLLM Proxy Server URL: http://env-proxy.example.com:5000" in result.output
def test_base_url_flag_beats_env_var_and_config_file(cli_runner, isolated_home, monkeypatch):
_write_config_file(isolated_home, {"base_url": "https://config-proxy.example.com"})
monkeypatch.setenv("LITELLM_PROXY_URL", "http://env-proxy.example.com:5000")
result = _invoke_version(cli_runner, "--base-url", "http://flag-proxy.example.com:9000")
assert result.exit_code == 0
assert "LiteLLM Proxy Server URL: http://flag-proxy.example.com:9000" in result.output
def test_default_base_url_unchanged_without_config_file(cli_runner, isolated_home):
result = _invoke_version(cli_runner)
assert result.exit_code == 0
assert "LiteLLM Proxy Server URL: 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."""
config_dir = isolated_home / ".litellm"
config_dir.mkdir(exist_ok=True)
(config_dir / "config.json").write_text("{not json")
result = _invoke_version(cli_runner)
assert result.exit_code == 0
assert "LiteLLM Proxy Server URL: http://localhost:4000" in result.output
assert result.stderr.count("Warning: ignoring invalid config file") == 1
def test_empty_base_url_flag_is_not_treated_as_unset(cli_runner, isolated_home):
"""`--base-url ""` explicitly provided an (empty) value; falling back to the
config file or localhost would silently redirect auth-sensitive commands."""
_write_config_file(isolated_home, {"base_url": "https://config-proxy.example.com"})
result = _invoke_version(cli_runner, "--base-url", "")
assert result.exit_code == 0
assert "LiteLLM Proxy Server URL:" not in result.output
def test_version_flag_reads_config_file_base_url(cli_runner, isolated_home):
"""--version resolves through the same precedence chain as every other command."""
_write_config_file(isolated_home, {"base_url": "https://config-proxy.example.com"})
with patch(
"litellm.proxy.client.health.HealthManagementClient.get_server_version",
return_value="1.2.3",
):
result = cli_runner.invoke(cli, ["--version"])
assert result.exit_code == 0
assert "LiteLLM Proxy Server URL: https://config-proxy.example.com" in result.output
def test_version_flag_prefers_env_var_over_config_file(cli_runner, isolated_home, monkeypatch):
_write_config_file(isolated_home, {"base_url": "https://config-proxy.example.com"})
monkeypatch.setenv("LITELLM_PROXY_URL", "http://env-proxy.example.com:5000")
with patch(
"litellm.proxy.client.health.HealthManagementClient.get_server_version",
return_value="1.2.3",
):
result = cli_runner.invoke(cli, ["--version"])
assert result.exit_code == 0
assert "LiteLLM Proxy Server URL: http://env-proxy.example.com:5000" in result.output
def test_version_flag_prefers_explicit_base_url_over_config_file(cli_runner, isolated_home):
"""An eager --version could not see the flag and silently queried the config
server instead of the one the user named."""
_write_config_file(isolated_home, {"base_url": "https://config-proxy.example.com"})
with patch(
"litellm.proxy.client.health.HealthManagementClient.get_server_version",
return_value="1.2.3",
):
result = cli_runner.invoke(cli, ["--base-url", "https://flag-proxy.example.com", "--version"])
assert result.exit_code == 0
assert "LiteLLM Proxy Server URL: https://flag-proxy.example.com" in result.output
assert "config-proxy.example.com" not in result.output
def test_version_flag_never_sends_api_key_to_unnamed_server(cli_runner, isolated_home, monkeypatch):
"""The version request carries a bearer token; it must reach only the server the
user named, never whichever host happens to sit in the config file."""
_write_config_file(isolated_home, {"base_url": "https://config-proxy.example.com"})
monkeypatch.setenv("LITELLM_PROXY_API_KEY", "sk-intended-for-flag-proxy")
with patch("litellm.proxy.client.http_client.requests.request") as mock_request:
mock_request.return_value.json.return_value = {"litellm_version": "1.2.3"}
mock_request.return_value.raise_for_status.return_value = None
result = cli_runner.invoke(cli, ["--base-url", "https://flag-proxy.example.com", "--version"])
assert result.exit_code == 0
requested_urls = [call.kwargs["url"] for call in mock_request.call_args_list]
assert requested_urls
assert all(url.startswith("https://flag-proxy.example.com") for url in requested_urls)
sent_keys = [call.kwargs["headers"].get("Authorization") for call in mock_request.call_args_list]
assert sent_keys == ["Bearer sk-intended-for-flag-proxy"] * len(requested_urls)