Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/client/cli/commands/config.py: 0%

97 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 12:01 +0000

1import json 

2import os 

3import sys 

4from collections.abc import Callable, Mapping 

5from pathlib import Path 

6from types import MappingProxyType 

7from typing import Final 

8from urllib.parse import urlparse 

9 

10import click 

11from pydantic import TypeAdapter 

12 

13from litellm.litellm_core_utils.private_json import ensure_private_dir, write_private_json 

14 

15HIDDEN_COMMANDS_KEY: Final = "hidden_commands" 

16 

17_config_adapter: Final[TypeAdapter[Mapping[str, str]]] = TypeAdapter(Mapping[str, str]) 

18 

19 

20def get_config_file_path() -> str: 

21 """Get the path to the persistent CLI config file""" 

22 home_dir: Final = Path.home() 

23 config_dir: Final = home_dir / ".litellm" 

24 return str(config_dir / "config.json") 

25 

26 

27def load_config() -> Mapping[str, str]: 

28 """Load CLI config from file; returns {} if missing or unreadable""" 

29 try: 

30 config_file: Final = get_config_file_path() 

31 except RuntimeError: 

32 return {} 

33 if not os.path.exists(config_file): 

34 return {} 

35 try: 

36 with open(config_file, "r") as f: 

37 return _config_adapter.validate_python(json.load(f)) 

38 except (OSError, ValueError) as e: 

39 click.echo(f"Warning: ignoring invalid config file {config_file}: {e}", err=True) 

40 return {} 

41 

42 

43def save_config(config: Mapping[str, str]) -> None: 

44 """Save CLI config to file""" 

45 config_file: Final = Path(get_config_file_path()) 

46 ensure_private_dir(config_file.parent) 

47 write_private_json(str(config_file), config) 

48 

49 

50def get_config_value(key: str) -> str | None: 

51 """Get a single value from the persistent CLI config""" 

52 return load_config().get(key) 

53 

54 

55def parse_hidden_commands(raw: str | None) -> frozenset[str]: 

56 """Split a stored `hidden_commands` value, e.g. "codex, opencode".""" 

57 return frozenset(name.strip() for name in (raw or "").split(",") if name.strip()) 

58 

59 

60def hidden_command_names() -> frozenset[str]: 

61 """Top-level commands the operator chose to keep out of `lite`'s listings.""" 

62 return parse_hidden_commands(get_config_value(HIDDEN_COMMANDS_KEY)) 

63 

64 

65def normalize_base_url(value: str) -> str: 

66 if any(ord(char) <= 32 or ord(char) == 127 for char in value): 

67 raise click.UsageError("base_url must not contain whitespace or control characters") 

68 parsed: Final = urlparse(value) 

69 if parsed.scheme not in ("http", "https") or not parsed.netloc: 

70 raise click.UsageError("base_url must be a full http:// or https:// URL including a host") 

71 if "?" in value or "#" in value: 

72 raise click.UsageError("base_url must not include a query string or fragment") 

73 if parsed.username is not None or parsed.password is not None: 

74 raise click.UsageError("base_url must not contain credentials; pass --api-key separately") 

75 return value.rstrip("/") 

76 

77 

78def _normalize_hidden_commands(value: str) -> str: 

79 names: Final = parse_hidden_commands(value) 

80 if not names: 

81 raise click.UsageError( 

82 f"{HIDDEN_COMMANDS_KEY} must be a comma-separated list of command names, e.g. " 

83 f"`lite config set {HIDDEN_COMMANDS_KEY} codex,opencode`. To list everything again, " 

84 f"run `lite config unset {HIDDEN_COMMANDS_KEY}`" 

85 ) 

86 if any(" " in name for name in names): 

87 raise click.UsageError(f"{HIDDEN_COMMANDS_KEY} entries must be single command names, without spaces") 

88 return ",".join(sorted(names)) 

89 

90 

91_NORMALIZERS: Final[Mapping[str, Callable[[str], str]]] = MappingProxyType( 

92 { 

93 "base_url": normalize_base_url, 

94 HIDDEN_COMMANDS_KEY: _normalize_hidden_commands, 

95 } 

96) 

97 

98ALLOWED_CONFIG_KEYS: Final[tuple[str, ...]] = tuple(_NORMALIZERS) 

99 

100 

101@click.group(name="config") 

102def config_commands() -> None: 

103 """Manage persistent CLI configuration (~/.litellm/config.json)""" 

104 

105 

106@config_commands.command(name="set") 

107@click.argument("key") 

108@click.argument("value") 

109def set_config(key: str, value: str) -> None: 

110 """Set a config KEY to VALUE (e.g. `lite config set base_url https://your-proxy.example.com`)""" 

111 normalizer: Final = _NORMALIZERS.get(key) 

112 if normalizer is None: 

113 raise click.UsageError(f"Unknown config key '{key}'. Allowed keys: {', '.join(ALLOWED_CONFIG_KEYS)}") 

114 

115 normalized_value: Final = normalizer(value) 

116 save_config({**load_config(), key: normalized_value}) 

117 click.echo(f"Set {key} = {normalized_value} in {get_config_file_path()}") 

118 

119 

120@config_commands.command(name="get") 

121@click.argument("key", required=False) 

122def get_config(key: str | None) -> None: 

123 """Print the value of KEY, or all stored config when KEY is omitted""" 

124 config: Final = load_config() 

125 

126 if key is not None: 

127 value: Final = config.get(key) 

128 if value is None: 

129 click.echo(f"{key} is not set", err=True) 

130 sys.exit(1) 

131 click.echo(value) 

132 return 

133 

134 if not config: 

135 click.echo("(no config set)") 

136 return 

137 

138 for entry_key, entry_value in config.items(): 

139 click.echo(f"{entry_key} = {entry_value}") 

140 

141 

142@config_commands.command(name="unset") 

143@click.argument("key") 

144def unset_config(key: str) -> None: 

145 """Remove KEY from the config file""" 

146 config: Final = load_config() 

147 if key not in config: 

148 click.echo(f"{key} was not set") 

149 return 

150 

151 save_config({k: v for k, v in config.items() if k != key}) 

152 click.echo(f"Removed {key} from {get_config_file_path()}")