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

1import atexit 

2import secrets 

3import signal 

4import threading 

5from collections.abc import Mapping 

6from types import FrameType 

7from typing import Final 

8 

9import click 

10import yaml 

11from pydantic import JsonValue, TypeAdapter, ValidationError 

12 

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 

45 

46_GENERATED_CONFIG_ADAPTER: Final = TypeAdapter(dict[str, JsonValue]) 

47 

48 

49def _ensure_master_key() -> str: 

50 """Reuse the master key already persisted in the generated config.yaml, minting one only when absent. 

51 

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 

78 

79 

80@click.group(name="autoroute") 

81def autoroute_group() -> None: 

82 """QA complexity-based auto-routing against models your key can already use""" 

83 

84 

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) 

90 

91 

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) 

99 

100 

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.") 

107 

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 ) 

117 

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 ) 

127 

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 ) 

133 

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 ) 

139 

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 ) 

145 

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))) 

150 

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)) 

157 

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)) 

180 

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.") 

183 

184 stop_event: Final = threading.Event() 

185 restored: Final = threading.Lock() 

186 

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 ) 

206 

207 def _handle_signal(_signum: int, _frame: FrameType | None) -> None: 

208 stop_event.set() 

209 

210 signal.signal(signal.SIGINT, _handle_signal) 

211 signal.signal(signal.SIGTERM, _handle_signal) 

212 atexit.register(_teardown) 

213 

214 log_thread: Final = threading.Thread(target=stream_log, args=(LOG_PATH, stop_event), daemon=True) 

215 log_thread.start() 

216 

217 stop_event.wait() 

218 _teardown() 

219 

220 

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() 

235 

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`).") 

246 

247 

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) 

252 

253 

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") 

256 

257 

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) 

265 

266 

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) 

273 

274 

275__all__ = ["autoroute_group"]