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
« 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
15import click
16import requests
17from pydantic import TypeAdapter, ValidationError
19from ..up import UpError, secure_create
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"
27class ProcessLaunchError(Exception):
28 """Raised when the ephemeral proxy subprocess fails to come up healthy."""
31@dataclass(frozen=True, slots=True)
32class PidRecord:
33 pid: int
34 port: int
35 config_path: str
36 log_path: str
39_PID_RECORD_ADAPTER: Final = TypeAdapter(PidRecord)
42_PROXY_RUNTIME_MODULES: tuple[str, ...] = ("fastapi", "uvicorn", "backoff", "orjson", "websockets", "apscheduler")
45def missing_proxy_runtime_modules() -> tuple[str, ...]:
46 """Proxy-server modules that ``lite autoroute start`` needs but the thin CLI install lacks.
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)
56DEFAULT_AUTOROUTE_PORT: Final = 5483
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
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 )
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:])
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 )
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 )
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.")
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)
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
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)
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)
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]