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.
This commit is contained in:
Leonid Pershin
2026-08-21 05:34:09 +03:00
parent 2005b00175
commit 82e36129cd
8 changed files with 258 additions and 56 deletions
+196
View File
@@ -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)
+8 -4
View File
@@ -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,
+3 -22
View File
@@ -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")
+1 -2
View File
@@ -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)
+9 -25
View File
@@ -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)