mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
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:
parent
fa346d6f6a
commit
8bfb296566
3 changed files with 100 additions and 1 deletions
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue