From 214f2c61cfa2c1e74c06881e08c3745dd72d9e17 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Thu, 28 May 2026 02:06:07 +0200 Subject: [PATCH] Make Connect button actually verify the API, not just TCP reachability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit probe-url now accepts a type param (llm/stt/tts) and checks service- specific endpoints: LLM → /v1/models with data[] key, STT → /health then /v1/models, TTS → /health then /voices endpoints. Random websites and wrong services are now rejected. Connect passes the card's section type; success toast shows which endpoint responded. Co-Authored-By: Claude Sonnet 4.6 --- server.py | 54 +++++++++++++++++++++++++++++++++++++++------------ static/app.js | 24 ++++++++++++++++++----- 2 files changed, 61 insertions(+), 17 deletions(-) diff --git a/server.py b/server.py index 2445641..344911c 100644 --- a/server.py +++ b/server.py @@ -3498,18 +3498,48 @@ async def restart_local_container(name: str): @app.get("/api/probe-url") -async def probe_url(url: str): - """Server-side reachability check — avoids browser CORS restrictions.""" - try: - r = requests.get(url, timeout=5, allow_redirects=True, - headers={"User-Agent": "TTS-Voice-Creator/probe"}) - return {"ok": True, "status": r.status_code} - except requests.exceptions.ConnectionError: - return {"ok": False, "error": "Connection refused"} - except requests.exceptions.Timeout: - return {"ok": False, "error": "Timeout"} - except Exception as e: - return {"ok": False, "error": str(e)} +async def probe_url(url: str, type: str = ""): + """Server-side API probe — checks service-specific endpoints and validates JSON responses.""" + base = url.rstrip("/") + hdrs = {"User-Agent": "TTS-Voice-Creator/probe"} + + # Ordered list of (path, json_key_that_must_exist_or_None) + if type == "llm": + checks = [("/v1/models", "data"), ("/api/tags", "models"), ("/api/version", None)] + elif type == "stt": + checks = [("/health", None), ("/v1/models", "data"), ("/v1/audio/transcriptions", None)] + elif type == "tts": + checks = [("/health", None), ("/v1/audio/voices", None), ("/speakers", None), ("/voices", None)] + else: + checks = [("", None)] + + last_err = "No response" + for path, json_key in checks: + try: + r = requests.get(base + path, timeout=5, headers=hdrs, allow_redirects=True) + if r.status_code >= 500: + last_err = f"HTTP {r.status_code} on {path or '/'}" + continue + if r.status_code >= 400 and path: + continue # try next endpoint + # If we expect a specific JSON key, verify it + if json_key: + try: + data = r.json() + if json_key not in data: + last_err = f"Unexpected response from {path} (missing '{json_key}')" + continue + except Exception: + last_err = f"{path} returned non-JSON (HTTP {r.status_code})" + continue + return {"ok": True, "status": r.status_code, "endpoint": path or "/"} + except requests.exceptions.ConnectionError: + return {"ok": False, "error": "Connection refused"} + except requests.exceptions.Timeout: + return {"ok": False, "error": "Timeout (5 s)"} + except Exception as e: + last_err = str(e) + return {"ok": False, "error": last_err} @app.post("/api/tts/restart-flags/clear") diff --git a/static/app.js b/static/app.js index a51a417..d23b0cb 100644 --- a/static/app.js +++ b/static/app.js @@ -7187,7 +7187,8 @@ function renderLocalContainers(containers) { btn.disabled = true; btn.textContent = 'Connecting…'; try { if (window.probeUrl) { - const d = await window.probeUrl(rawUrl); + const type = card ? cardType(card) : ''; + const d = await window.probeUrl(rawUrl, type); if (d.ok) { btn.textContent = '✓ Connected'; btn.className = 'llm-local-ping dc-connect-btn ok'; if (card) card.classList.add('llm-local-card-online'); @@ -7310,12 +7311,20 @@ document.querySelectorAll('.dc-refresh-btn').forEach(b => b.addEventListener('cl return raw.replace(/^(https?:\/\/)0\.0\.0\.0([\/:])/, '$1host.docker.internal$2'); } - async function probeUrl(rawUrl) { + async function probeUrl(rawUrl, type = '') { const url = normalizeProbeUrl(rawUrl); - const r = await fetch('/api/probe-url?' + new URLSearchParams({ url })); + const params = { url }; + if (type) params.type = type; + const r = await fetch('/api/probe-url?' + new URLSearchParams(params)); return r.json(); } + function cardType(card) { + const page = card.closest('[data-page]'); + if (page) return page.dataset.page; // 'llm', 'stt', 'tts' + return ''; + } + function applyCardState(card, key, connected, failed) { card.classList.toggle('llm-local-card-online', connected); card.classList.toggle('llm-local-card-offline', !connected && !!failed); @@ -7353,9 +7362,14 @@ document.querySelectorAll('.dc-refresh-btn').forEach(b => b.addEventListener('cl btn.disabled = true; btn.textContent = 'Connecting…'; try { - const d = await probeUrl(rawUrl); + const type = cardType(card); + const d = await probeUrl(rawUrl, type); applyCardState(card, key, d.ok, !d.ok); - if (!d.ok) toast('Cannot reach ' + normalizeProbeUrl(rawUrl) + ': ' + (d.error || 'No response'), 'error'); + if (d.ok) { + toast(`✓ ${type.toUpperCase() || 'Service'} reachable — ${d.endpoint}`, 'success'); + } else { + toast('Cannot reach ' + normalizeProbeUrl(rawUrl) + ': ' + (d.error || 'No response'), 'error'); + } } catch (e) { applyCardState(card, key, false, true); toast('Probe failed: ' + e.message, 'error');