Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion litellm/proxy/client/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -545,7 +545,7 @@ You must run `configure` at least once before `up`; running `up` first fails wit
lite autoroute up
```

Starts a local, throwaway litellm proxy on a random free port, running the config `configure` generated, with a freshly-minted random API key baked in for this session only (your real proxy key never leaves the generated config -- it only appears there, forwarding to your real proxy). It waits for the ephemeral proxy to report healthy, then patches `~/.claude/settings.json` the same way `lite up` does, except with a static `ANTHROPIC_AUTH_TOKEN` env var instead of an `apiKeyHelper`, since this key is short-lived and self-issued rather than something needing SSO refresh. Any `claude` session started afterward, from any terminal, routes through the ephemeral proxy.
Starts a local, throwaway litellm proxy on a random free port, running the config `configure` generated, with the fixed local development key `sk-1234` baked into the generated config. It waits for the ephemeral proxy to report healthy, then patches `~/.claude/settings.json` the same way `lite up` does, except with a static `ANTHROPIC_AUTH_TOKEN` env var instead of an `apiKeyHelper`. Any `claude` session started afterward, from any terminal, routes through the ephemeral proxy. Pass `--debug` or `--detailed-debug` to forward those logging flags to the local proxy; startup output includes the config and log paths, port, and key.

`lite autoroute up` runs in the foreground and streams the ephemeral proxy's own log file into your terminal, so you can watch its routing decisions -- which tier and model got picked for each request -- as you use Claude Code normally. Press Ctrl-C (or send SIGTERM) to stop it; this kills the child proxy process and restores your original Claude Code settings, in that order.

Expand Down
25 changes: 16 additions & 9 deletions litellm/proxy/client/cli/commands/autoroute/commands.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import atexit
import json
import secrets
import signal
import threading
from types import FrameType
Expand Down Expand Up @@ -35,17 +34,17 @@
AUTOROUTE_BACKUP_PATH = AUTOROUTE_DIR / "claude_settings_backup.json"

_GENERATED_CONFIG_ADAPTER = TypeAdapter(dict[str, JsonValue])
MASTER_KEY = "sk-1234"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 security Well-known key lowers bar for lateral local API access

Replacing the per-session random key with the fixed sk-1234 means any process running on the same machine — including scripts, CI runners, or other user-space tools — that is aware of this convention can authenticate to the ephemeral proxy and make real Anthropic API calls on the developer's behalf without reading ~/.claude/settings.json. The previous random key required reading that file (permissions 0o600) to learn the credential; sk-1234 requires no file access at all. The proxy does bind only to 127.0.0.1, and the teardown message already cautions against multi-tenant hosts, so this is a deliberate trade-off for dev convenience. However, it's worth considering a compromise — e.g. a short fixed prefix like sk-local- followed with a few random bytes — that retains discoverability while avoiding a universally guessable credential.



def _mint_and_embed_master_key() -> str:
"""Generate a fresh key for this session and write it into the generated config.yaml.
def _embed_master_key() -> str:
"""Write the local development key into the generated config.yaml.

Must go under general_settings, not litellm_settings -- the proxy server only ever
reads general_settings.master_key (proxy_server.py:4530) to authenticate requests. A
key placed under litellm_settings is silently ignored, leaving the ephemeral proxy with
no real auth: any request reaches it regardless of the token Claude Code sends.
"""
master_key = secrets.token_urlsafe(32)
with open(CONFIG_PATH, "r") as f:
try:
generated = _GENERATED_CONFIG_ADAPTER.validate_python(yaml.safe_load(f))
Expand All @@ -56,12 +55,12 @@ def _mint_and_embed_master_key() -> str:
general_settings = generated.get("general_settings")
updated_settings: dict[str, JsonValue] = {
**(general_settings if isinstance(general_settings, dict) else {}),
"master_key": master_key,
"master_key": MASTER_KEY,
}
updated: dict[str, JsonValue] = {**generated, "general_settings": updated_settings}
with secure_create(CONFIG_PATH) as f:
yaml.safe_dump(updated, f, sort_keys=False)
return master_key
return MASTER_KEY


@click.group(name="autoroute")
Expand All @@ -77,7 +76,11 @@ def configure(ctx: click.Context) -> None:


@autoroute_group.command("up")
def up() -> None:
@click.option("--debug", is_flag=True, help="Enable debug logging in the local proxy.")
@click.option(
"--detailed-debug", "detailed_debug", is_flag=True, help="Enable detailed debug logging in the local proxy."
)
def up(debug: bool, detailed_debug: bool) -> None:
"""Launch the ephemeral auto-router proxy and route Claude Code through it"""
if not CONFIG_PATH.exists():
raise click.ClickException("No config found. Run `lite autoroute configure` first.")
Expand Down Expand Up @@ -108,10 +111,10 @@ def up() -> None:
"running (or crashed without cleanup). Run `lite autoroute down` first."
)

master_key = _mint_and_embed_master_key()
master_key = _embed_master_key()
port = allocate_free_port()
base_url = f"http://127.0.0.1:{port}"
process = launch_proxy(CONFIG_PATH, port, LOG_PATH)
process = launch_proxy(CONFIG_PATH, port, LOG_PATH, debug=debug, detailed_debug=detailed_debug)
write_pid_record(PidRecord(pid=process.pid, port=port, config_path=str(CONFIG_PATH), log_path=str(LOG_PATH)))

try:
Expand All @@ -138,6 +141,10 @@ def up() -> None:
raise click.ClickException(str(e))

click.echo(f"litellm: ephemeral auto-router proxy up at {base_url} (pid {process.pid})")
click.echo(f"Config: {CONFIG_PATH}")
click.echo(f"Log: {LOG_PATH}")
click.echo(f"Port: {port}")
click.echo(f"Master key: {MASTER_KEY}")
click.echo("Claude Code sessions started now will route through it. Press Ctrl-C to stop and restore.")

stop_event = threading.Event()
Expand Down
35 changes: 19 additions & 16 deletions litellm/proxy/client/cli/commands/autoroute/process.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,24 +58,27 @@ def allocate_free_port() -> int:
return int(sock.getsockname()[1])


def launch_proxy(config_path: Path, port: int, log_path: Path) -> "subprocess.Popen[bytes]":
def launch_proxy(
config_path: Path, port: int, log_path: Path, *, debug: bool = False, detailed_debug: bool = False
) -> "subprocess.Popen[bytes]":
log_path.parent.mkdir(parents=True, exist_ok=True)
with open(log_path, "w") as log_file:
return subprocess.Popen(
[
sys.executable,
"-m",
"litellm.proxy.proxy_cli",
"--config",
str(config_path),
"--port",
str(port),
"--host",
"127.0.0.1",
],
stdout=log_file,
stderr=subprocess.STDOUT,
command = (
(
sys.executable,
"-m",
"litellm.proxy.proxy_cli",
"--config",
str(config_path),
"--port",
str(port),
"--host",
"127.0.0.1",
)
+ (("--debug",) if debug else ())
+ (("--detailed_debug",) if detailed_debug else ())
)
with open(log_path, "w") as log_file:
return subprocess.Popen(command, stdout=log_file, stderr=subprocess.STDOUT)


def _tail(log_path: Path, lines: int = 40) -> str:
Expand Down
40 changes: 31 additions & 9 deletions tests/test_litellm/proxy/client/cli/autoroute/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,6 @@ def test_happy_path_patches_settings_then_restores_everything_on_stop(self, monk
monkeypatch.setattr(commands_module, "poll_liveliness", lambda *a, **k: None)
monkeypatch.setattr(commands_module, "allocate_free_port", lambda: 54321)
monkeypatch.setattr(commands_module, "terminate", lambda pid, **k: terminate_calls.append(pid))
monkeypatch.setattr(commands_module.secrets, "token_urlsafe", lambda n: "fixed-master-key")

captured = {}

def fake_wait(self, timeout=None):
Expand All @@ -154,7 +152,7 @@ def fake_wait(self, timeout=None):
assert captured["backup_existed"] is True
assert captured["settings"]["theme"] == "dark"
assert captured["settings"]["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:54321"
assert captured["settings"]["env"]["ANTHROPIC_AUTH_TOKEN"] == "fixed-master-key"
assert captured["settings"]["env"]["ANTHROPIC_AUTH_TOKEN"] == "sk-1234"
assert "apiKeyHelper" not in captured["settings"]
assert captured["settings_mode"] == 0o600

Expand All @@ -164,8 +162,37 @@ def fake_wait(self, timeout=None):
assert json.loads(claude_settings_path.read_text()) == original_settings

written_config = yaml.safe_load(config_path.read_text())
assert written_config["general_settings"]["master_key"] == "fixed-master-key"
assert written_config["general_settings"]["master_key"] == "sk-1234"
assert stat.S_IMODE(config_path.stat().st_mode) == 0o600
assert f"Config: {config_path}" in result.output
assert f"Log: {log_path}" in result.output
assert "Port: 54321" in result.output
assert "Master key: sk-1234" in result.output

def test_passes_debug_flags_to_proxy(self, monkeypatch, tmp_path):
config_path, _log_path, claude_settings_path, _backup_path, _pid_record_path = _patch_paths(
monkeypatch, tmp_path
)
config_path.write_text(yaml.safe_dump({"model_list": []}))
claude_settings_path.write_text(json.dumps({"theme": "dark"}))
_silence_signal_handling(monkeypatch)

fake_process = FakeProcess(pid=99999)
launch_kwargs = {}
monkeypatch.setattr(
commands_module,
"launch_proxy",
lambda *args, **kwargs: launch_kwargs.update(kwargs) or fake_process,
)
monkeypatch.setattr(commands_module, "poll_liveliness", lambda *a, **k: None)
monkeypatch.setattr(commands_module, "allocate_free_port", lambda: 54321)
monkeypatch.setattr(commands_module, "terminate", lambda pid, **k: None)
monkeypatch.setattr("threading.Event.wait", lambda self, timeout=None: True)

result = self.runner.invoke(up, ["--debug", "--detailed-debug"])

assert result.exit_code == 0, result.output
assert launch_kwargs == {"debug": True, "detailed_debug": True}

def test_teardown_reports_clean_error_when_backup_is_corrupt(self, monkeypatch, tmp_path):
"""A corrupt backup at teardown time (e.g. a concurrent process wrote garbage to it) must
Expand All @@ -181,7 +208,6 @@ def test_teardown_reports_clean_error_when_backup_is_corrupt(self, monkeypatch,
monkeypatch.setattr(commands_module, "poll_liveliness", lambda *a, **k: None)
monkeypatch.setattr(commands_module, "allocate_free_port", lambda: 65432)
monkeypatch.setattr(commands_module, "terminate", lambda pid, **k: None)
monkeypatch.setattr(commands_module.secrets, "token_urlsafe", lambda n: "fixed-master-key")

def fake_wait(self, timeout=None):
backup_path.write_text("not json at all {{{")
Expand Down Expand Up @@ -211,8 +237,6 @@ def _raise_launch_error(*args, **kwargs):
monkeypatch.setattr(commands_module, "poll_liveliness", _raise_launch_error)
monkeypatch.setattr(commands_module, "allocate_free_port", lambda: 12345)
monkeypatch.setattr(commands_module, "terminate", lambda pid, **k: terminate_calls.append(pid))
monkeypatch.setattr(commands_module.secrets, "token_urlsafe", lambda n: "fixed-master-key")

result = self.runner.invoke(up)

assert result.exit_code != 0
Expand All @@ -236,8 +260,6 @@ def test_terminates_ephemeral_proxy_when_claude_settings_is_corrupt(self, monkey
monkeypatch.setattr(commands_module, "poll_liveliness", lambda *a, **k: None)
monkeypatch.setattr(commands_module, "allocate_free_port", lambda: 23456)
monkeypatch.setattr(commands_module, "terminate", lambda pid, **k: terminate_calls.append(pid))
monkeypatch.setattr(commands_module.secrets, "token_urlsafe", lambda n: "fixed-master-key")

result = self.runner.invoke(up)

assert result.exit_code != 0
Expand Down
4 changes: 2 additions & 2 deletions tests/test_litellm/proxy/client/cli/autoroute/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,8 @@ def test_default_classifier_and_semantic_matching_add_no_extra_keys(self):
class TestBuildGeneratedProxyConfig:
def test_embeds_master_key_under_general_settings(self):
config = _base_config()
proxy_config = build_generated_proxy_config(config, "sk-master-123")
assert proxy_config["general_settings"] == {"master_key": "sk-master-123"}
proxy_config = build_generated_proxy_config(config, "sk-1234")
assert proxy_config["general_settings"] == {"master_key": "sk-1234"}
assert proxy_config["model_list"] == build_generated_model_list(config)


Expand Down
10 changes: 10 additions & 0 deletions tests/test_litellm/proxy/client/cli/autoroute/test_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ def test_binds_loopback_only_not_all_interfaces(self, tmp_path):
assert "--host" in args
assert args[args.index("--host") + 1] == "127.0.0.1"

def test_forwards_debug_flags(self, tmp_path):
config_path = tmp_path / "config.yaml"
log_path = tmp_path / "proxy.log"

with patch.object(process_module.subprocess, "Popen") as mock_popen:
launch_proxy(config_path, 12345, log_path, debug=True, detailed_debug=True)

args = mock_popen.call_args[0][0]
assert args[-2:] == ("--debug", "--detailed_debug")


class TestPidRecordRoundTrip:
def test_write_then_read_round_trips(self, tmp_path):
Expand Down
Loading