Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions clk_harness/providers/_endpoint_fallback.py
Original file line number Diff line number Diff line change
@@ -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()
26 changes: 14 additions & 12 deletions clk_harness/providers/ollama.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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:
Expand Down
19 changes: 9 additions & 10 deletions clk_harness/providers/openwebui.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,13 @@
from __future__ import annotations

import json
import socket
import sys
import time
import traceback
import urllib.error
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

Expand Down Expand Up @@ -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)
Expand Down
24 changes: 24 additions & 0 deletions clk_harness/tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
20 changes: 20 additions & 0 deletions kickoff.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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) ===
Expand Down
75 changes: 75 additions & 0 deletions scripts/install_tool.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
Loading