mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
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:
parent
6fe1e73699
commit
581f5c319e
8 changed files with 726 additions and 49 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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]]:
|
||||
|
|
|
|||
108
litellm/proxy/client/cli/commands/config.py
Normal file
108
litellm/proxy/client/cli/commands/config.py
Normal 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()}")
|
||||
20
litellm/proxy/client/cli/commands/private_json.py
Normal file
20
litellm/proxy/client/cli/commands/private_json.py
Normal 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)
|
||||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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-*")) == []
|
||||
|
|
|
|||
284
tests/test_litellm/proxy/client/cli/test_config_commands.py
Normal file
284
tests/test_litellm/proxy/client/cli/test_config_commands.py
Normal 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-*")) == []
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue