From 82e36129cd494719a59494747590ec19b3790c3c Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 21 Aug 2026 05:34:09 +0300 Subject: [PATCH] Refactor access card handling and update CLI output for tunnel operations - Integrated `print_access_card` functionality into the `up` command for both tunneled and non-tunneled scenarios. - Removed the deprecated `print_mcp_snippet` function from the session management flow. - Updated the `tunnel_forwards` function to streamline port handling for LLM runtimes. - Enhanced test cases to reflect changes in access card printing and MCP snippet logging. --- src/gpu_rent/access_card.py | 196 +++++++++++++++++++++++++++++++++++ src/gpu_rent/cli.py | 12 ++- src/gpu_rent/notify.py | 25 +---- src/gpu_rent/session.py | 3 +- src/gpu_rent/tunnel.py | 34 ++---- tests/test_access_card.py | 31 ++++++ tests/test_ready_snapshot.py | 12 ++- tests/test_session.py | 1 - 8 files changed, 258 insertions(+), 56 deletions(-) create mode 100644 src/gpu_rent/access_card.py create mode 100644 tests/test_access_card.py diff --git a/src/gpu_rent/access_card.py b/src/gpu_rent/access_card.py new file mode 100644 index 0000000..c87f1a4 --- /dev/null +++ b/src/gpu_rent/access_card.py @@ -0,0 +1,196 @@ +"""Pretty end-of-launch access card: all local URLs and API endpoints.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from rich.console import Console, Group +from rich.panel import Panel +from rich.table import Table +from rich.text import Text + +from gpu_rent.config import Config +from gpu_rent.llm_runtime import normalize_runtime +from gpu_rent.state import load_state + +Log = Callable[[str], None] + + +@dataclass(frozen=True) +class AccessLink: + label: str + url: str + note: str = "" + + +def resolve_llm_runtime(cfg: Config) -> str: + runtime = normalize_runtime(cfg.llm_runtime) + try: + noted = (load_state().notes or {}).get("llm_runtime") + if noted: + runtime = normalize_runtime(str(noted)) + except Exception: + pass + return runtime + + +def collect_access_links(cfg: Config, *, tunneled: bool) -> list[AccessLink]: + """Build the list of user-facing endpoints (unit-tested).""" + port = cfg.swarmui_local_port + base = f"http://127.0.0.1:{port}" + links: list[AccessLink] = [] + if tunneled: + links.extend( + [ + AccessLink("SwarmUI UI", base, "браузер"), + AccessLink("SwarmUI API", f"{base}/API/", "HTTP JSON"), + AccessLink("SwarmUI MCP", f"{base}/mcp", "Cursor mcp.json"), + ] + ) + runtime = resolve_llm_runtime(cfg) + if runtime == "ollama": + o = cfg.ollama_local_port + links.extend( + [ + AccessLink( + "Ollama API", + f"http://127.0.0.1:{o}", + f"OLLAMA_HOST=http://127.0.0.1:{o}", + ), + AccessLink("Ollama tags", f"http://127.0.0.1:{o}/api/tags", "список моделей"), + AccessLink( + "Ollama chat", + f"http://127.0.0.1:{o}/api/chat", + "POST generate", + ), + ] + ) + elif runtime == "llamacpp": + p = cfg.llamacpp_local_port + links.extend( + [ + AccessLink("llama.cpp", f"http://127.0.0.1:{p}", "OpenAI-compatible"), + AccessLink( + "OpenAI /v1", + f"http://127.0.0.1:{p}/v1/chat/completions", + "chat completions", + ), + AccessLink("Models", f"http://127.0.0.1:{p}/v1/models", "list"), + ] + ) + else: + links.append( + AccessLink( + "Туннель", + "gpu-rent tunnel --open", + "локальные URL появятся после tunnel", + ) + ) + return links + + +def mcp_snippet_lines(cfg: Config) -> list[str]: + port = cfg.swarmui_local_port + return [ + "{", + ' "mcpServers": {', + ' "swarmui": {', + f' "url": "http://127.0.0.1:{port}/mcp"', + " }", + " }", + "}", + ] + + +def render_access_panel( + cfg: Config, + *, + tunneled: bool = True, + host: str | None = None, + title: str = "gpu-rent · доступы", +) -> Panel: + links = collect_access_links(cfg, tunneled=tunneled) + table = Table(show_header=True, header_style="bold", box=None, pad_edge=False) + table.add_column("сервис", style="cyan", no_wrap=True) + table.add_column("URL / команда", style="bold white") + table.add_column("заметка", style="dim") + + for link in links: + table.add_row(link.label, link.url, link.note) + + cmds = Table(show_header=False, box=None, pad_edge=False) + cmds.add_column(style="dim", no_wrap=True) + cmds.add_column() + cmds.add_row("открыть UI", "gpu-rent open") + if resolve_llm_runtime(cfg) in {"ollama", "llamacpp"}: + cmds.add_row("открыть LLM", "gpu-rent open --llm") + cmds.add_row("hold killer", "gpu-rent hold") + cmds.add_row("стоп GPU", "gpu-rent stop") + if not tunneled: + cmds.add_row("туннель", "gpu-rent tunnel --open") + + mcp = Text("\n".join(mcp_snippet_lines(cfg)), style="green") + subtitle = Text() + if tunneled: + subtitle.append("туннель слушает localhost", style="green") + if host: + subtitle.append(f" · VM {host}", style="dim") + else: + subtitle.append("облако готово, туннеля нет", style="yellow") + if host: + subtitle.append(f" · FIP {host}", style="dim") + + body = Group( + subtitle, + Text(""), + table, + Text(""), + Text("команды", style="bold"), + cmds, + Text(""), + Text("Cursor MCP (вставь в mcp.json — файл сам не трогаем)", style="bold"), + mcp, + ) + return Panel( + body, + title=f"[bold]{title}[/bold]", + border_style="bright_blue", + padding=(1, 2), + ) + + +def print_access_card( + cfg: Config, + *, + tunneled: bool = True, + host: str | None = None, + console: Console | None = None, + log: Log | None = None, +) -> None: + """Print Rich panel; fall back to plain lines if needed.""" + panel = render_access_panel(cfg, tunneled=tunneled, host=host) + if console is not None: + console.print() + console.print(panel) + console.print() + return + if log is not None: + # Plain fallback for non-Rich loggers + log("") + log("══ gpu-rent · доступы ══") + for link in collect_access_links(cfg, tunneled=tunneled): + extra = f" ({link.note})" if link.note else "" + log(f" {link.label:14} {link.url}{extra}") + log("— Cursor MCP —") + for line in mcp_snippet_lines(cfg): + log(line) + log("hold: gpu-rent hold | stop: gpu-rent stop") + log("") + return + Console(highlight=False, legacy_windows=False).print(panel) + + +def print_mcp_snippet(cfg: Config, log: Log) -> None: + """Back-compat: plain MCP + links via log.""" + print_access_card(cfg, tunneled=True, log=log) diff --git a/src/gpu_rent/cli.py b/src/gpu_rent/cli.py index 3fbaa1c..753bc92 100644 --- a/src/gpu_rent/cli.py +++ b/src/gpu_rent/cli.py @@ -456,20 +456,24 @@ def up( log=lambda m: console.print(m), ) if no_tunnel: + from gpu_rent.access_card import print_access_card + console.print( f"[bold]готово[/bold] (без туннеля). " f"UI: gpu-rent tunnel --open | stop: gpu-rent stop" ) if state.floating_ip: console.print(f"FIP {state.floating_ip}") + print_access_card( + cfg, + tunneled=False, + host=state.floating_ip, + console=console, + ) return if not state.floating_ip: raise GpuRentError("нет floating IP после up — туннель не открыть") - console.print( - f"[bold]панель[/bold] http://127.0.0.1:{cfg.swarmui_local_port} " - "(Ctrl+C = закрыть туннель, GPU жив → gpu-rent stop)" - ) run_tunnel( cfg, state.floating_ip, diff --git a/src/gpu_rent/notify.py b/src/gpu_rent/notify.py index 3dd7944..1bd3b16 100644 --- a/src/gpu_rent/notify.py +++ b/src/gpu_rent/notify.py @@ -5,10 +5,13 @@ from __future__ import annotations import sys from collections.abc import Callable +from gpu_rent.access_card import print_access_card, print_mcp_snippet from gpu_rent.config import Config Log = Callable[[str], None] +__all__ = ["notify_ready", "print_mcp_snippet", "print_access_card"] + def notify_ready(cfg: Config, log: Log) -> None: if not cfg.notify_ready: @@ -36,7 +39,6 @@ def _sound() -> None: def _windows_toast(log: Log) -> None: - # PowerShell WinRT toast — no admin; fail soft. script = ( "[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, " "ContentType = WindowsRuntime] > $null; " @@ -59,24 +61,3 @@ def _windows_toast(log: Log) -> None: ) except Exception as exc: log(f"toast недоступен ({exc}) — только звук/лог") - - -def print_mcp_snippet(cfg: Config, log: Log) -> None: - port = cfg.swarmui_local_port - log("") - log("— Cursor MCP (вставь в mcp.json, файл сам не трогаем) —") - log("{") - log(' "mcpServers": {') - log(' "swarmui": {') - log(f' "url": "http://127.0.0.1:{port}/mcp"') - log(" }") - log(" }") - log("}") - log("") - log(f"SwarmUI на VM: 127.0.0.1:7801 (только через туннель)") - log(f"Локально: уже в up (или gpu-rent tunnel)") - log(f"Браузер: gpu-rent open → http://127.0.0.1:{port}") - log(f"API: http://127.0.0.1:{port}/API/") - log(f"MCP: http://127.0.0.1:{port}/mcp") - log("Hold killer: gpu-rent hold") - log("Стоп GPU: gpu-rent stop") diff --git a/src/gpu_rent/session.py b/src/gpu_rent/session.py index 170b431..87dfa6c 100644 --- a/src/gpu_rent/session.py +++ b/src/gpu_rent/session.py @@ -24,7 +24,7 @@ from gpu_rent.bootstrap import run_bootstrap from gpu_rent.provision import provision_vm from gpu_rent.ready import wait_backend_idle from gpu_rent.snapshot import ensure_boot_snapshot -from gpu_rent.notify import notify_ready, print_mcp_snippet +from gpu_rent.notify import notify_ready from gpu_rent.config import Config from gpu_rent.errors import CloudError, GpuRentError from gpu_rent.inventory import ( @@ -125,7 +125,6 @@ def _bind_access( except CloudError as exc: log(f"snapshot: {exc}") notify_ready(cfg, log) - print_mcp_snippet(cfg, log) state.bootstrapped = True state.phase = "ready_cloud" save_state(state) diff --git a/src/gpu_rent/tunnel.py b/src/gpu_rent/tunnel.py index 4e258fc..0433014 100644 --- a/src/gpu_rent/tunnel.py +++ b/src/gpu_rent/tunnel.py @@ -15,7 +15,7 @@ from gpu_rent.cloud import ( ) from gpu_rent.config import Config from gpu_rent.errors import CloudError, GpuRentError -from gpu_rent.llm_runtime import llm_local_port, llm_remote_port, normalize_runtime +from gpu_rent.llm_runtime import normalize_runtime from gpu_rent.os_client import connect from gpu_rent.ssh_ops import wait_ssh from gpu_rent.state import load_state, save_state, utc_now @@ -75,7 +75,6 @@ def tunnel_forwards(cfg: Config) -> list[tuple[int, int]]: """List of (local_port, remote_port). SwarmUI always; LLM if configured.""" pairs = [(cfg.swarmui_local_port, 7801)] runtime = normalize_runtime(cfg.llm_runtime) - # Prefer state notes if provision recorded a different runtime this session. state = load_state() noted = (state.notes or {}).get("llm_runtime") if noted: @@ -83,17 +82,10 @@ def tunnel_forwards(cfg: Config) -> list[tuple[int, int]]: runtime = normalize_runtime(str(noted)) except ValueError: pass - remote = llm_remote_port(runtime) - local = llm_local_port(cfg) if runtime != "none" else None - # llm_local_port uses cfg.llm_runtime — override by mutating check: if runtime == "ollama": - local = cfg.ollama_local_port - remote = 11434 + pairs.append((cfg.ollama_local_port, 11434)) elif runtime == "llamacpp": - local = cfg.llamacpp_local_port - remote = 8080 - if local and remote: - pairs.append((local, remote)) + pairs.append((cfg.llamacpp_local_port, 8080)) return pairs @@ -194,23 +186,15 @@ def run_tunnel( server = _start_forwarder(cfg, current_host, forwards) swarm_url = f"http://127.0.0.1:{cfg.swarmui_local_port}" - log(f"UI {swarm_url}") - log(f"API {swarm_url}/API/") - log(f"MCP {swarm_url}/mcp") - runtime = normalize_runtime(cfg.llm_runtime) - state = load_state() - if (state.notes or {}).get("llm_runtime"): - try: - runtime = normalize_runtime(str(state.notes["llm_runtime"])) - except ValueError: - pass - if runtime == "ollama": - log(f"Ollama API http://127.0.0.1:{cfg.ollama_local_port} (OLLAMA_HOST=…)") - elif runtime == "llamacpp": - log(f"llama.cpp http://127.0.0.1:{cfg.llamacpp_local_port}") + + from gpu_rent.access_card import print_access_card + + print_access_card(cfg, tunneled=True, host=current_host) + if open_browser: webbrowser.open(swarm_url) + state = load_state() state.phase = "ready_tunneled" save_state(state) diff --git a/tests/test_access_card.py b/tests/test_access_card.py new file mode 100644 index 0000000..603c023 --- /dev/null +++ b/tests/test_access_card.py @@ -0,0 +1,31 @@ +from gpu_rent.access_card import collect_access_links, mcp_snippet_lines + + +class _Cfg: + swarmui_local_port = 17801 + llm_runtime = "ollama" + ollama_local_port = 17811 + llamacpp_local_port = 17812 + + +def test_collect_links_swarm_and_ollama(monkeypatch): + monkeypatch.setattr( + "gpu_rent.access_card.load_state", + lambda: type("S", (), {"notes": {"llm_runtime": "ollama"}})(), + ) + links = collect_access_links(_Cfg(), tunneled=True) + labels = [x.label for x in links] + assert "SwarmUI UI" in labels + assert "SwarmUI MCP" in labels + assert "Ollama API" in labels + assert any("17811" in x.url for x in links) + + +def test_collect_links_no_tunnel(): + links = collect_access_links(_Cfg(), tunneled=False) + assert links[0].url.startswith("gpu-rent tunnel") + + +def test_mcp_snippet_json(): + lines = mcp_snippet_lines(_Cfg()) + assert any("17801/mcp" in line for line in lines) diff --git a/tests/test_ready_snapshot.py b/tests/test_ready_snapshot.py index 95d223c..369033d 100644 --- a/tests/test_ready_snapshot.py +++ b/tests/test_ready_snapshot.py @@ -6,6 +6,9 @@ class _Cfg: boot_snapshot_name = "gpu-rent-boot-ok" swarmui_local_port = 17801 notify_ready = True + llm_runtime = "none" + ollama_local_port = 17811 + llamacpp_local_port = 17812 def test_ensure_boot_snapshot_skips_existing(): @@ -66,10 +69,15 @@ def test_ensure_boot_snapshot_creates(monkeypatch): assert got.status == "available" -def test_mcp_snippet(capsys): +def test_mcp_snippet(monkeypatch): + monkeypatch.setattr( + "gpu_rent.access_card.load_state", + lambda: type("S", (), {"notes": {}})(), + ) logs = [] print_mcp_snippet(_Cfg(), logs.append) text = "\n".join(logs) assert "17801/mcp" in text - assert "gpu-rent tunnel" in text assert "mcp.json" in text + assert "SwarmUI UI" in text + assert "gpu-rent stop" in text diff --git a/tests/test_session.py b/tests/test_session.py index 4b15720..10525e2 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -53,7 +53,6 @@ def _mock_bind(monkeypatch): lambda conn, boot_volume_id, cfg, log: None, ) monkeypatch.setattr("gpu_rent.session.notify_ready", lambda cfg, log: None) - monkeypatch.setattr("gpu_rent.session.print_mcp_snippet", lambda cfg, log: None) def test_cmd_up_refuses_zero_gpu_quota(monkeypatch):