fix(cli): reattach controlling terminal after SSO login, keep litellm-proxy alias

When the first `lite claude` has to log in via browser SSO, completing the login could
leave stdin detached from the terminal, so a TUI agent like Claude Code would start in
non-interactive mode and exit with "Input must be provided". The wrapper now reopens the
controlling terminal onto stdin just before handoff when the session started interactively;
piped or redirected input is detected up front and left alone, so agent-mode and
non-interactive use are unchanged.

Also keep the `litellm-proxy` console script as an alias for `lite` so existing scripts and
CI that invoke `litellm-proxy` keep working; both names map to the same CLI.
This commit is contained in:
mateo-berri 2026-06-08 22:29:42 +00:00
parent fa346d6f6a
commit 8bfb296566
No known key found for this signature in database
3 changed files with 100 additions and 1 deletions

View file

@ -145,6 +145,26 @@ def _exec(path: str, args: Sequence[str], env: Mapping[str, str]) -> None:
os.execvpe(path, list(args), dict(env))
def _restore_controlling_terminal() -> None:
"""Reattach the controlling terminal to stdin before handing off to the agent.
Completing the browser SSO login can leave stdin detached from the terminal,
which makes a TUI agent like Claude Code start in non-interactive mode and
exit immediately. Reopening /dev/tty onto fd 0 gives the agent a live
terminal; when stdin is still a tty (no login happened) this is a no-op.
"""
if sys.stdin.isatty():
return
try:
fd = os.open("/dev/tty", os.O_RDONLY)
except OSError:
return
try:
os.dup2(fd, 0)
finally:
os.close(fd)
def run_agent(
base_url: str,
api_key: str,
@ -155,11 +175,13 @@ def run_agent(
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,
reattach_terminal: Optional[Callable[[], None]] = None,
) -> 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.
reattach_terminal, when given, runs just before handoff to restore stdin.
"""
if not command:
raise AgentRunError("Nothing to run.")
@ -181,6 +203,8 @@ def run_agent(
profiles,
)
extra_args = agent_launch_args(command[0], base_url)
if reattach_terminal is not None:
reattach_terminal()
launcher(binary, [command[0], *extra_args, *command[1:]], env)
@ -217,6 +241,7 @@ def _launch(
ctx: click.Context, binary: str, args: Sequence[str], *, skip_verify: bool
) -> None:
base_url = ctx.obj["base_url"]
started_interactive = _is_interactive()
api_key = _resolve_api_key(ctx)
display_name, _ = agent_profile(binary)
@ -225,7 +250,15 @@ def _launch(
)
try:
run_agent(base_url, api_key, [binary, *args], skip_verify=skip_verify)
run_agent(
base_url,
api_key,
[binary, *args],
skip_verify=skip_verify,
reattach_terminal=(
_restore_controlling_terminal if started_interactive else None
),
)
except AgentRunError as e:
raise click.ClickException(str(e))

View file

@ -133,6 +133,7 @@ proxy-runtime = [
[project.scripts]
litellm = "litellm:run_server"
lite = "litellm.proxy.client.cli:cli"
litellm-proxy = "litellm.proxy.client.cli:cli"
[dependency-groups]
dev = [

View file

@ -286,6 +286,33 @@ class TestRunAgent:
with pytest.raises(AgentRunError):
run_agent("http://localhost:4000", "sk-key", [])
def test_reattach_terminal_runs_just_before_launch(self):
order = []
run_agent(
"http://localhost:4000",
"sk-key",
["claude"],
skip_verify=True,
base_env={},
which=lambda name: "/usr/local/bin/claude",
launcher=lambda *a: order.append("launch"),
reattach_terminal=lambda: order.append("reattach"),
)
assert order == ["reattach", "launch"]
def test_no_reattach_terminal_by_default(self):
order = []
run_agent(
"http://localhost:4000",
"sk-key",
["claude"],
skip_verify=True,
base_env={},
which=lambda name: "/usr/local/bin/claude",
launcher=lambda *a: order.append("launch"),
)
assert order == ["launch"]
class TestAgentCommands:
def setup_method(self):
@ -408,3 +435,41 @@ class TestAgentCommands:
)
assert result.exit_code != 0
assert "could not reach proxy" in result.output
def test_interactive_session_reattaches_terminal_before_handoff(self):
from litellm.proxy.client.cli.commands.agents import (
_restore_controlling_terminal,
)
captured = {}
with (
patch(f"{AGENTS_MODULE}._is_interactive", return_value=True),
patch(
f"{AGENTS_MODULE}.run_agent",
side_effect=lambda b, k, c, **kw: captured.update(kw),
),
):
result = self.runner.invoke(
_agent_command("claude"),
[],
obj={"base_url": "http://localhost:4000", "api_key": "sk-key"},
)
assert result.exit_code == 0, result.output
assert captured["reattach_terminal"] is _restore_controlling_terminal
def test_non_interactive_agent_mode_leaves_stdin_alone(self):
captured = {}
with (
patch(f"{AGENTS_MODULE}._is_interactive", return_value=False),
patch(
f"{AGENTS_MODULE}.run_agent",
side_effect=lambda b, k, c, **kw: captured.update(kw),
),
):
result = self.runner.invoke(
_agent_command("claude"),
[],
obj={"base_url": "http://localhost:4000", "api_key": "sk-key"},
)
assert result.exit_code == 0, result.output
assert captured["reattach_terminal"] is None