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

109 statements  

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

1import contextlib 

2import importlib.util 

3import json 

4import os 

5import signal 

6import socket 

7import subprocess 

8import sys 

9import threading 

10import time 

11from dataclasses import dataclass 

12from pathlib import Path 

13from typing import Final 

14 

15import click 

16import requests 

17from pydantic import TypeAdapter, ValidationError 

18 

19from ..up import UpError, secure_create 

20 

21AUTOROUTE_DIR: Final = Path.home() / ".litellm" / "autorouter" 

22CONFIG_PATH: Final = AUTOROUTE_DIR / "config.yaml" 

23LOG_PATH: Final = AUTOROUTE_DIR / "proxy.log" 

24PID_RECORD_PATH: Final = AUTOROUTE_DIR / "proxy.pid.json" 

25 

26 

27class ProcessLaunchError(Exception): 

28 """Raised when the ephemeral proxy subprocess fails to come up healthy.""" 

29 

30 

31@dataclass(frozen=True, slots=True) 

32class PidRecord: 

33 pid: int 

34 port: int 

35 config_path: str 

36 log_path: str 

37 

38 

39_PID_RECORD_ADAPTER: Final = TypeAdapter(PidRecord) 

40 

41 

42_PROXY_RUNTIME_MODULES: tuple[str, ...] = ("fastapi", "uvicorn", "backoff", "orjson", "websockets", "apscheduler") 

43 

44 

45def missing_proxy_runtime_modules() -> tuple[str, ...]: 

46 """Proxy-server modules that ``lite autoroute start`` needs but the thin CLI install lacks. 

47 

48 ``launch_proxy`` runs the full ``litellm.proxy.proxy_cli`` server, whose dependencies live in 

49 the ``proxy`` extra, not the ``cli`` extra that installs the ``lite`` command. On a thin 

50 ``litellm[cli]`` install the subprocess dies with a bare ``ModuleNotFoundError``; detecting the 

51 gap here lets ``start`` fail with an actionable message instead. 

52 """ 

53 return tuple(name for name in _PROXY_RUNTIME_MODULES if importlib.util.find_spec(name) is None) 

54 

55 

56DEFAULT_AUTOROUTE_PORT: Final = 5483 

57 

58 

59def is_port_available(port: int) -> bool: 

60 with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: 

61 sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 

62 try: 

63 sock.bind(("127.0.0.1", port)) 

64 except OSError: 

65 return False 

66 return True 

67 

68 

69def launch_proxy(config_path: Path, port: int, log_path: Path) -> "subprocess.Popen[bytes]": 

70 log_path.parent.mkdir(parents=True, exist_ok=True) 

71 with open(log_path, "w") as log_file: 

72 return subprocess.Popen( 

73 [ 

74 sys.executable, 

75 "-m", 

76 "litellm.proxy.proxy_cli", 

77 "--config", 

78 str(config_path), 

79 "--port", 

80 str(port), 

81 "--host", 

82 "127.0.0.1", 

83 ], 

84 stdout=log_file, 

85 stderr=subprocess.STDOUT, 

86 ) 

87 

88 

89def _tail(log_path: Path, lines: int = 40) -> str: 

90 if not log_path.exists(): 

91 return "(no log output captured)" 

92 return "\n".join(log_path.read_text(errors="replace").splitlines()[-lines:]) 

93 

94 

95def poll_liveliness(base_url: str, log_path: Path, process: "subprocess.Popen[bytes]", timeout: float = 30.0) -> None: 

96 """Poll /health/liveliness until it responds, the process dies, or timeout elapses.""" 

97 deadline: Final = time.monotonic() + timeout 

98 url: Final = base_url.rstrip("/") + "/health/liveliness" 

99 while time.monotonic() < deadline: 

100 if process.poll() is not None: 

101 raise ProcessLaunchError( 

102 f"Ephemeral proxy exited early (code {process.returncode}). Last log lines:\n{_tail(log_path)}" 

103 ) 

104 with contextlib.suppress(requests.RequestException): 

105 if requests.get(url, timeout=2).status_code == 200: 

106 return 

107 time.sleep(0.5) 

108 raise ProcessLaunchError( 

109 f"Ephemeral proxy never became healthy within {timeout}s. Last log lines:\n{_tail(log_path)}" 

110 ) 

111 

112 

113def write_pid_record(record: PidRecord, path: Path | None = None) -> None: 

114 resolved_path: Final = path if path is not None else PID_RECORD_PATH 

115 resolved_path.parent.mkdir(parents=True, exist_ok=True) 

116 with open(resolved_path, "w") as f: 

117 json.dump( 

118 {"pid": record.pid, "port": record.port, "config_path": record.config_path, "log_path": record.log_path}, 

119 f, 

120 indent=2, 

121 ) 

122 

123 

124def read_pid_record(path: Path | None = None) -> PidRecord | None: 

125 resolved_path: Final = path if path is not None else PID_RECORD_PATH 

126 if not resolved_path.exists(): 

127 return None 

128 with open(resolved_path, "r") as f: 

129 content: Final = f.read() 

130 try: 

131 return _PID_RECORD_ADAPTER.validate_json(content) 

132 except ValidationError: 

133 raise UpError(f"{resolved_path} contains invalid or unexpected JSON; cannot proceed safely.") 

134 

135 

136def clear_pid_record(path: Path | None = None) -> None: 

137 resolved_path: Final = path if path is not None else PID_RECORD_PATH 

138 resolved_path.unlink(missing_ok=True) 

139 

140 

141def is_running(pid: int) -> bool: 

142 try: 

143 os.kill(pid, 0) 

144 except ProcessLookupError: 

145 return False 

146 except PermissionError: 

147 return True 

148 return True 

149 

150 

151def terminate(pid: int, grace_period: float = 5.0) -> None: 

152 """Terminate a process by pid, escalating from SIGTERM to SIGKILL if needed.""" 

153 if not is_running(pid): 

154 return 

155 with contextlib.suppress(ProcessLookupError): 

156 os.kill(pid, signal.SIGTERM) 

157 deadline: Final = time.monotonic() + grace_period 

158 while time.monotonic() < deadline and is_running(pid): 

159 time.sleep(0.2) 

160 if is_running(pid): 

161 with contextlib.suppress(ProcessLookupError): 

162 os.kill(pid, signal.SIGKILL) 

163 

164 

165def stream_log(log_path: Path, stop_event: threading.Event) -> None: 

166 """Print new lines appended to log_path until stop_event is set. Blocks the calling thread.""" 

167 while not log_path.exists() and not stop_event.is_set(): 

168 time.sleep(0.1) 

169 if stop_event.is_set() or not log_path.exists(): 

170 return 

171 with open(log_path, "r") as f: 

172 while not stop_event.is_set(): 

173 line = f.readline() 

174 if line: 

175 click.echo(line, nl=False) 

176 else: 

177 time.sleep(0.2) 

178 

179 

180__all__ = [ 

181 "AUTOROUTE_DIR", 

182 "CONFIG_PATH", 

183 "DEFAULT_AUTOROUTE_PORT", 

184 "LOG_PATH", 

185 "PID_RECORD_PATH", 

186 "PidRecord", 

187 "ProcessLaunchError", 

188 "clear_pid_record", 

189 "is_port_available", 

190 "is_running", 

191 "launch_proxy", 

192 "missing_proxy_runtime_modules", 

193 "poll_liveliness", 

194 "read_pid_record", 

195 "secure_create", 

196 "stream_log", 

197 "terminate", 

198 "write_pid_record", 

199]