Make Connect button actually verify the API, not just TCP reachability

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 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-05-28 02:06:07 +02:00
parent 838445ba07
commit 214f2c61cf
2 changed files with 61 additions and 17 deletions

View File

@ -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."""
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(url, timeout=5, allow_redirects=True,
headers={"User-Agent": "TTS-Voice-Creator/probe"})
return {"ok": True, "status": r.status_code}
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"}
return {"ok": False, "error": "Timeout (5 s)"}
except Exception as e:
return {"ok": False, "error": str(e)}
last_err = str(e)
return {"ok": False, "error": last_err}
@app.post("/api/tts/restart-flags/clear")

View File

@ -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');