Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/client/cli/commands/autoroute/commands.py: 0%
142 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 atexit
2import secrets
3import signal
4import threading
5from collections.abc import Mapping
6from types import FrameType
7from typing import Final
9import click
10import yaml
11from pydantic import JsonValue, TypeAdapter, ValidationError
13from ..claude_settings import (
14 AUTOROUTE_BACKUP_PATH,
15 CLAUDE_SETTINGS_PATH,
16 ClaudeSettingsError,
17 StaticToken,
18 install_statusline_script,
19 load_json_or_empty,
20 merge_claude_settings,
21 write_claude_settings,
22)
23from ..up import BackupRecord as ClaudeBackupRecord
24from ..up import restore_claude_settings, write_backup
25from .config import AUTOROUTER_MODEL_NAME, master_key_from_config
26from .process import (
27 CONFIG_PATH,
28 DEFAULT_AUTOROUTE_PORT,
29 LOG_PATH,
30 PidRecord,
31 ProcessLaunchError,
32 clear_pid_record,
33 is_port_available,
34 is_running,
35 launch_proxy,
36 missing_proxy_runtime_modules,
37 poll_liveliness,
38 read_pid_record,
39 secure_create,
40 stream_log,
41 terminate,
42 write_pid_record,
43)
44from .wizard import run_configure_wizard
46_GENERATED_CONFIG_ADAPTER: Final = TypeAdapter(dict[str, JsonValue])
49def _ensure_master_key() -> str:
50 """Reuse the master key already persisted in the generated config.yaml, minting one only when absent.
52 The generated config is the single home of the key: the proxy server authenticates against
53 general_settings.master_key only (a key under litellm_settings is silently ignored, which
54 would leave the ephemeral proxy with no real auth), and the file is written 0600 via
55 secure_create. Reusing that persisted value keeps the key stable across `start` runs, so a
56 client configured against one session keeps working in the next.
57 """
58 with open(CONFIG_PATH, "r") as f:
59 try:
60 generated: Final = _GENERATED_CONFIG_ADAPTER.validate_python(yaml.safe_load(f))
61 except (yaml.YAMLError, ValidationError):
62 raise click.ClickException(
63 f"{CONFIG_PATH} is empty or corrupt. Run `lite autoroute configure` again to regenerate it."
64 )
65 persisted: Final = master_key_from_config(generated)
66 if persisted is not None:
67 return persisted
68 master_key: Final = secrets.token_urlsafe(32)
69 general_settings: Final = generated.get("general_settings")
70 updated_settings: Final[dict[str, JsonValue]] = {
71 **(general_settings if isinstance(general_settings, Mapping) else {}),
72 "master_key": master_key,
73 }
74 updated: Final[dict[str, JsonValue]] = {**generated, "general_settings": updated_settings}
75 with secure_create(CONFIG_PATH) as f:
76 yaml.safe_dump(updated, f, sort_keys=False)
77 return master_key
80@click.group(name="autoroute")
81def autoroute_group() -> None:
82 """QA complexity-based auto-routing against models your key can already use"""
85@autoroute_group.command("configure")
86@click.pass_context
87def configure(ctx: click.Context) -> None:
88 """Discover accessible models and generate an ephemeral auto-router config"""
89 run_configure_wizard(ctx)
92_PORT_OPTION: Final = click.option(
93 "--port",
94 type=click.IntRange(1, 65535),
95 default=DEFAULT_AUTOROUTE_PORT,
96 show_default=True,
97 help="Loopback port for the ephemeral proxy; stable across runs so configured clients keep working.",
98)
101@autoroute_group.command("start")
102@_PORT_OPTION
103def start(port: int) -> None:
104 """Launch the ephemeral auto-router proxy and route Claude Code through it"""
105 if not CONFIG_PATH.exists():
106 raise click.ClickException("No config found. Run `lite autoroute configure` first.")
108 missing: Final = missing_proxy_runtime_modules()
109 if missing:
110 raise click.ClickException(
111 "lite autoroute start launches a local litellm proxy, which needs the proxy runtime that the "
112 f"thin `litellm[cli]` install does not include (missing: {', '.join(missing)}). Install the "
113 "proxy runtime with `uv tool install --force 'litellm[proxy]'`, or to QA a branch, "
114 "`curl -fsSL https://raw.githubusercontent.com/BerriAI/litellm/<branch>/scripts/install.sh | "
115 "LITELLM_CLI_REF=<branch> sh`."
116 )
118 try:
119 existing_pid: Final = read_pid_record()
120 except ClaudeSettingsError as e:
121 raise click.ClickException(str(e))
122 if existing_pid is not None and is_running(existing_pid.pid):
123 raise click.ClickException(
124 "An ephemeral proxy is already running (lite autoroute start looks already active). "
125 "Run `lite autoroute stop` first."
126 )
128 if AUTOROUTE_BACKUP_PATH.exists():
129 raise click.ClickException(
130 f"{AUTOROUTE_BACKUP_PATH} already exists -- `lite autoroute start` looks like it's already "
131 "running (or crashed without cleanup). Run `lite autoroute stop` first."
132 )
134 if port == 4000:
135 raise click.ClickException(
136 "Port 4000 is the litellm proxy's own default and its launcher silently rebinds it to a random "
137 "port when busy; pick a different --port."
138 )
140 if not is_port_available(port):
141 raise click.ClickException(
142 f"Port {port} on 127.0.0.1 is already in use. If a previous `lite autoroute start` is still "
143 "running or crashed, run `lite autoroute stop`; otherwise pick a different port with --port."
144 )
146 master_key: Final = _ensure_master_key()
147 base_url: Final = f"http://127.0.0.1:{port}"
148 process: Final = launch_proxy(CONFIG_PATH, port, LOG_PATH)
149 write_pid_record(PidRecord(pid=process.pid, port=port, config_path=str(CONFIG_PATH), log_path=str(LOG_PATH)))
151 try:
152 poll_liveliness(base_url, LOG_PATH, process)
153 except ProcessLaunchError as e:
154 terminate(process.pid)
155 clear_pid_record()
156 raise click.ClickException(str(e))
158 try:
159 status_line: Final = install_statusline_script()
160 original_existed: Final = CLAUDE_SETTINGS_PATH.exists()
161 original_settings: Final = load_json_or_empty(CLAUDE_SETTINGS_PATH)
162 write_backup(
163 ClaudeBackupRecord(existed=original_existed, content=original_settings if original_existed else None),
164 AUTOROUTE_BACKUP_PATH,
165 )
166 merged: Final = merge_claude_settings(
167 original_settings,
168 base_url,
169 StaticToken(master_key),
170 AUTOROUTER_MODEL_NAME,
171 AUTOROUTER_MODEL_NAME,
172 status_line=status_line,
173 )
174 CLAUDE_SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
175 write_claude_settings(CLAUDE_SETTINGS_PATH, merged)
176 except ClaudeSettingsError as e:
177 terminate(process.pid)
178 clear_pid_record()
179 raise click.ClickException(str(e))
181 click.echo(f"litellm: ephemeral auto-router proxy up at {base_url} (pid {process.pid})")
182 click.echo("Claude Code sessions started now will route through it. Press Ctrl-C to stop and restore.")
184 stop_event: Final = threading.Event()
185 restored: Final = threading.Lock()
187 def _teardown() -> None:
188 if not restored.acquire(blocking=False):
189 return
190 terminate(process.pid)
191 clear_pid_record()
192 try:
193 restore_claude_settings(CLAUDE_SETTINGS_PATH, AUTOROUTE_BACKUP_PATH)
194 except ClaudeSettingsError as e:
195 # Runs from atexit/a signal handler too, outside Click's own exception
196 # handling -- raising here would only produce an unhandled-exception
197 # warning on stderr, not a clean message.
198 click.echo(str(e), err=True)
199 return
200 click.echo("\nStopped ephemeral proxy and restored Claude Code settings.")
201 click.echo(
202 f"Restart any Claude Code session still open from this session, or another local account could "
203 f"bind the now-free port {port} and receive its requests. Do not use `lite autoroute start` on a "
204 f"shared or multi-tenant host."
205 )
207 def _handle_signal(_signum: int, _frame: FrameType | None) -> None:
208 stop_event.set()
210 signal.signal(signal.SIGINT, _handle_signal)
211 signal.signal(signal.SIGTERM, _handle_signal)
212 atexit.register(_teardown)
214 log_thread: Final = threading.Thread(target=stream_log, args=(LOG_PATH, stop_event), daemon=True)
215 log_thread.start()
217 stop_event.wait()
218 _teardown()
221@autoroute_group.command("stop")
222def stop() -> None:
223 """Restore Claude Code settings and stop a leftover ephemeral proxy, if any"""
224 try:
225 record: PidRecord | None = read_pid_record()
226 except ClaudeSettingsError as e:
227 # stop is the crash-recovery path -- a corrupt pid record must not block it; clear the
228 # unusable record and keep going rather than leaving the user with no way to clean up.
229 click.echo(f"{e} Clearing it and continuing cleanup.", err=True)
230 record = None
231 if record is not None and is_running(record.pid):
232 terminate(record.pid)
233 click.echo(f"Stopped leftover ephemeral proxy (pid {record.pid}).")
234 clear_pid_record()
236 try:
237 restored: Final = restore_claude_settings(CLAUDE_SETTINGS_PATH, AUTOROUTE_BACKUP_PATH)
238 except ClaudeSettingsError as e:
239 raise click.ClickException(str(e))
240 if restored is None:
241 click.echo("Nothing to restore.")
242 elif restored.existed:
243 click.echo(f"Restored {CLAUDE_SETTINGS_PATH} to its original contents.")
244 else:
245 click.echo(f"Removed {CLAUDE_SETTINGS_PATH} (it did not exist before `lite autoroute start`).")
248AUTOROUTE_ALIAS_DEPRECATION_NOTICE: Final = (
249 "`lite autoroute {retired}` is deprecated and will be removed in a future release; "
250 "run `lite autoroute {current}` instead, it takes the same options."
251)
254def _warn_deprecated_alias(retired: str, current: str) -> None:
255 click.secho(AUTOROUTE_ALIAS_DEPRECATION_NOTICE.format(retired=retired, current=current), err=True, fg="yellow")
258@autoroute_group.command("up", hidden=True)
259@_PORT_OPTION
260@click.pass_context
261def up(ctx: click.Context, port: int) -> None:
262 """Deprecated alias of `lite autoroute start`"""
263 _warn_deprecated_alias("up", "start")
264 ctx.invoke(start, port=port)
267@autoroute_group.command("down", hidden=True)
268@click.pass_context
269def down(ctx: click.Context) -> None:
270 """Deprecated alias of `lite autoroute stop`"""
271 _warn_deprecated_alias("down", "stop")
272 ctx.invoke(stop)
275__all__ = ["autoroute_group"]