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:
parent
838445ba07
commit
214f2c61cf
44
server.py
44
server.py
@ -3498,18 +3498,48 @@ async def restart_local_container(name: str):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/probe-url")
|
@app.get("/api/probe-url")
|
||||||
async def probe_url(url: str):
|
async def probe_url(url: str, type: str = ""):
|
||||||
"""Server-side reachability check — avoids browser CORS restrictions."""
|
"""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:
|
try:
|
||||||
r = requests.get(url, timeout=5, allow_redirects=True,
|
r = requests.get(base + path, timeout=5, headers=hdrs, allow_redirects=True)
|
||||||
headers={"User-Agent": "TTS-Voice-Creator/probe"})
|
if r.status_code >= 500:
|
||||||
return {"ok": True, "status": r.status_code}
|
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:
|
except requests.exceptions.ConnectionError:
|
||||||
return {"ok": False, "error": "Connection refused"}
|
return {"ok": False, "error": "Connection refused"}
|
||||||
except requests.exceptions.Timeout:
|
except requests.exceptions.Timeout:
|
||||||
return {"ok": False, "error": "Timeout"}
|
return {"ok": False, "error": "Timeout (5 s)"}
|
||||||
except Exception as e:
|
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")
|
@app.post("/api/tts/restart-flags/clear")
|
||||||
|
|||||||
@ -7187,7 +7187,8 @@ function renderLocalContainers(containers) {
|
|||||||
btn.disabled = true; btn.textContent = 'Connecting…';
|
btn.disabled = true; btn.textContent = 'Connecting…';
|
||||||
try {
|
try {
|
||||||
if (window.probeUrl) {
|
if (window.probeUrl) {
|
||||||
const d = await window.probeUrl(rawUrl);
|
const type = card ? cardType(card) : '';
|
||||||
|
const d = await window.probeUrl(rawUrl, type);
|
||||||
if (d.ok) {
|
if (d.ok) {
|
||||||
btn.textContent = '✓ Connected'; btn.className = 'llm-local-ping dc-connect-btn ok';
|
btn.textContent = '✓ Connected'; btn.className = 'llm-local-ping dc-connect-btn ok';
|
||||||
if (card) card.classList.add('llm-local-card-online');
|
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');
|
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 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();
|
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) {
|
function applyCardState(card, key, connected, failed) {
|
||||||
card.classList.toggle('llm-local-card-online', connected);
|
card.classList.toggle('llm-local-card-online', connected);
|
||||||
card.classList.toggle('llm-local-card-offline', !connected && !!failed);
|
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.disabled = true;
|
||||||
btn.textContent = 'Connecting…';
|
btn.textContent = 'Connecting…';
|
||||||
try {
|
try {
|
||||||
const d = await probeUrl(rawUrl);
|
const type = cardType(card);
|
||||||
|
const d = await probeUrl(rawUrl, type);
|
||||||
applyCardState(card, key, d.ok, !d.ok);
|
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) {
|
} catch (e) {
|
||||||
applyCardState(card, key, false, true);
|
applyCardState(card, key, false, true);
|
||||||
toast('Probe failed: ' + e.message, 'error');
|
toast('Probe failed: ' + e.message, 'error');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user