From 1670b9b4c03dd84fed2318dd66d8d60857e9870d Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 6 Jun 2026 18:54:36 +0000 Subject: [PATCH] feat(cli): add `litellm-proxy run -- ` to wrap coding agents through the proxy Wraps Claude Code, Codex, OpenCode, and any other coding agent so all of its LLM traffic routes through a LiteLLM proxy, with the agent-vault style of "just works" DX: one `run -- ` command, auto SSO login when interactive, env-key "agent mode" for containers/CI, and a fail-fast key check against the proxy so bad credentials error immediately instead of deep inside the agent. The wrapped binary is detected by name to pick the right variables. Claude Code gets ANTHROPIC_BASE_URL (the bare proxy root, so it appends /v1/messages) and ANTHROPIC_AUTH_TOKEN, with any stray ANTHROPIC_API_KEY cleared so the proxy token wins. Codex and OpenCode get OPENAI_BASE_URL (proxy + /v1) and OPENAI_API_KEY. Unrecognized commands get both sets so they work either way. `litellm-proxy claude-code` remains as a shortcut for `run -- claude`. The core logic is split into dependency-injected helpers (agent_profile, build_agent_env, verify_proxy_key, run_agent) so env wiring, the preflight, and the launch handoff are unit-tested without monkeypatching, alongside CliRunner tests for auth resolution, agent mode, and auto-login. Mutation-tested the env profiles, preflight, and agent-mode branch to confirm the tests fail when the behavior is broken. https://claude.ai/code/session_0154VpLXW7mMvk5wfbgPRJa6 --- litellm/proxy/client/cli/README.md | 30 ++ litellm/proxy/client/cli/commands/run.py | 263 ++++++++++++ litellm/proxy/client/cli/interface.py | 2 + litellm/proxy/client/cli/main.py | 4 + .../proxy/client/cli/test_run_commands.py | 373 ++++++++++++++++++ 5 files changed, 672 insertions(+) create mode 100644 litellm/proxy/client/cli/commands/run.py create mode 100644 tests/test_litellm/proxy/client/cli/test_run_commands.py diff --git a/litellm/proxy/client/cli/README.md b/litellm/proxy/client/cli/README.md index 6ef837cb521..799e50bf980 100644 --- a/litellm/proxy/client/cli/README.md +++ b/litellm/proxy/client/cli/README.md @@ -438,6 +438,36 @@ litellm-proxy http request POST /chat/completions -j '{"model": "gpt-4", "messag litellm-proxy http request GET /health/test_connection -H "X-Custom-Header:value" ``` +### Run a Coding Agent + +Run a coding agent (Claude Code, Codex, OpenCode, and others) with all of its LLM traffic routed through your LiteLLM proxy. Everything after `--` is the command to launch: + +```bash +litellm-proxy run -- claude +litellm-proxy run -- codex +litellm-proxy run -- opencode +``` + +`litellm-proxy claude-code` is a shortcut for `litellm-proxy run -- claude`. + +The wrapper resolves your LiteLLM key (logging in via SSO when none is stored and you are at a terminal; otherwise it expects `LITELLM_PROXY_API_KEY` or `--api-key`), checks the key against the proxy so bad credentials fail immediately instead of deep inside the agent, exports the environment variables the agent reads, then replaces itself with the agent process. + +The command is detected from the binary name to pick the right variables. Claude Code gets `ANTHROPIC_BASE_URL` (the proxy root, so it appends `/v1/messages`) and `ANTHROPIC_AUTH_TOKEN`, with any stray `ANTHROPIC_API_KEY` cleared so the proxy token wins. Codex and OpenCode get `OPENAI_BASE_URL` (the proxy plus `/v1`) and `OPENAI_API_KEY`. An unrecognized command gets both sets so it works either way. + +Options: + +- `--model`, `-m`: Model Claude Code should request (sets `ANTHROPIC_MODEL`); must resolve on your proxy. OpenAI-style agents take the model through their own flag. +- `--small-fast-model`: Background-task model for Claude Code (sets `ANTHROPIC_SMALL_FAST_MODEL`). +- `--skip-verify`: Skip the pre-launch key check (useful offline or with non-standard auth). + +Forward flags the agent owns after `--`: + +```bash +litellm-proxy run --model claude-sonnet-proxy -- claude --resume +``` + +Whatever model the agent requests (default Claude names, or your `--model` override) must exist on the proxy, since requests land on the proxy's `/v1/messages` (Anthropic) or `/v1/chat/completions` and `/v1/responses` (OpenAI) endpoints. + ## Environment Variables The CLI respects the following environment variables: diff --git a/litellm/proxy/client/cli/commands/run.py b/litellm/proxy/client/cli/commands/run.py new file mode 100644 index 00000000000..27306d433fa --- /dev/null +++ b/litellm/proxy/client/cli/commands/run.py @@ -0,0 +1,263 @@ +import os +import shutil +import sys +from typing import Callable, Dict, FrozenSet, Mapping, Optional, Sequence, Tuple + +import click +import requests + +from .auth import get_stored_api_key, login + +ANTHROPIC_BASE_URL_ENV = "ANTHROPIC_BASE_URL" +ANTHROPIC_AUTH_TOKEN_ENV = "ANTHROPIC_AUTH_TOKEN" +ANTHROPIC_API_KEY_ENV = "ANTHROPIC_API_KEY" +ANTHROPIC_MODEL_ENV = "ANTHROPIC_MODEL" +ANTHROPIC_SMALL_FAST_MODEL_ENV = "ANTHROPIC_SMALL_FAST_MODEL" +OPENAI_BASE_URL_ENV = "OPENAI_BASE_URL" +OPENAI_API_KEY_ENV = "OPENAI_API_KEY" + +PROFILE_ANTHROPIC = "anthropic" +PROFILE_OPENAI = "openai" + +_KNOWN_AGENTS: Dict[str, Tuple[str, FrozenSet[str]]] = { + "claude": ("Claude Code", frozenset({PROFILE_ANTHROPIC})), + "codex": ("Codex", frozenset({PROFILE_OPENAI})), + "opencode": ("OpenCode", frozenset({PROFILE_OPENAI})), +} + +_INSTALL_DOCS: Dict[str, str] = { + "claude": "https://docs.claude.com/en/docs/claude-code/setup", + "codex": "https://developers.openai.com/codex/cli", + "opencode": "https://opencode.ai/docs", +} + + +class AgentRunError(Exception): + """Raised for any user-actionable failure while preparing to run an agent.""" + + +def agent_profile(command: str) -> Tuple[str, FrozenSet[str]]: + """Return the (display name, env profiles) for a wrapped command. + + Known agents map to the API family they speak. Anything else gets both + families so it works regardless of which env vars the tool reads. + """ + base = os.path.basename(command) + if base in _KNOWN_AGENTS: + return _KNOWN_AGENTS[base] + return base, frozenset({PROFILE_ANTHROPIC, PROFILE_OPENAI}) + + +def build_agent_env( + base_env: Mapping[str, str], + base_url: str, + api_key: str, + profiles: FrozenSet[str], + *, + model: Optional[str] = None, + small_fast_model: Optional[str] = None, +) -> Dict[str, str]: + """Return a copy of base_env wired to route the agent through the proxy. + + Anthropic clients (Claude Code) append /v1/messages to ANTHROPIC_BASE_URL, + so it stays the bare proxy root; OpenAI clients (Codex, OpenCode) expect the + /v1 suffix on OPENAI_BASE_URL. ANTHROPIC_API_KEY is dropped so a stray + Anthropic key cannot win over the bearer token we set. + """ + env = dict(base_env) + root = base_url.rstrip("/") + if PROFILE_ANTHROPIC in profiles: + env[ANTHROPIC_BASE_URL_ENV] = root + env[ANTHROPIC_AUTH_TOKEN_ENV] = api_key + env.pop(ANTHROPIC_API_KEY_ENV, None) + if model: + env[ANTHROPIC_MODEL_ENV] = model + if small_fast_model: + env[ANTHROPIC_SMALL_FAST_MODEL_ENV] = small_fast_model + if PROFILE_OPENAI in profiles: + env[OPENAI_BASE_URL_ENV] = root + "/v1" + env[OPENAI_API_KEY_ENV] = api_key + return env + + +def verify_proxy_key( + base_url: str, + api_key: str, + *, + get: Callable[..., requests.Response] = requests.get, +) -> None: + """Probe the proxy with the key so bad creds fail here, not inside the agent. + + Raises AgentRunError when the proxy is unreachable or rejects the key. Other + non-2xx responses are tolerated; the agent's own call is the real test. + """ + url = base_url.rstrip("/") + "/v1/models" + try: + resp = get(url, headers={"Authorization": f"Bearer {api_key}"}, timeout=10) + except requests.RequestException as e: + raise AgentRunError( + f"Could not reach the LiteLLM proxy at {base_url.rstrip('/')}: {e}. " + "Is it running, and is --base-url (or LITELLM_PROXY_URL) correct?" + ) + if resp.status_code in (401, 403): + raise AgentRunError( + f"LiteLLM rejected your key (HTTP {resp.status_code}). " + "Run `litellm-proxy login` to refresh it, or pass a valid --api-key." + ) + + +def _exec(path: str, args: Sequence[str], env: Mapping[str, str]) -> None: + os.execvpe(path, list(args), dict(env)) + + +def run_agent( + base_url: str, + api_key: str, + command: Sequence[str], + *, + model: Optional[str] = None, + small_fast_model: Optional[str] = None, + skip_verify: bool = False, + base_env: Optional[Mapping[str, str]] = None, + which: Callable[[str], Optional[str]] = shutil.which, + verify: Callable[[str, str], None] = verify_proxy_key, + launcher: Callable[[str, Sequence[str], Mapping[str, str]], None] = _exec, +) -> None: + """Validate, wire the environment, and hand off to the agent. + + On success this replaces the current process and never returns. Raises + AgentRunError for missing binaries, an unreachable proxy, or a rejected key. + """ + if not command: + raise AgentRunError("Nothing to run. Try `litellm-proxy run -- claude`.") + + _, profiles = agent_profile(command[0]) + binary = which(command[0]) + if binary is None: + docs = _INSTALL_DOCS.get(os.path.basename(command[0])) + hint = f" Install it first: {docs}" if docs else "" + raise AgentRunError(f"Could not find `{command[0]}` on your PATH.{hint}") + + if not skip_verify: + verify(base_url, api_key) + + env = build_agent_env( + base_env if base_env is not None else os.environ, + base_url, + api_key, + profiles, + model=model, + small_fast_model=small_fast_model, + ) + launcher(binary, command, env) + + +def _is_interactive() -> bool: + return sys.stdin.isatty() + + +def _resolve_api_key(ctx: click.Context) -> str: + base_url = ctx.obj["base_url"] + api_key = ctx.obj.get("api_key") + if api_key: + return api_key + + if not _is_interactive(): + raise click.ClickException( + "No LiteLLM key found. Set LITELLM_PROXY_API_KEY (or pass --api-key) for " + "non-interactive use, or run `litellm-proxy login` from a terminal." + ) + + click.echo("No LiteLLM credentials found; starting login...") + ctx.invoke(login) + api_key = get_stored_api_key(expected_base_url=base_url) + if not api_key: + raise click.ClickException( + "Login did not produce an API key; cannot start the agent." + ) + return api_key + + +_MODEL_HELP = ( + "Model the agent should request, exported as ANTHROPIC_MODEL for Claude Code. " + "Must resolve on your proxy. OpenAI-style agents take the model via their own flag." +) +_SMALL_FAST_MODEL_HELP = ( + "Background-task model for Claude Code, exported as ANTHROPIC_SMALL_FAST_MODEL." +) +_SKIP_VERIFY_HELP = "Skip the pre-launch key check against the proxy." + + +@click.command(name="run", context_settings={"ignore_unknown_options": True}) +@click.option("--model", "-m", default=None, help=_MODEL_HELP) +@click.option("--small-fast-model", default=None, help=_SMALL_FAST_MODEL_HELP) +@click.option("--skip-verify", is_flag=True, default=False, help=_SKIP_VERIFY_HELP) +@click.argument("command", nargs=-1, type=click.UNPROCESSED, required=True) +@click.pass_context +def run( + ctx: click.Context, + model: Optional[str], + small_fast_model: Optional[str], + skip_verify: bool, + command: Sequence[str], +): + """Run a coding agent wired to your LiteLLM proxy. + + Everything after `--` is the command to execute, e.g. + `litellm-proxy run -- claude`, `litellm-proxy run -- codex`, or + `litellm-proxy run -- opencode`. Logs in with LiteLLM if needed, checks the + key against the proxy, exports the env vars the agent reads, then hands off. + """ + command = list(command) + base_url = ctx.obj["base_url"] + api_key = _resolve_api_key(ctx) + + display_name, _ = agent_profile(command[0]) + click.echo( + f"litellm: routing {display_name} through proxy at {base_url.rstrip('/')}" + ) + + try: + run_agent( + base_url, + api_key, + command, + model=model, + small_fast_model=small_fast_model, + skip_verify=skip_verify, + ) + except AgentRunError as e: + raise click.ClickException(str(e)) + + +@click.command(name="claude-code", context_settings={"ignore_unknown_options": True}) +@click.option("--model", "-m", default=None, help=_MODEL_HELP) +@click.option("--small-fast-model", default=None, help=_SMALL_FAST_MODEL_HELP) +@click.option("--skip-verify", is_flag=True, default=False, help=_SKIP_VERIFY_HELP) +@click.argument("claude_args", nargs=-1, type=click.UNPROCESSED) +@click.pass_context +def claude_code( + ctx: click.Context, + model: Optional[str], + small_fast_model: Optional[str], + skip_verify: bool, + claude_args: Sequence[str], +): + """Shortcut for `litellm-proxy run -- claude`. Extra args are forwarded to claude.""" + ctx.invoke( + run, + model=model, + small_fast_model=small_fast_model, + skip_verify=skip_verify, + command=("claude", *claude_args), + ) + + +__all__ = [ + "run", + "claude_code", + "run_agent", + "build_agent_env", + "verify_proxy_key", + "agent_profile", +] diff --git a/litellm/proxy/client/cli/interface.py b/litellm/proxy/client/cli/interface.py index eba693dc18e..7da3084497d 100644 --- a/litellm/proxy/client/cli/interface.py +++ b/litellm/proxy/client/cli/interface.py @@ -91,6 +91,8 @@ def show_commands(): ("keys", "Manage API keys"), ("teams", "Manage teams and team assignments"), ("users", "Manage users"), + ("run", "Run a coding agent (claude, codex, ...) through the proxy"), + ("claude-code", "Shortcut for `run -- claude`"), ("version", "Show version information"), ("help", "Show this help message"), ("quit", "Exit the interactive session"), diff --git a/litellm/proxy/client/cli/main.py b/litellm/proxy/client/cli/main.py index be55f79c066..e213460604a 100644 --- a/litellm/proxy/client/cli/main.py +++ b/litellm/proxy/client/cli/main.py @@ -15,6 +15,7 @@ from .commands.keys import keys # local imports from .commands.models import models +from .commands.run import claude_code, run from .commands.teams import teams from .commands.users import users from .interface import interactive_shell @@ -112,6 +113,9 @@ cli.add_command(keys) cli.add_command(teams) # Add the users command group cli.add_command(users) +# Add the agent runner commands +cli.add_command(run) +cli.add_command(claude_code) if __name__ == "__main__": diff --git a/tests/test_litellm/proxy/client/cli/test_run_commands.py b/tests/test_litellm/proxy/client/cli/test_run_commands.py new file mode 100644 index 00000000000..f824d10772f --- /dev/null +++ b/tests/test_litellm/proxy/client/cli/test_run_commands.py @@ -0,0 +1,373 @@ +import os +import sys +from unittest.mock import patch + +import click +import pytest +import requests +from click.testing import CliRunner + +sys.path.insert( + 0, os.path.abspath("../../..") +) # Adds the parent directory to the system path + + +from litellm.proxy.client.cli.commands.run import ( + AgentRunError, + agent_profile, + build_agent_env, + claude_code, + run, + run_agent, + verify_proxy_key, +) + +RUN_MODULE = "litellm.proxy.client.cli.commands.run" + + +class _FakeResponse: + def __init__(self, status_code): + self.status_code = status_code + + +class TestAgentProfile: + def test_claude_is_anthropic(self): + name, profiles = agent_profile("claude") + assert name == "Claude Code" + assert profiles == frozenset({"anthropic"}) + + def test_claude_full_path_uses_basename(self): + name, profiles = agent_profile("/usr/local/bin/claude") + assert name == "Claude Code" + assert profiles == frozenset({"anthropic"}) + + def test_codex_and_opencode_are_openai(self): + assert agent_profile("codex") == ("Codex", frozenset({"openai"})) + assert agent_profile("opencode") == ("OpenCode", frozenset({"openai"})) + + def test_unknown_command_gets_both_profiles(self): + name, profiles = agent_profile("mytool") + assert name == "mytool" + assert profiles == frozenset({"anthropic", "openai"}) + + +class TestBuildAgentEnv: + def test_anthropic_profile_uses_bare_root_and_bearer(self): + env = build_agent_env( + {}, "http://localhost:4000/", "sk-key", frozenset({"anthropic"}) + ) + assert env["ANTHROPIC_BASE_URL"] == "http://localhost:4000" + assert env["ANTHROPIC_AUTH_TOKEN"] == "sk-key" + assert "OPENAI_BASE_URL" not in env + assert "OPENAI_API_KEY" not in env + + def test_anthropic_profile_drops_existing_api_key(self): + env = build_agent_env( + {"ANTHROPIC_API_KEY": "real-key"}, + "http://localhost:4000", + "sk-key", + frozenset({"anthropic"}), + ) + assert "ANTHROPIC_API_KEY" not in env + + def test_openai_profile_appends_v1(self): + env = build_agent_env( + {}, "http://localhost:4000/", "sk-key", frozenset({"openai"}) + ) + assert env["OPENAI_BASE_URL"] == "http://localhost:4000/v1" + assert env["OPENAI_API_KEY"] == "sk-key" + assert "ANTHROPIC_BASE_URL" not in env + + def test_both_profiles_set_everything(self): + env = build_agent_env( + {}, "http://localhost:4000", "sk-key", frozenset({"anthropic", "openai"}) + ) + assert env["ANTHROPIC_BASE_URL"] == "http://localhost:4000" + assert env["OPENAI_BASE_URL"] == "http://localhost:4000/v1" + assert env["ANTHROPIC_AUTH_TOKEN"] == "sk-key" + assert env["OPENAI_API_KEY"] == "sk-key" + + def test_model_overrides_only_apply_to_anthropic(self): + anthropic = build_agent_env( + {}, + "http://localhost:4000", + "sk-key", + frozenset({"anthropic"}), + model="claude-proxy", + small_fast_model="haiku-proxy", + ) + assert anthropic["ANTHROPIC_MODEL"] == "claude-proxy" + assert anthropic["ANTHROPIC_SMALL_FAST_MODEL"] == "haiku-proxy" + + openai = build_agent_env( + {}, + "http://localhost:4000", + "sk-key", + frozenset({"openai"}), + model="claude-proxy", + ) + assert "ANTHROPIC_MODEL" not in openai + + def test_model_overrides_omitted_when_not_given(self): + env = build_agent_env( + {}, "http://localhost:4000", "sk-key", frozenset({"anthropic"}) + ) + assert "ANTHROPIC_MODEL" not in env + assert "ANTHROPIC_SMALL_FAST_MODEL" not in env + + def test_preserves_unrelated_env_and_does_not_mutate_input(self): + base = {"PATH": "/usr/bin", "ANTHROPIC_API_KEY": "real-key"} + env = build_agent_env( + base, "http://localhost:4000", "sk-key", frozenset({"anthropic"}) + ) + assert env["PATH"] == "/usr/bin" + assert base == {"PATH": "/usr/bin", "ANTHROPIC_API_KEY": "real-key"} + + +class TestVerifyProxyKey: + def test_ok_status_passes_and_uses_models_endpoint(self): + captured = {} + + def fake_get(url, headers, timeout): + captured["url"] = url + captured["headers"] = headers + return _FakeResponse(200) + + verify_proxy_key("http://localhost:4000/", "sk-key", get=fake_get) + + assert captured["url"] == "http://localhost:4000/v1/models" + assert captured["headers"] == {"Authorization": "Bearer sk-key"} + + @pytest.mark.parametrize("status", [401, 403]) + def test_rejected_key_raises(self, status): + with pytest.raises(AgentRunError, match="rejected your key"): + verify_proxy_key( + "http://localhost:4000", + "sk-key", + get=lambda *a, **k: _FakeResponse(status), + ) + + def test_unreachable_proxy_raises(self): + def boom(*a, **k): + raise requests.ConnectionError("refused") + + with pytest.raises(AgentRunError, match="Could not reach"): + verify_proxy_key("http://localhost:4000", "sk-key", get=boom) + + def test_other_non_2xx_is_tolerated(self): + verify_proxy_key( + "http://localhost:4000", + "sk-key", + get=lambda *a, **k: _FakeResponse(500), + ) + + +class TestRunAgent: + def test_wires_env_and_launches_resolved_binary(self): + calls = {} + + def fake_launcher(path, args, env): + calls["path"] = path + calls["args"] = tuple(args) + calls["env"] = dict(env) + + run_agent( + "http://localhost:4000", + "sk-key", + ["claude", "--resume"], + base_env={"PATH": "/usr/bin", "ANTHROPIC_API_KEY": "leaked"}, + which=lambda name: "/usr/local/bin/claude", + verify=lambda *a: None, + launcher=fake_launcher, + ) + + assert calls["path"] == "/usr/local/bin/claude" + assert calls["args"] == ("claude", "--resume") + env = calls["env"] + assert env["ANTHROPIC_BASE_URL"] == "http://localhost:4000" + assert env["ANTHROPIC_AUTH_TOKEN"] == "sk-key" + assert "ANTHROPIC_API_KEY" not in env + assert "OPENAI_BASE_URL" not in env + + def test_codex_gets_openai_env(self): + calls = {} + run_agent( + "http://localhost:4000", + "sk-key", + ["codex"], + base_env={}, + which=lambda name: "/usr/local/bin/codex", + verify=lambda *a: None, + launcher=lambda p, a, e: calls.update(env=dict(e)), + ) + assert calls["env"]["OPENAI_BASE_URL"] == "http://localhost:4000/v1" + assert calls["env"]["OPENAI_API_KEY"] == "sk-key" + assert "ANTHROPIC_BASE_URL" not in calls["env"] + + def test_missing_binary_raises_with_install_hint(self): + with pytest.raises(AgentRunError, match="claude.*Install it first"): + run_agent( + "http://localhost:4000", + "sk-key", + ["claude"], + base_env={}, + which=lambda name: None, + verify=lambda *a: None, + launcher=lambda *a: None, + ) + + def test_skip_verify_does_not_call_verify(self): + verified = [] + launched = [] + run_agent( + "http://localhost:4000", + "sk-key", + ["claude"], + skip_verify=True, + base_env={}, + which=lambda name: "/usr/local/bin/claude", + verify=lambda *a: verified.append(a), + launcher=lambda *a: launched.append(a), + ) + assert verified == [] + assert len(launched) == 1 + + def test_verify_failure_aborts_before_launch(self): + launched = [] + + def boom(*a): + raise AgentRunError("rejected") + + with pytest.raises(AgentRunError): + run_agent( + "http://localhost:4000", + "sk-key", + ["claude"], + base_env={}, + which=lambda name: "/usr/local/bin/claude", + verify=boom, + launcher=lambda *a: launched.append(a), + ) + assert launched == [] + + def test_empty_command_raises(self): + with pytest.raises(AgentRunError): + run_agent("http://localhost:4000", "sk-key", []) + + +class TestRunCommand: + def setup_method(self): + self.runner = CliRunner() + + def test_launches_with_stored_key_and_forwards_args(self): + captured = {} + + def fake_run_agent(base_url, api_key, command, **kwargs): + captured["base_url"] = base_url + captured["api_key"] = api_key + captured["command"] = list(command) + captured["model"] = kwargs.get("model") + + with patch(f"{RUN_MODULE}.run_agent", side_effect=fake_run_agent): + result = self.runner.invoke( + run, + ["--model", "claude-proxy", "--", "claude", "--resume"], + obj={"base_url": "http://localhost:4000", "api_key": "sk-key"}, + ) + + assert result.exit_code == 0, result.output + assert captured["api_key"] == "sk-key" + assert captured["command"] == ["claude", "--resume"] + assert captured["model"] == "claude-proxy" + assert ( + "routing Claude Code through proxy at http://localhost:4000" + in result.output + ) + + def test_codex_shows_friendly_name(self): + with patch(f"{RUN_MODULE}.run_agent"): + result = self.runner.invoke( + run, + ["--", "codex"], + obj={"base_url": "http://localhost:4000", "api_key": "sk-key"}, + ) + assert result.exit_code == 0, result.output + assert "routing Codex through proxy" in result.output + + def test_non_interactive_without_key_errors_clearly(self): + with ( + patch(f"{RUN_MODULE}._is_interactive", return_value=False), + patch(f"{RUN_MODULE}.run_agent") as mock_run, + ): + result = self.runner.invoke( + run, + ["--", "claude"], + obj={"base_url": "http://localhost:4000", "api_key": None}, + ) + assert result.exit_code != 0 + assert "LITELLM_PROXY_API_KEY" in result.output + mock_run.assert_not_called() + + def test_interactive_without_key_logs_in_then_launches(self): + captured = {} + + @click.command() + def fake_login(): + pass + + with ( + patch(f"{RUN_MODULE}._is_interactive", return_value=True), + patch(f"{RUN_MODULE}.login", fake_login), + patch( + f"{RUN_MODULE}.get_stored_api_key", return_value="sk-after-login" + ) as mock_get, + patch( + f"{RUN_MODULE}.run_agent", + side_effect=lambda base_url, api_key, command, **k: captured.update( + api_key=api_key + ), + ), + ): + result = self.runner.invoke( + run, + ["--", "claude"], + obj={"base_url": "http://localhost:4000", "api_key": None}, + ) + + assert result.exit_code == 0, result.output + assert captured["api_key"] == "sk-after-login" + mock_get.assert_called_once_with(expected_base_url="http://localhost:4000") + + def test_agent_run_error_becomes_click_error(self): + with patch( + f"{RUN_MODULE}.run_agent", + side_effect=AgentRunError("could not reach proxy"), + ): + result = self.runner.invoke( + run, + ["--", "claude"], + obj={"base_url": "http://localhost:4000", "api_key": "sk-key"}, + ) + assert result.exit_code != 0 + assert "could not reach proxy" in result.output + + +class TestClaudeCodeShortcut: + def setup_method(self): + self.runner = CliRunner() + + def test_delegates_to_run_with_claude_prepended(self): + captured = {} + + def fake_run_agent(base_url, api_key, command, **kwargs): + captured["command"] = list(command) + + with patch(f"{RUN_MODULE}.run_agent", side_effect=fake_run_agent): + result = self.runner.invoke( + claude_code, + ["--", "--resume"], + obj={"base_url": "http://localhost:4000", "api_key": "sk-key"}, + ) + + assert result.exit_code == 0, result.output + assert captured["command"] == ["claude", "--resume"]