From 88a2121d16f2f36c1a3c785c7e90f52ef7aedf6d Mon Sep 17 00:00:00 2001 From: ArkNill <48707894+ArkNill@users.noreply.github.com> Date: Thu, 21 May 2026 13:20:20 +0900 Subject: [PATCH] chore(release): 0.9.6 -- atomic init + Windows one-command installer Surfaced by the 2026-05-21 hmj PC dogfood: `llm-relay init` would write ANTHROPIC_BASE_URL into ~/.claude/settings.json BEFORE the proxy server was confirmed serving traffic, so any running Claude Code session would route to a dead port and the symptom looked like a generic API connection error. This release fixes the ordering and adds a one-line Windows installer on top. Key changes: setup_init.run_init -- atomic ordering 1. detect / port / DB / config / knowledge dir 2. start the proxy server 3. /_health gate 4. write ANTHROPIC_BASE_URL + register MCP **only if** (3) passed `--skip-server` now also skips routing and the health gate as one decision; the previous trap of "server skipped, settings still mutated" is gone. _start_server on Windows now uses win_service.start_daemon (pythonw + CREATE_BREAKAWAY_FROM_JOB). The Popen path with CREATE_NEW_PROCESS_GROUP left the proxy tied to its job object so the OS killed it when the parent SSH session disconnected. scripts/install.ps1 -- one-command Windows install Verifies Python 3.9+, detects an active venv (uses it if present, else --user with a PATH hint), pip installs llm-relay[all], then calls llm-relay init. Documented in README.md "One-command install (Windows native)". README -- new "Prerequisites" section Python 3.9+ requirement spelled out, recommended venv flow, platform-specific install hints (winget / brew / apt). Tests New test_skip_server_does_not_write_settings_json regression pins the atomic contract. CHANGELOG carries the 0.9.5 Docker hotfix forward. Tests: 594 pass (+1 new). Ruff clean. NDA grep on diff: 0 hits. --- CHANGELOG.md | 74 ++++++++++++++-- README.md | 31 +++++++ pyproject.toml | 2 +- scripts/install.ps1 | 148 +++++++++++++++++++++++++++++++ src/llm_relay/__init__.py | 2 +- src/llm_relay/detect/__init__.py | 2 +- src/llm_relay/setup_init.py | 120 ++++++++++++++++++------- tests/test_api/test_init.py | 36 +++++++- 8 files changed, 368 insertions(+), 47 deletions(-) create mode 100644 scripts/install.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index af94327..8aaacaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,16 +4,72 @@ All notable changes to llm-relay are documented here. ## [Unreleased] +## [0.9.6] - 2026-05-21 + +> Single-command Windows install + atomic `llm-relay init`. Surfaced by +> 2026-05-21 hmj PC dogfood: the previous `init` would write +> `ANTHROPIC_BASE_URL` into `~/.claude/settings.json` before the proxy +> server was actually serving traffic, so any running Claude Code session +> would route to a dead port and the symptom looked like a generic API +> connection error. This release makes that ordering atomic and ships a +> PowerShell one-line installer. + +### Added +- **One-command Windows installer** (`scripts/install.ps1`): for users + with Python 3.9+ already installed, the entire install flow collapses + to one PowerShell command: + ```powershell + irm https://raw.githubusercontent.com/ArkNill/llm-relay/main/scripts/install.ps1 | iex + ``` + The script verifies Python, detects an active venv (uses it if + present, else falls back to `--user` with a PATH hint), `pip install`s + `llm-relay[all]`, then calls `llm-relay init` which handles + daemon + health-gate + routing in the right order. Documented in + `README.md` "One-command install (Windows native)". +- **README "Prerequisites" section** spelling out the Python 3.9+ + requirement, the recommended venv setup, and platform-specific install + hints (winget / Homebrew / apt). + +### Changed +- **`llm-relay init` is now atomic** (`setup_init.run_init`). New ordering: + 1. Detect CLIs, find port, init DB, write config, init knowledge dir. + 2. **Start the proxy server.** + 3. Health-gate (`/_health` polling). + 4. Configure Claude Code (`ANTHROPIC_BASE_URL`, MCP) **only after** + the server is verified healthy. + Routing is never activated unless the proxy actually responds, so an + abort at step 2 or step 3 leaves `~/.claude/settings.json` untouched + -- no more "ConnectionRefused on port 8083" surprises in your next + Claude Code session. +- **`--skip-server` now also skips routing**. Previously it was a UX + trap: server skipped, but `settings.json` still gained an + `ANTHROPIC_BASE_URL` pointing at the not-running port. Now + `--skip-server` skips the server, the health gate, AND the + Claude Code routing change as a single decision. The summary message + surfaces this explicitly so a follow-up `llm-relay serve` + re-run is + the documented path to enabling routing. +- **Windows background daemon goes through `win_service.start_daemon`** + (`_start_server` in `setup_init.py`). The previous Popen + + `CREATE_NEW_PROCESS_GROUP` left the proxy tied to its job object on + Windows so it died when the parent SSH session disconnected (observed + during the 2026-05-21 dogfood). The win_service path uses pythonw plus + `CREATE_BREAKAWAY_FROM_JOB`, which detaches cleanly. + +### Tests +- New `test_skip_server_does_not_write_settings_json` regression in + `tests/test_api/test_init.py` pins the atomic contract. + ### Fixed -- **Docker image build** (`Dockerfile`): removed the unconditional - `COPY vendor/tokpress /tmp/tokpress` + `pip install` step. The vendor - source is kept outside the repository, so the COPY always failed in - GitHub Actions and the Docker workflow has been broken on every tag - since `v0.9.2`. The proxy already imports `tokpress` inside a - `try/except ImportError` guard, so the image runs unchanged when the - package is absent (`_tokpress_available` simply stays `False`). The - v0.9.5 image is rebuilt via `gh workflow run docker.yml -f tag=0.9.5` - after this change lands on `main`. +- **Docker image build** (`Dockerfile`, 0.9.5 carry-over): removed the + unconditional `COPY vendor/tokpress /tmp/tokpress` + `pip install` + step. The vendor source is kept outside the repository, so the COPY + always failed in GitHub Actions and the Docker workflow had been + broken on every tag since v0.9.2. The proxy already imports `tokpress` + inside a `try/except ImportError` guard, so the image runs unchanged + when the package is absent (`_tokpress_available` simply stays + `False`). The v0.9.5 image was rebuilt via + `gh workflow run docker.yml -f tag=0.9.5` after the change landed on + main; the v0.9.6 image rebuilds automatically on the new tag. ## [0.9.5] - 2026-05-21 diff --git a/README.md b/README.md index 80559ee..7bc7843 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,37 @@ Unified LLM usage management — API proxy, session diagnostics, multi-CLI orche ## Install +### One-command install (Windows native) + +For Windows users who just want it running, after Python 3.9+ is installed: + +```powershell +irm https://raw.githubusercontent.com/ArkNill/llm-relay/main/scripts/install.ps1 | iex +``` + +That script `pip install`s `llm-relay[all]`, starts the proxy as a Windows +background daemon, health-gates it, and only then routes Claude Code through +it. Routing is never activated unless the proxy actually responds, so this is +safe to run on a machine where Claude Code is already configured -- the +worst case is the install aborts with a clear message and leaves your +existing setup untouched. See [Prerequisites](#prerequisites) below for the +Python requirement and venv guidance. + +If you would rather do it by hand (Linux, macOS, or just to see each step), +keep reading. + +### Prerequisites + +- **Python 3.9 or newer** (3.12 recommended). We do not bundle a Python + runtime; install it once and llm-relay reuses it. + - Windows: `winget install Python.Python.3.12` or + [python.org/downloads](https://www.python.org/downloads/) + - macOS: `brew install python@3.12` + - Linux: your distribution's package manager (`apt install python3.12`, + `dnf install python3.12`, etc.) +- **(Recommended) A virtual environment.** Clean uninstall, no PATH + surprises, isolated dependency tree. + ### 1. Set up Python environment
diff --git a/pyproject.toml b/pyproject.toml index 76fc0ae..11eb3ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "llm-relay" -version = "0.9.5" +version = "0.9.6" description = "Unified LLM usage management — API proxy, session diagnostics, multi-CLI orchestration." readme = "README.md" license = "MIT" diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 0000000..91d8edf --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,148 @@ +# llm-relay one-command installer for Windows native. +# +# Usage from PowerShell: +# irm https://raw.githubusercontent.com/ArkNill/llm-relay/main/scripts/install.ps1 | iex +# +# What this does: +# 1. Verifies Python 3.9+ is on PATH. +# 2. Detects an active virtual environment (uses it if present). +# 3. pip install --upgrade "llm-relay[all]". +# 4. Calls `llm-relay init` -- atomic: starts the Windows daemon, waits +# for /_health, then writes ANTHROPIC_BASE_URL into ~/.claude/settings.json. +# Routing is NEVER activated unless the proxy actually responds. +# 5. Prints the dashboard URL. +# +# What this does NOT do: +# - Install Python (we don't bundle a runtime). +# - Install Claude Code / Codex / Gemini (vendor responsibility). +# - Modify your shell profile or PATH (we surface a hint if --user install +# puts the entry point somewhere PATH doesn't see). +# +# Prerequisites and venv guidance: see README.md. + +$ErrorActionPreference = 'Stop' + +function Write-Step($msg) { + Write-Host "" + Write-Host "==> $msg" -ForegroundColor Cyan +} + +function Write-OK($msg) { + Write-Host " [OK] $msg" -ForegroundColor Green +} + +function Write-Warn($msg) { + Write-Host " [WARN] $msg" -ForegroundColor Yellow +} + +function Write-Err($msg) { + Write-Host " [FAIL] $msg" -ForegroundColor Red +} + + +# ── 1. Python ────────────────────────────────────────────────────────────── + +Write-Step "Checking Python" + +$py = Get-Command python -ErrorAction SilentlyContinue +if (-not $py) { + Write-Err "python not found on PATH." + Write-Host "" + Write-Host "Install Python 3.9 or newer first. Quick options:" -ForegroundColor White + Write-Host " winget install Python.Python.3.12" -ForegroundColor Gray + Write-Host " or download from https://www.python.org/downloads/" -ForegroundColor Gray + Write-Host "" + Write-Host "Full prerequisites and venv guidance:" -ForegroundColor White + Write-Host " https://github.com/ArkNill/llm-relay#prerequisites" -ForegroundColor Gray + exit 1 +} + +$pyVer = (& python --version 2>&1) +Write-OK "$pyVer at $($py.Source)" + +# Verify >= 3.9 +$verMatch = [regex]::Match($pyVer, 'Python\s+(\d+)\.(\d+)') +if ($verMatch.Success) { + $major = [int]$verMatch.Groups[1].Value + $minor = [int]$verMatch.Groups[2].Value + if ($major -lt 3 -or ($major -eq 3 -and $minor -lt 9)) { + Write-Err "Python $major.$minor is older than the required 3.9." + Write-Host " Upgrade with: winget install Python.Python.3.12" -ForegroundColor Gray + exit 1 + } +} + + +# ── 2. venv detection ────────────────────────────────────────────────────── + +Write-Step "Checking for active virtual environment" + +$pipUserFlag = @() +if ($env:VIRTUAL_ENV) { + Write-OK "venv active at $env:VIRTUAL_ENV (installing there)" +} else { + Write-Warn "No venv active. Falling back to --user install." + Write-Host " (A venv is recommended for clean uninstall. See README.)" -ForegroundColor Gray + $pipUserFlag = @('--user') +} + + +# ── 3. pip install ───────────────────────────────────────────────────────── + +Write-Step "Installing llm-relay[all] from PyPI" + +$pipArgs = @('-m', 'pip', 'install') + $pipUserFlag + @('--upgrade', 'llm-relay[all]') +& python @pipArgs + +if ($LASTEXITCODE -ne 0) { + Write-Err "pip install failed (exit code $LASTEXITCODE)." + exit $LASTEXITCODE +} +Write-OK "Installed." + + +# ── 4. PATH sanity for --user installs ───────────────────────────────────── + +if (-not $env:VIRTUAL_ENV) { + $userScripts = & python -c "import sysconfig; print(sysconfig.get_path('scripts', f'{sysconfig.get_default_scheme()}_user'))" + if ($userScripts -and (Test-Path $userScripts)) { + $pathParts = $env:PATH -split ';' + if ($pathParts -notcontains $userScripts) { + Write-Warn "Your --user Scripts dir is not on PATH:" + Write-Host " $userScripts" -ForegroundColor Gray + Write-Host " Add it once via:" -ForegroundColor Gray + Write-Host " [Environment]::SetEnvironmentVariable('Path', `"`$env:Path;$userScripts`", 'User')" -ForegroundColor Gray + Write-Host " then reopen this terminal. Continuing with the absolute path for this run." -ForegroundColor Gray + $env:PATH = "$env:PATH;$userScripts" + } + } +} + + +# ── 5. llm-relay init ────────────────────────────────────────────────────── + +Write-Step "Running llm-relay init (atomic: server + health-gate + routing)" + +& llm-relay init +$initExit = $LASTEXITCODE + +if ($initExit -ne 0) { + Write-Err "llm-relay init failed (exit code $initExit)." + Write-Host " Server may not be running. Inspect:" -ForegroundColor Gray + Write-Host " Get-Content `$env:USERPROFILE\.llm-relay\service-error.log -Tail 30" -ForegroundColor Gray + exit $initExit +} + + +# ── 6. Done ──────────────────────────────────────────────────────────────── + +Write-Step "Install complete" +Write-Host " Dashboard: http://localhost:8083/dashboard/" -ForegroundColor White +Write-Host " Display: http://localhost:8083/display/" -ForegroundColor White +Write-Host "" +Write-Host " Verify everything:" -ForegroundColor Gray +Write-Host " llm-relay verify all" -ForegroundColor Gray +Write-Host "" +Write-Host " Roll back (turn proxy off, leave package installed):" -ForegroundColor Gray +Write-Host " llm-relay service stop" -ForegroundColor Gray +Write-Host " llm-relay service uninstall" -ForegroundColor Gray diff --git a/src/llm_relay/__init__.py b/src/llm_relay/__init__.py index 9e33bec..6b347b8 100644 --- a/src/llm_relay/__init__.py +++ b/src/llm_relay/__init__.py @@ -4,4 +4,4 @@ Part of the Mirror Agent ecosystem (open-network DLC). """ -__version__ = "0.9.5" +__version__ = "0.9.6" diff --git a/src/llm_relay/detect/__init__.py b/src/llm_relay/detect/__init__.py index 2a3d69c..841e4df 100644 --- a/src/llm_relay/detect/__init__.py +++ b/src/llm_relay/detect/__init__.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING -__version__ = "0.9.5" +__version__ = "0.9.6" __all__ = ["__version__", "get_all_detectors", "get_detectors_for_provider"] if TYPE_CHECKING: diff --git a/src/llm_relay/setup_init.py b/src/llm_relay/setup_init.py index 7a0de26..a818389 100644 --- a/src/llm_relay/setup_init.py +++ b/src/llm_relay/setup_init.py @@ -272,9 +272,17 @@ def _write_config(db_dir: Path, port: int) -> str: def _start_server(port: int) -> Tuple[bool, str]: - """Start the proxy server in background.""" + """Start the proxy server in background. + + Windows delegates to win_service.start_daemon which uses pythonw plus + CREATE_BREAKAWAY_FROM_JOB so the server survives the parent SSH or + terminal exit. The plain Popen path below would still leave the child + tied to its job object on Windows and the OS kills it when the parent + disconnects (observed during 2026-05-21 hmj PC dogfood). + + POSIX uses uvicorn in a new session (setsid), which is sufficient. + """ if _is_port_in_use(port): - # Verify it's llm-relay try: import urllib.request resp = urllib.request.urlopen( @@ -287,7 +295,17 @@ def _start_server(port: int) -> Tuple[bool, str]: pass return False, "Port {} is in use by another process".format(port) - # Start uvicorn in background + if sys.platform == "win32": + try: + from llm_relay.win_service import start_daemon + except ImportError as exc: + return False, "Windows daemon helper unavailable: {}".format(exc) + ok = start_daemon(port=port) + if ok: + return True, "Started on port {} (Windows daemon via pythonw)".format(port) + return False, "Windows daemon start failed (see service-error.log under db dir)" + + # POSIX: uvicorn in a detached session try: env = os.environ.copy() env["LLM_RELAY_HISTORY"] = "1" @@ -295,14 +313,6 @@ def _start_server(port: int) -> Tuple[bool, str]: log_path = db_dir_for_env() / "server.log" log_file = open(str(log_path), "a") # noqa: SIM115 - # Detach the server process so it survives parent exit. - # Windows: CREATE_NEW_PROCESS_GROUP; POSIX: start_new_session (setsid). - detach_kwargs = {} # type: dict - if sys.platform == "win32": - detach_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP - else: - detach_kwargs["start_new_session"] = True - proc = subprocess.Popen( [ sys.executable, "-m", "uvicorn", @@ -315,10 +325,9 @@ def _start_server(port: int) -> Tuple[bool, str]: stdout=log_file, stderr=log_file, stdin=subprocess.DEVNULL, - **detach_kwargs, + start_new_session=True, ) - # Wait for startup for _ in range(20): time.sleep(0.5) if _is_port_in_use(port): @@ -439,32 +448,75 @@ def run_init( # Step 5: Initialize knowledge directory summary["knowledge"] = _init_knowledge(db_dir, dry_run=dry_run) - # Step 6: Configure Claude Code has_cc = any(c["id"] == "claude-code" for c in summary["clis"]) - if has_cc: - summary["claude_code"] = _configure_claude_code(port, dry_run=dry_run) - else: - summary["claude_code"] = ["Claude Code not detected (skipped)"] - # Step 6: Start server - if not skip_server and not dry_run: - ok, msg = _start_server(port) - summary["server"] = msg - if not ok: - summary["health"] = "Skipped (server not running)" - summary["urls"] = {} - return summary - elif dry_run: - summary["server"] = "[dry-run] Would start server on port {}".format(port) + # ── Atomic ordering rationale (2026-05-21 dogfood lesson) ──────────── + # We DO NOT mutate ~/.claude/settings.json's ANTHROPIC_BASE_URL until + # the proxy is actually serving traffic. Otherwise a fresh Claude Code + # session next launched by the user (or the agent running this command + # under Claude Code) reroutes to a port that isn't listening, and the + # symptom looks like a generic API connection error. + # + # Order: + # start server → health-gate → write settings.json + # `--skip-server` therefore also skips the settings.json mutation; + # we never half-configure. + + # Step 6: Start server (or honour --skip-server / --dry-run) + if skip_server: + summary["server"] = "Skipped (--skip-server)" + summary["health"] = "Skipped (--skip-server)" + if has_cc: + summary["claude_code"] = [ + "Claude Code routing NOT configured (--skip-server). " + "Run `llm-relay serve` and re-run `llm-relay init` to activate.", + ] + else: + summary["claude_code"] = ["Claude Code not detected (skipped)"] + summary["urls"] = {} + return summary - # Step 7: Health check - if not skip_server and not dry_run: - all_ok, results = _health_check(port) - summary["health"] = results + if dry_run: + summary["server"] = "[dry-run] Would start server on port {}".format(port) + summary["health"] = "[dry-run] Would health-gate before routing" + if has_cc: + summary["claude_code"] = ["[dry-run] Would configure Claude Code routing AFTER health-gate passes"] + else: + summary["claude_code"] = ["Claude Code not detected (skipped)"] + summary["urls"] = { + "dashboard": "http://localhost:{}/dashboard/".format(port), + "display": "http://localhost:{}/display/".format(port), + "history": "http://localhost:{}/history/".format(port), + "proxy": "http://localhost:{}".format(port), + } + return summary + + ok, msg = _start_server(port) + summary["server"] = msg + if not ok: + summary["health"] = "Skipped (server not running)" + summary["claude_code"] = ["Skipped (server not running -- routing NOT modified)"] + summary["urls"] = {} + return summary + + # Step 7: Health gate -- only proceed if /_health responds + all_ok, results = _health_check(port) + summary["health"] = results + if not all_ok: + summary["claude_code"] = [ + "Skipped (server started but /_health failed -- routing NOT modified, " + "inspect server log before re-running init).", + ] + summary["urls"] = {} + return summary + + # Step 8: Configure Claude Code (only after server is verified healthy) + if has_cc: + summary["claude_code"] = _configure_claude_code(port, dry_run=False) else: - summary["health"] = "Skipped" + summary["claude_code"] = ["Claude Code not detected (skipped)"] - # Step 8: URLs + # Step 9: URLs summary["urls"] = { "dashboard": "http://localhost:{}/dashboard/".format(port), "display": "http://localhost:{}/display/".format(port), diff --git a/tests/test_api/test_init.py b/tests/test_api/test_init.py index 54fd6ed..aef71ac 100644 --- a/tests/test_api/test_init.py +++ b/tests/test_api/test_init.py @@ -168,4 +168,38 @@ def test_skip_server(self, tmp_path, monkeypatch): monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("LLM_RELAY_DB", str(tmp_path / ".llm-relay" / "usage.db")) summary = run_init(port=59997, skip_server=True) - assert summary["server"] is None or "not started" in str(summary.get("server", "")) + # New contract (atomic ordering, 0.9.6): server is skipped explicitly + server_str = str(summary.get("server", "")) + assert "skip" in server_str.lower() or "not started" in server_str.lower(), \ + "server status should indicate it was skipped, got: {!r}".format(server_str) + + def test_skip_server_does_not_write_settings_json(self, tmp_path, monkeypatch): + """Regression for the 2026-05-21 dogfood failure. + + `--skip-server` previously still wrote ANTHROPIC_BASE_URL into + ~/.claude/settings.json, which broke any Claude Code session that + tried to reach the (non-existent) proxy on the next request. After + the atomic re-ordering in 0.9.6, settings.json must remain + untouched when the server isn't started. + """ + # Claude Code config dir + dummy settings.json to ensure init would + # otherwise consider it present. + claude_dir = tmp_path / ".claude" + claude_dir.mkdir() + settings_path = claude_dir / "settings.json" + original_settings = '{"numStartups": 1}' + settings_path.write_text(original_settings) + + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) # Windows path used by some helpers + monkeypatch.setenv("LLM_RELAY_DB", str(tmp_path / ".llm-relay" / "usage.db")) + + summary = run_init(port=59996, skip_server=True) + + # settings.json must NOT have been touched + assert settings_path.read_text() == original_settings, \ + "settings.json was modified despite --skip-server (atomic ordering bug)" + # claude_code entry should explicitly say routing was not configured + cc_msgs = summary.get("claude_code", []) + assert any("NOT configured" in m or "skipped" in m.lower() for m in cc_msgs), \ + "summary should signal routing was intentionally skipped, got: {!r}".format(cc_msgs)