From eb9154d545e9a7f2d46ade32594685058c02ba8c Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Fri, 17 Jul 2026 04:24:14 +0000 Subject: [PATCH] feat(cli): improve autoroute up debuggability Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/client/cli/README.md | 2 +- .../client/cli/commands/autoroute/commands.py | 25 +++++++----- .../client/cli/commands/autoroute/process.py | 35 ++++++++-------- .../client/cli/autoroute/test_commands.py | 40 ++++++++++++++----- .../proxy/client/cli/autoroute/test_config.py | 4 +- .../client/cli/autoroute/test_process.py | 10 +++++ 6 files changed, 79 insertions(+), 37 deletions(-) diff --git a/litellm/proxy/client/cli/README.md b/litellm/proxy/client/cli/README.md index 17041751d155..025470e125bc 100644 --- a/litellm/proxy/client/cli/README.md +++ b/litellm/proxy/client/cli/README.md @@ -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. diff --git a/litellm/proxy/client/cli/commands/autoroute/commands.py b/litellm/proxy/client/cli/commands/autoroute/commands.py index 161907f5b279..75c735635572 100644 --- a/litellm/proxy/client/cli/commands/autoroute/commands.py +++ b/litellm/proxy/client/cli/commands/autoroute/commands.py @@ -1,6 +1,5 @@ import atexit import json -import secrets import signal import threading from types import FrameType @@ -35,17 +34,17 @@ AUTOROUTE_BACKUP_PATH = AUTOROUTE_DIR / "claude_settings_backup.json" _GENERATED_CONFIG_ADAPTER = TypeAdapter(dict[str, JsonValue]) +MASTER_KEY = "sk-1234" -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)) @@ -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") @@ -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.") @@ -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: @@ -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() diff --git a/litellm/proxy/client/cli/commands/autoroute/process.py b/litellm/proxy/client/cli/commands/autoroute/process.py index 712f2eed2dad..5537d2ff52a7 100644 --- a/litellm/proxy/client/cli/commands/autoroute/process.py +++ b/litellm/proxy/client/cli/commands/autoroute/process.py @@ -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: diff --git a/tests/test_litellm/proxy/client/cli/autoroute/test_commands.py b/tests/test_litellm/proxy/client/cli/autoroute/test_commands.py index 9efde03e04c7..6311c5f42074 100644 --- a/tests/test_litellm/proxy/client/cli/autoroute/test_commands.py +++ b/tests/test_litellm/proxy/client/cli/autoroute/test_commands.py @@ -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): @@ -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 @@ -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 @@ -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 {{{") @@ -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 @@ -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 diff --git a/tests/test_litellm/proxy/client/cli/autoroute/test_config.py b/tests/test_litellm/proxy/client/cli/autoroute/test_config.py index f8d82476ef02..0f0850a1a407 100644 --- a/tests/test_litellm/proxy/client/cli/autoroute/test_config.py +++ b/tests/test_litellm/proxy/client/cli/autoroute/test_config.py @@ -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) diff --git a/tests/test_litellm/proxy/client/cli/autoroute/test_process.py b/tests/test_litellm/proxy/client/cli/autoroute/test_process.py index a4f85ea44ff9..88bb1eaf06c3 100644 --- a/tests/test_litellm/proxy/client/cli/autoroute/test_process.py +++ b/tests/test_litellm/proxy/client/cli/autoroute/test_process.py @@ -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):