diff --git a/clk_harness/providers/_endpoint_fallback.py b/clk_harness/providers/_endpoint_fallback.py new file mode 100644 index 0000000..4316739 --- /dev/null +++ b/clk_harness/providers/_endpoint_fallback.py @@ -0,0 +1,97 @@ +"""Docker-host fallback for localhost provider endpoints. + +When CLK runs inside a container, ``http://localhost:PORT`` cannot +reach an ollama / OpenWebUI server bound to the host's loopback. On +Docker Desktop (and on Linux with +``--add-host=host.docker.internal:host-gateway``) the host is reachable +at ``host.docker.internal`` instead. + +This module centralises the "probe localhost, fall back to +host.docker.internal" logic shared by every HTTP-based provider so +runtime ``available()`` checks recover automatically without having to +re-run setup. +""" + +from __future__ import annotations + +import socket +import sys +from typing import Optional +from urllib.parse import urlparse, urlunparse + + +_LOCALHOST_HOSTS = {"localhost", "127.0.0.1", "::1"} +_DOCKER_HOST = "host.docker.internal" + + +def _port_for(url) -> int: + if url.port: + return url.port + return 443 if url.scheme == "https" else 80 + + +def probe_endpoint(endpoint: str, timeout: float = 1.0) -> bool: + """Return True if a TCP connection to ``endpoint`` succeeds quickly.""" + try: + url = urlparse(endpoint) + host = url.hostname or "localhost" + with socket.create_connection((host, _port_for(url)), timeout=timeout): + return True + except Exception: + return False + + +def docker_host_swap(endpoint: str) -> Optional[str]: + """Return the host.docker.internal version of ``endpoint``, or None. + + Returns None when the original endpoint does not target localhost. + """ + try: + url = urlparse(endpoint) + except Exception: + return None + if (url.hostname or "").lower() not in _LOCALHOST_HOSTS: + return None + netloc = _DOCKER_HOST + if url.port: + netloc = f"{_DOCKER_HOST}:{url.port}" + if url.username or url.password: + creds = url.username or "" + if url.password: + creds = f"{creds}:{url.password}" + netloc = f"{creds}@{netloc}" + return urlunparse(url._replace(netloc=netloc)) + + +def maybe_docker_host_fallback( + endpoint: str, + *, + label: str = "endpoint", + timeout: float = 1.0, +) -> Optional[str]: + """If ``endpoint`` (localhost) is dead but host.docker.internal works, + return the swapped URL and log a one-line notice to stderr. Otherwise + return None. + + The notice is emitted exactly once per (label, swap) pair so noisy + health-check loops don't spam the log. + """ + if probe_endpoint(endpoint, timeout=timeout): + return None + candidate = docker_host_swap(endpoint) + if not candidate or candidate == endpoint: + return None + if not probe_endpoint(candidate, timeout=timeout): + return None + key = (label, endpoint, candidate) + if key not in _NOTIFIED: + _NOTIFIED.add(key) + print( + f"[{label}] {endpoint} unreachable from this container; " + f"auto-switching to {candidate} (host.docker.internal).", + file=sys.stderr, + ) + return candidate + + +_NOTIFIED: set = set() diff --git a/clk_harness/providers/ollama.py b/clk_harness/providers/ollama.py index d238e49..659c2f4 100644 --- a/clk_harness/providers/ollama.py +++ b/clk_harness/providers/ollama.py @@ -8,14 +8,13 @@ from __future__ import annotations import json -import socket import sys import traceback import urllib.error import urllib.request -from urllib.parse import urlparse from .base import AgentProvider, AgentRequest, AgentResponse, estimate_tokens +from ._endpoint_fallback import maybe_docker_host_fallback, probe_endpoint class OllamaProvider(AgentProvider): @@ -24,19 +23,22 @@ class OllamaProvider(AgentProvider): def _endpoint(self) -> str: return (self.config.get("endpoint") or "http://localhost:11434").rstrip("/") + def available(self) -> bool: + endpoint = self._endpoint() + if probe_endpoint(endpoint): + return True + # Container-on-host rescue: if the configured localhost endpoint + # is dead but host.docker.internal answers, mutate our config so + # subsequent calls (invoke, list_models, …) use the working URL. + swapped = maybe_docker_host_fallback(endpoint, label="ollama") + if swapped: + self.config["endpoint"] = swapped + return True + return False + def _model(self) -> str: return self.config.get("model") or "llama3.1" - def available(self) -> bool: - try: - url = urlparse(self._endpoint()) - host = url.hostname or "localhost" - port = url.port or (443 if url.scheme == "https" else 80) - with socket.create_connection((host, port), timeout=1.0): - return True - except Exception: - return False - def invoke(self, req: AgentRequest) -> AgentResponse: progress = req.on_progress or (lambda kind, msg: None) if req.dry_run: diff --git a/clk_harness/providers/openwebui.py b/clk_harness/providers/openwebui.py index 803f4ab..72e5706 100644 --- a/clk_harness/providers/openwebui.py +++ b/clk_harness/providers/openwebui.py @@ -21,7 +21,6 @@ from __future__ import annotations import json -import socket import sys import time import traceback @@ -29,7 +28,6 @@ import urllib.request import uuid from typing import Any, Dict, List, Tuple -from urllib.parse import urlparse from .base import AgentProvider, AgentRequest, AgentResponse, estimate_tokens @@ -87,14 +85,15 @@ def _model(self) -> str: return self.config.get("model") or "llama3.1" def available(self) -> bool: - try: - url = urlparse(self._endpoint()) - host = url.hostname or "localhost" - port = url.port or (443 if url.scheme == "https" else 80) - with socket.create_connection((host, port), timeout=1.0): - return True - except Exception: - return False + endpoint = self._endpoint() + from ._endpoint_fallback import maybe_docker_host_fallback, probe_endpoint + if probe_endpoint(endpoint): + return True + swapped = maybe_docker_host_fallback(endpoint, label="openwebui") + if swapped: + self.config["endpoint"] = swapped + return True + return False def invoke(self, req: AgentRequest) -> AgentResponse: progress = req.on_progress or (lambda kind, msg: None) diff --git a/clk_harness/tui.py b/clk_harness/tui.py index 0dfe2da..14c651a 100644 --- a/clk_harness/tui.py +++ b/clk_harness/tui.py @@ -1788,8 +1788,32 @@ def _emit_provider_health(self) -> None: return try: from .providers import available_providers + from .config import save_providers_config prov_cfg = load_providers_config(self.state.paths) + # Snapshot endpoints so we can detect auto-failover (localhost -> + # host.docker.internal) inside available_providers and persist + # the swap to providers.json. + before = { + name: (cfg or {}).get("endpoint") + for name, cfg in (prov_cfg.get("providers") or {}).items() + } avail = available_providers(prov_cfg) + swapped = [] + for name, cfg in (prov_cfg.get("providers") or {}).items(): + new_ep = (cfg or {}).get("endpoint") + if new_ep and before.get(name) and new_ep != before[name]: + swapped.append((name, before[name], new_ep)) + if swapped: + try: + save_providers_config(self.state.paths, prov_cfg) + except Exception as exc: + log_exception("tui.TuiApp._emit_provider_health.save", exc) + for name, old, new in swapped: + self.state.add_log( + f"{name}: {old} unreachable, auto-switched to {new} " + "(host.docker.internal). providers.json updated.", + level="WARN", + ) active = self.state.provider or prov_cfg.get("active") or "" self.state.add_log("provider check:", level="SYSTEM") for name, ok in avail.items(): diff --git a/kickoff.sh b/kickoff.sh index 44c3a26..c62cb82 100644 --- a/kickoff.sh +++ b/kickoff.sh @@ -240,6 +240,26 @@ first-use config (auth -> route -> model -> verify)." _mark_step tool_setup fi + # --- docker host fallback for local LLM endpoints -------------------- + # Catches the common Docker-in-container case where CLK_OLLAMA_ENDPOINT + # or CLK_OPENWEBUI_ENDPOINT default to http://localhost:... but the + # actual server is on the host. We probe both the configured URL and + # the host.docker.internal equivalent; if only the latter answers, + # we offer to rewrite .env — even if the user picked a different + # provider as active (the TUI's health check surfaces all of them). + if _should_run_step "docker_host_fallback"; then + _sv_explain "=== Local LLM endpoint check === +If you have ollama or OpenWebUI running on the host but CLK is in a +container, 'localhost' won't reach them. We'll probe each configured +endpoint and, when only host.docker.internal works, offer to switch." + _it_offer_docker_host_fallback "Ollama" CLK_OLLAMA_ENDPOINT \ + "${CLK_OLLAMA_ENDPOINT:-http://localhost:11434}" || true + _it_offer_docker_host_fallback "OpenWebUI" CLK_OPENWEBUI_ENDPOINT \ + "${CLK_OPENWEBUI_ENDPOINT:-http://localhost:8080}" || true + _mark_step docker_host_fallback + [ -s "$env_file" ] && { set -a; . "$env_file"; set +a; } + fi + # --- telegram -------------------------------------------------------- if _should_run_step "telegram"; then _sv_explain "=== Telegram bot (optional) === diff --git a/scripts/install_tool.sh b/scripts/install_tool.sh index d626662..2124e9c 100755 --- a/scripts/install_tool.sh +++ b/scripts/install_tool.sh @@ -100,6 +100,63 @@ _it_platform() { _it_has() { command -v "$1" >/dev/null 2>&1; } +# --------------------------------------------------------------------------- +# Endpoint reachability + host.docker.internal fallback. +# +# When CLK runs inside a Docker container, a localhost URL points at the +# container, not the host machine — so a user's ollama / openwebui running +# on the host shows up as UNAVAILABLE. Docker exposes the host at +# `host.docker.internal` (Linux needs --add-host=host.docker.internal:host-gateway). +# These helpers probe both, and if only the host.docker.internal variant +# answers, offer to rewrite the env file. +# --------------------------------------------------------------------------- +_it_probe_endpoint() { + local endpoint="$1" host port code + host="$(printf '%s' "$endpoint" | sed -E 's|^https?://||; s|/.*||; s|:.*||')" + port="$(printf '%s' "$endpoint" | sed -nE 's|^https?://[^:/]+:([0-9]+).*|\1|p')" + if [ -z "$port" ]; then + case "$endpoint" in https://*) port=443 ;; *) port=80 ;; esac + fi + [ -n "$host" ] || return 1 + if _it_has curl; then + code="$(curl -sS -o /dev/null -m 3 -w '%{http_code}' "$endpoint" 2>/dev/null)" || code="000" + [ -n "$code" ] && [ "$code" != "000" ] && return 0 + fi + (echo > "/dev/tcp/$host/$port") 2>/dev/null +} + +# offer_docker_host_fallback LABEL VAR_NAME CURRENT_URL +# If CURRENT_URL targets localhost/127.0.0.1 and isn't reachable, but +# the host.docker.internal equivalent IS reachable, warn and ask to switch. +# On accept, rewrites env file + exports the var. Returns 0 if switched. +_it_offer_docker_host_fallback() { + local label="$1" var_name="$2" current="$3" + [ -n "$current" ] || return 1 + case "$current" in + *://localhost*|*://127.0.0.1*) ;; + *) return 1 ;; + esac + if _it_probe_endpoint "$current"; then + return 1 + fi + local candidate + candidate="$(printf '%s' "$current" | sed -E 's#(://)(localhost|127\.0\.0\.1)#\1host.docker.internal#')" + [ "$candidate" = "$current" ] && return 1 + _it_probe_endpoint "$candidate" || return 1 + _it_say "" + _it_warn "$label endpoint $current is unreachable from here," + _it_warn "but $candidate IS reachable. This is the usual sign that CLK is" + _it_warn "running inside a container while $label is on the host —" + _it_warn "'localhost' inside the container does not reach the host." + if _it_confirm "Switch $var_name to $candidate?" "Y"; then + env_set "$_IT_ENV_FILE" "$var_name" "$candidate" + export "$var_name=$candidate" + _it_say "[install_tool] updated $var_name -> $candidate" + return 0 + fi + return 1 +} + # --------------------------------------------------------------------------- # Installer registry — one function per supported tool. Each one echoes # the canonical install command (so the caller can print it before @@ -247,6 +304,13 @@ install_tool() { _it_say "[install_tool] openwebui reachable at $_ow_endpoint — skipping install." return 0 fi + if _it_offer_docker_host_fallback "OpenWebUI" CLK_OPENWEBUI_ENDPOINT "$_ow_endpoint"; then + _ow_endpoint="$CLK_OPENWEBUI_ENDPOINT" + if check_tool openwebui; then + _it_say "[install_tool] openwebui reachable at $_ow_endpoint — skipping install." + return 0 + fi + fi _it_warn "still no openwebui server at $_ow_endpoint." fi @@ -463,6 +527,13 @@ _configure_ollama() { local endpoint endpoint="$(_it_read "Ollama endpoint" "${CLK_OLLAMA_ENDPOINT:-http://localhost:11434}")" env_set "$_IT_ENV_FILE" CLK_OLLAMA_ENDPOINT "$endpoint" + export CLK_OLLAMA_ENDPOINT="$endpoint" + + # Container-on-host rescue: localhost from inside Docker doesn't reach + # the host's ollama daemon; try host.docker.internal before giving up. + if _it_offer_docker_host_fallback "Ollama" CLK_OLLAMA_ENDPOINT "$endpoint"; then + endpoint="$CLK_OLLAMA_ENDPOINT" + fi # If the server isn't reachable, offer to start it. if ! check_tool ollama; then @@ -524,6 +595,10 @@ _configure_openwebui() { local endpoint key model endpoint="$(_it_read "OpenWebUI endpoint" "${CLK_OPENWEBUI_ENDPOINT:-http://localhost:8080}")" env_set "$_IT_ENV_FILE" CLK_OPENWEBUI_ENDPOINT "$endpoint" + export CLK_OPENWEBUI_ENDPOINT="$endpoint" + if _it_offer_docker_host_fallback "OpenWebUI" CLK_OPENWEBUI_ENDPOINT "$endpoint"; then + endpoint="$CLK_OPENWEBUI_ENDPOINT" + fi if _it_confirm "Set OpenWebUI API key now? (only needed for authenticated instances)" "N"; then key="$(_it_secret "Paste OpenWebUI API key")" [ -n "$key" ] && env_set "$_IT_ENV_FILE" CLK_OPENWEBUI_API_KEY "$key"