Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/client/cli/commands/up.py: 0%
141 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 contextlib
3import json
4import os
5import signal
6import sys
7import threading
8from collections.abc import Iterator
9from dataclasses import dataclass
10from pathlib import Path
11from types import FrameType
12from typing import IO, Final
14import click
15from pydantic import JsonValue, TypeAdapter, ValidationError
17from litellm.litellm_core_utils.cli_keyring import SecretVault
18from litellm.litellm_core_utils.cli_token_utils import is_cli_token_fresh
19from litellm.litellm_core_utils.private_json import ensure_private_dir
21from .agents import AgentRunError, resolve_api_key, verify_proxy_key
22from .auth import CliContextObj, context_secret_vault, get_stored_api_key, load_token, login
23from .claude_settings import (
24 BACKUP_PATH,
25 CLAUDE_SETTINGS_PATH,
26 ClaudeSettingsError,
27 StaticToken,
28 install_statusline_script,
29 load_json_or_empty,
30 merge_claude_settings,
31 write_claude_settings,
32)
35class UpError(ClaudeSettingsError):
36 """Raised for any user-actionable failure while starting/stopping interception."""
39@dataclass(frozen=True, slots=True)
40class BackupRecord:
41 """Snapshot of ~/.claude/settings.json taken right before `lite up` patches it."""
43 existed: bool
44 content: dict[str, JsonValue] | None
47_BACKUP_RECORD_ADAPTER: Final = TypeAdapter(BackupRecord)
50@contextlib.contextmanager
51def secure_create(path: Path) -> Iterator[IO[str]]:
52 """Open path for writing with mode 0600 fixed up before any content is written.
54 A plain `open(path, "w")` creates a *new* file at the umask-derived default (commonly 0644)
55 and leaves it world- or group-readable until a later `chmod` call catches up -- a real window
56 in which a file holding a credential is readable by another local account. Passing the mode to
57 `os.open` closes that window for a brand-new file, but `O_CREAT`'s mode argument is only
58 applied on creation: if the file already exists its old, broader permissions carry over
59 untouched. `os.fchmod` right after opening -- before a single byte of the new content is
60 written -- covers both cases.
61 """
62 fd: Final = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
63 os.fchmod(fd, 0o600)
64 f: Final[IO[str]] = os.fdopen(fd, "w")
65 try:
66 yield f
67 finally:
68 f.close()
71def write_backup(record: BackupRecord, backup_path: Path | None = None) -> None:
72 path: Final = backup_path if backup_path is not None else BACKUP_PATH
73 ensure_private_dir(path.parent)
74 with secure_create(path) as f:
75 json.dump({"existed": record.existed, "content": record.content}, f, indent=2)
78def read_backup(backup_path: Path | None = None) -> BackupRecord | None:
79 path: Final = backup_path if backup_path is not None else BACKUP_PATH
80 if not path.exists():
81 return None
82 with open(path, "r") as f:
83 content: Final = f.read()
84 try:
85 return _BACKUP_RECORD_ADAPTER.validate_json(content)
86 except ValidationError:
87 raise UpError(f"{path} contains invalid or unexpected JSON; cannot restore from it safely.")
90def restore_claude_settings(settings_path: Path | None = None, backup_path: Path | None = None) -> BackupRecord | None:
91 """Restore settings_path from the backup at backup_path, then delete the backup.
93 Returns the restored record, or None if there was nothing to restore.
94 """
95 resolved_settings_path: Final = settings_path if settings_path is not None else CLAUDE_SETTINGS_PATH
96 resolved_backup_path: Final = backup_path if backup_path is not None else BACKUP_PATH
97 record: Final = read_backup(resolved_backup_path)
98 if record is None:
99 return None
100 if record.existed and record.content is not None:
101 resolved_settings_path.parent.mkdir(parents=True, exist_ok=True)
102 write_claude_settings(resolved_settings_path, record.content)
103 elif resolved_settings_path.exists():
104 resolved_settings_path.unlink()
105 resolved_backup_path.unlink()
106 return record
109def _usable_login(api_key: str | None, vault: SecretVault) -> bool:
110 if api_key is None:
111 return False
112 token_data: Final = load_token(vault=vault)
113 return token_data is not None and is_cli_token_fresh(token_data)
116def _key_resolved_on_the_way_in(ctx_obj: CliContextObj, base_url: str, vault: SecretVault) -> str | None:
117 if ctx_obj.get("api_key_from_token_file"):
118 return ctx_obj.get("api_key")
119 return get_stored_api_key(expected_base_url=base_url, vault=vault)
122def _stored_login_is_pkce(vault: SecretVault) -> bool:
123 token_data: Final = load_token(vault=vault)
124 return token_data is not None and token_data.get("refresh_token") is not None
127def ensure_fresh_login(ctx: click.Context) -> None:
128 ctx_obj: Final[CliContextObj] = ctx.obj
129 base_url: Final = ctx_obj["base_url"].rstrip("/")
130 vault: Final = context_secret_vault(ctx)
131 if _usable_login(_key_resolved_on_the_way_in(ctx_obj, base_url, vault), vault):
132 return
134 pkce: Final = _stored_login_is_pkce(vault)
135 login_command: Final = "lite login --pkce" if pkce else "lite login"
136 if not sys.stdin.isatty():
137 raise UpError(f"No fresh LiteLLM login found for this proxy. Run `{login_command}` first.")
139 click.echo("No fresh LiteLLM login found for this proxy; starting login...")
140 ctx.invoke(login, config_claude=False, pkce=pkce)
141 if not _usable_login(get_stored_api_key(expected_base_url=base_url, vault=vault), vault):
142 raise UpError("Login did not produce a usable token.")
145def _restore_and_report() -> None:
146 record: Final = restore_claude_settings()
147 if record is None:
148 click.echo("Nothing to restore.")
149 return
150 if record.existed:
151 click.echo(f"Restored {CLAUDE_SETTINGS_PATH} to its original contents.")
152 else:
153 click.echo(f"Removed {CLAUDE_SETTINGS_PATH} (it did not exist before `lite up`).")
156@click.command(name="up")
157@click.pass_context
158def up(ctx: click.Context) -> None:
159 """Route every Claude Code session through your LiteLLM proxy until stopped.
161 Patches ~/.claude/settings.json so Claude Code picks up the proxy on its own
162 next startup, from any terminal -- no need to launch it through `lite`. The
163 key written is the one this command resolved (your fresh `lite login`, or an
164 explicit --api-key), copied in as a static token for as long as `up` runs.
165 Press Ctrl-C to stop and restore your original settings. Assumes the proxy
166 is already running (this does not start one for you). Cursor is not
167 supported: it has no equivalent file-based config to patch.
168 """
169 ctx_obj: Final[CliContextObj] = ctx.obj
170 base_url: Final = ctx_obj["base_url"]
172 try:
173 ensure_fresh_login(ctx)
174 api_key: Final = resolve_api_key(ctx)
175 verify_proxy_key(base_url, api_key)
177 if BACKUP_PATH.exists():
178 raise UpError(
179 f"{BACKUP_PATH} already exists -- `lite up` looks like it's already "
180 "running (or crashed without cleanup). Run `lite down` first."
181 )
183 status_line: Final = install_statusline_script()
184 original_existed: Final = CLAUDE_SETTINGS_PATH.exists()
185 original_settings: Final = load_json_or_empty(CLAUDE_SETTINGS_PATH)
186 write_backup(
187 BackupRecord(
188 existed=original_existed,
189 content=original_settings if original_existed else None,
190 )
191 )
193 CLAUDE_SETTINGS_PATH.parent.mkdir(exist_ok=True)
194 merged: Final = merge_claude_settings(
195 original_settings, base_url, StaticToken(api_key), status_line=status_line
196 )
197 write_claude_settings(CLAUDE_SETTINGS_PATH, merged)
198 except (AgentRunError, ClaudeSettingsError) as e:
199 raise click.ClickException(str(e))
201 click.echo(f"litellm: routing Claude Code through proxy at {base_url.rstrip('/')}")
202 click.echo("Press Ctrl-C to stop and restore your original settings.")
204 stop_event: Final = threading.Event()
205 restored: Final = threading.Lock()
207 def _handle_signal(_signum: int, _frame: FrameType | None) -> None:
208 stop_event.set()
210 def _restore_once() -> None:
211 if not restored.acquire(blocking=False):
212 return
213 try:
214 _restore_and_report()
215 except ClaudeSettingsError as e:
216 # Runs from atexit/a signal handler, outside Click's own exception
217 # handling -- raising here would only produce an unhandled-exception
218 # warning on stderr, not a clean message.
219 click.echo(str(e), err=True)
221 signal.signal(signal.SIGINT, _handle_signal)
222 signal.signal(signal.SIGTERM, _handle_signal)
223 atexit.register(_restore_once)
225 stop_event.wait()
226 _restore_once()
229@click.command(name="down")
230def down() -> None:
231 """Restore ~/.claude/settings.json if a `lite up` session left it patched.
233 Use this after a `lite up` process was killed uncleanly (e.g. `kill -9`)
234 instead of stopped with Ctrl-C.
235 """
236 try:
237 _restore_and_report()
238 except ClaudeSettingsError as e:
239 raise click.ClickException(str(e))
242__all__ = [
243 "BACKUP_PATH",
244 "CLAUDE_SETTINGS_PATH",
245 "BackupRecord",
246 "ClaudeSettingsError",
247 "UpError",
248 "down",
249 "load_json_or_empty",
250 "merge_claude_settings",
251 "read_backup",
252 "restore_claude_settings",
253 "up",
254 "write_backup",
255]