feat: distinguish provider configuration and health
This commit is contained in:
parent
7c14f41cb9
commit
ae9df4f7f0
@ -8,6 +8,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- **Provider status clarity.** TTS and STT backend discovery now reports configuration separately from liveness,
|
||||
with bounded endpoint/status/error details shown in backend help text so unreachable services are actionable.
|
||||
- **Audiobook cast normalization extraction.** Same-speaker post-cast merging now lives in
|
||||
`audiobook-cast.js`, including dialogue-only merging, split-sentence repair, and nearby-duplicate removal,
|
||||
phantom-speaker filtering, and speaker-alias consolidation, keeping deterministic segment cleanup separate from
|
||||
|
||||
6
TODO.md
6
TODO.md
@ -99,8 +99,8 @@ architecture change: the larger Audiobook/Rehearser/style split.
|
||||
|
||||
- [x] **M — Add structured logs with request/job IDs.** Report provider, endpoint,
|
||||
duration and failure reason without logging prompts, keys or book contents.
|
||||
- [ ] **M — Separate provider health from configuration status.** Show actionable
|
||||
connectivity and authentication errors.
|
||||
- [x] **M — Separate provider health from configuration status.** TTS/STT discovery now returns independent
|
||||
`configured`, `available`, and bounded `health` details; backend help text shows actionable connectivity errors.
|
||||
- [x] **M — Add visible job history and retry controls.** Expose recent failures and
|
||||
safe retry state to users.
|
||||
- [x] **L — Add memory diagnostics.** Show browser heap where supported, rendered rows,
|
||||
@ -231,7 +231,7 @@ architecture change: the larger Audiobook/Rehearser/style split.
|
||||
|
||||
- [ ] Add structured logs with request and job IDs.
|
||||
- [ ] Report provider, endpoint, duration, and error reason clearly.
|
||||
- [ ] Separate health status from “configured”.
|
||||
- [x] Separate health status from “configured”; discovery APIs now preserve both states and expose sanitized probe errors.
|
||||
- [x] Add visible job history and safe retry state; the authenticated `/api/jobs` metadata endpoint now feeds a
|
||||
rendered panel with status, elapsed time, errors, cancellation, and an explicit reopen-source retry boundary.
|
||||
- [ ] Avoid logging prompts, API keys, or book contents by default.
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
## Current shape
|
||||
- **Backend**: FastAPI, modular — `server.py` (app + middleware) → `routes/*.py` (admin, tts, library, sources, stt, conversation, docker, settings) → `core/*.py` (audio, voice, config, validation, tts_helpers, …).
|
||||
- **Provider status**: TTS/STT discovery responses expose `configured` independently from `available`/`health`, with only bounded probe endpoint, HTTP status, and sanitized error details returned to the UI.
|
||||
- **Frontend**: ~15k lines of **plain global-scope JS** in `static/js/*.js`, loaded in order by `static/loader.js` (no bundler/build step). Sections are HTML fragments in `static/sections/*.html` fetched at runtime. Modules communicate via globals (`window._voices`, `$`, `escHtml`, `toast`, `saveMeta`, `loadVoiceLibrary`, …) and some runtime monkey-patching (e.g. `renderVoiceList` is reassigned in `voice-library.js`).
|
||||
|
||||
## Already optimised
|
||||
|
||||
@ -166,6 +166,22 @@ def _stt_backend_url(settings: dict, backend: str) -> str:
|
||||
return settings.get("whisper_url") or _WHISPER_DEFAULT
|
||||
|
||||
|
||||
def _stt_backend_configured(settings: dict, backend: str) -> bool:
|
||||
backend = _clean_stt_backend(backend)
|
||||
if backend == "custom:" or backend.startswith("custom:"):
|
||||
return bool(_stt_backend_url(settings, backend).strip())
|
||||
if backend == "groq_whisper":
|
||||
return bool(settings.get("groq_api_key", "").strip())
|
||||
keys = {
|
||||
"nvidia_parakeet": ("nvidia_asr_url",),
|
||||
"nvidia_router": ("nvidia_router_url",),
|
||||
"faster_whisper": ("faster_whisper_url",),
|
||||
"whisper_cpp": ("whisper_cpp_url",),
|
||||
"configured": ("whisper_url",),
|
||||
}.get(backend, ())
|
||||
return any(str(settings.get(key) or '').strip() for key in keys)
|
||||
|
||||
|
||||
def _stt_backend_model(backend: str) -> str:
|
||||
backend = _clean_stt_backend(backend)
|
||||
if backend in {"nvidia_parakeet", "nvidia_router"}:
|
||||
@ -227,7 +243,7 @@ def _make_minimal_wav(duration_ms: int = 500, sample_rate: int = 16000) -> bytes
|
||||
return header + data
|
||||
|
||||
|
||||
def _stt_backend_health(url: str) -> tuple[bool, list[str]]:
|
||||
def _stt_backend_health(url: str) -> dict:
|
||||
base = _validate_http_url(url, allow_private=True).rstrip("/")
|
||||
# Users commonly paste an OpenAI-style `/v1` base URL. WhisperX exposes
|
||||
# health at the service root, while its transcription route is under
|
||||
@ -249,14 +265,15 @@ def _stt_backend_health(url: str) -> tuple[bool, list[str]]:
|
||||
elif isinstance(item, str):
|
||||
models.append(item)
|
||||
if not ok:
|
||||
return False, models
|
||||
return {"ok": False, "models": models, "error": health.get("error") or models_result.get("error")}
|
||||
# Do not send a real audio transcription as a health probe. WhisperX's
|
||||
# batch server uses one worker and a probe consumes the GPU for several
|
||||
# seconds; while it runs, even /health blocks and the UI reports the
|
||||
# otherwise healthy service as unreachable. The service health endpoint is
|
||||
# the authoritative liveness check; the real transcription path is tested
|
||||
# when the user actually transcribes audio.
|
||||
return ok, models
|
||||
return {"ok": True, "models": models, "status": health.get("status") or models_result.get("status"),
|
||||
"endpoint": health.get("endpoint") or models_result.get("endpoint")}
|
||||
|
||||
|
||||
def _transcription_text_from_response(resp: requests.Response) -> str:
|
||||
@ -487,13 +504,16 @@ async def stt_backends():
|
||||
ok = bool(api_key)
|
||||
models: list[str] = ["whisper-large-v3-turbo", "whisper-large-v3", "distil-whisper-large-v3-en"]
|
||||
else:
|
||||
ok, models = _stt_backend_health(url)
|
||||
health_detail = _stt_backend_health(url)
|
||||
ok, models = health_detail.get("ok", False), health_detail.get("models", [])
|
||||
items.append({
|
||||
"id": backend,
|
||||
"label": _stt_backend_label(backend, url),
|
||||
"url": url,
|
||||
"port": _backend_port_label(url),
|
||||
"available": ok,
|
||||
"configured": _stt_backend_configured(settings, backend),
|
||||
"health": {key: health_detail.get(key) for key in ("ok", "status", "endpoint", "error") if health_detail.get(key) is not None},
|
||||
"model": _stt_backend_model(backend),
|
||||
"models": models,
|
||||
"metrics": _STT_BACKEND_METRICS.get(backend, {}),
|
||||
@ -509,7 +529,8 @@ async def stt_backends():
|
||||
if (card_id, url) in seen_urls:
|
||||
continue
|
||||
seen_urls.add((card_id, url))
|
||||
ok, models = _stt_backend_health(url)
|
||||
health_detail = _stt_backend_health(url)
|
||||
ok, models = health_detail.get("ok", False), health_detail.get("models", [])
|
||||
port = _backend_port_label(url)
|
||||
label = card.get("label") or card.get("name") or "Custom STT"
|
||||
if port:
|
||||
@ -520,6 +541,8 @@ async def stt_backends():
|
||||
"url": url,
|
||||
"port": port,
|
||||
"available": ok,
|
||||
"configured": _stt_backend_configured(settings, card_id),
|
||||
"health": {key: health_detail.get(key) for key in ("ok", "status", "endpoint", "error") if health_detail.get(key) is not None},
|
||||
"model": "whisper-1",
|
||||
"models": models,
|
||||
"metrics": {},
|
||||
|
||||
@ -321,8 +321,26 @@ def _backend_capabilities(backend: str) -> dict:
|
||||
return caps.get(_clean_preview_backend(backend), {})
|
||||
|
||||
|
||||
def _backend_health_detail(url: str) -> dict:
|
||||
return probe_provider(url, [("/health", None), ("/v1/health", None), ("/v1/audio/list_voices", None)], timeout=2)
|
||||
|
||||
|
||||
def _backend_health(url: str) -> bool:
|
||||
return bool(probe_provider(url, [("/health", None), ("/v1/health", None), ("/v1/audio/list_voices", None)], timeout=2)['ok'])
|
||||
return bool(_backend_health_detail(url).get('ok'))
|
||||
|
||||
|
||||
def _backend_configured(settings: dict, backend: str, voices: list) -> bool:
|
||||
"""Report explicit configuration independently from provider liveness."""
|
||||
keys = {
|
||||
"voice_clone": ("tts_url",), "streaming": ("tts_stream_url", "tts_url"),
|
||||
"customvoice": ("customvoice_url",), "voice_design": ("voice_design_url",),
|
||||
"nvidia_magpie": ("nvidia_tts_url", "nvidia_router_url"),
|
||||
"nvidia_zeroshot": ("nvidia_zeroshot_url", "nvidia_clone_url", "nvidia_router_url"),
|
||||
"nvidia_flow": ("nvidia_flow_url", "nvidia_clone_url", "nvidia_router_url"),
|
||||
"kokoro": ("kokoro_url",), "vibevoice": ("vibevoice_url",),
|
||||
"xtts": ("xtts_url",), "fishspeech": ("fishspeech_url",),
|
||||
}.get(_clean_preview_backend(backend), ())
|
||||
return bool(voices) if _clean_preview_backend(backend) in {"voice_clone", "streaming", "fishspeech"} and not any(str(settings.get(k) or '').strip() for k in keys) else any(str(settings.get(k) or '').strip() for k in keys)
|
||||
|
||||
|
||||
def _backend_available(backend: str, voices: list, health: bool) -> bool:
|
||||
@ -611,7 +629,8 @@ async def tts_backends():
|
||||
for backend in ("voice_clone", "voice_design", "customvoice", "streaming", "fishspeech", "kokoro", "vibevoice", "xtts", "nvidia_magpie", "nvidia_zeroshot", "nvidia_flow"):
|
||||
url = _validate_http_url(_preview_backend_base_url(settings, backend), allow_private=True).rstrip("/")
|
||||
voices = _fetch_backend_voices(settings, backend)
|
||||
health = _backend_health(url)
|
||||
health_detail = _backend_health_detail(url)
|
||||
health = bool(health_detail.get('ok'))
|
||||
available = _backend_available(backend, voices, health)
|
||||
items.append({
|
||||
"id": backend,
|
||||
@ -619,6 +638,13 @@ async def tts_backends():
|
||||
"url": url,
|
||||
"port": _backend_port_label(url),
|
||||
"available": available,
|
||||
"configured": _backend_configured(settings, backend, voices),
|
||||
"health": {
|
||||
"ok": health,
|
||||
"status": health_detail.get("status"),
|
||||
"endpoint": health_detail.get("endpoint"),
|
||||
"error": health_detail.get("error"),
|
||||
},
|
||||
"voice_count": len(voices) if isinstance(voices, list) else 0,
|
||||
**_backend_capabilities(backend),
|
||||
})
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="theme-color" content="#2563EB">
|
||||
<meta name="app-version" content="1.24.42">
|
||||
<meta name="app-version" content="1.24.43">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/static/icon.svg">
|
||||
@ -27,10 +27,10 @@
|
||||
|
||||
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
||||
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
||||
<link rel="stylesheet" href="/static/style.css?v=1.24.42">
|
||||
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.24.42">
|
||||
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.24.42">
|
||||
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.24.42">
|
||||
<link rel="stylesheet" href="/static/style.css?v=1.24.43">
|
||||
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.24.43">
|
||||
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.24.43">
|
||||
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.24.43">
|
||||
|
||||
|
||||
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
||||
@ -395,7 +395,7 @@ window.toggleNavTree = function(treeId, chevronId) {
|
||||
</script>
|
||||
|
||||
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
|
||||
<script src="/static/loader.js?v=1.24.42"></script>
|
||||
<script src="/static/loader.js?v=1.24.43"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -88,7 +88,12 @@ function backendHelpHtml(b, compact = false) {
|
||||
if (b.ram) metricParts.push(`<span class="backend-metric-tag"><span class="mdi mdi-memory"></span> ${escHtml(b.ram)}</span>`);
|
||||
const metrics = metricParts.length ? `<div class="backend-metrics-row">${metricParts.join('')}</div>` : '';
|
||||
const detail = compact ? escHtml(b.best_for || '') : `${escHtml(b.purpose || '')}<br><strong>Identity:</strong> ${escHtml(b.identity || '')}<br><strong>Style:</strong> ${escHtml(b.style || '')}<br><strong>Best for:</strong> ${escHtml(b.best_for || '')}`;
|
||||
return `<strong>${escHtml(b.label)}</strong><div class="backend-help-tags">${tags}</div>${metrics}<div>${detail}</div>`;
|
||||
const health = b.health || {};
|
||||
const state = b.available ? '<span class="backend-tag good">ready</span>'
|
||||
: b.configured ? '<span class="backend-tag warn">configured · unreachable</span>'
|
||||
: '<span class="backend-tag warn">not configured</span>';
|
||||
const error = !b.available && health.error ? `<div class="backend-health-error">${escHtml(health.error)}</div>` : '';
|
||||
return `<strong>${escHtml(b.label)}</strong> ${state}<div class="backend-help-tags">${tags}</div>${metrics}<div>${detail}</div>${error}`;
|
||||
}
|
||||
|
||||
function sttBackendHelpHtml(b) {
|
||||
@ -101,8 +106,12 @@ function sttBackendHelpHtml(b) {
|
||||
if (m.ram) metricParts.push(`<span class="backend-metric-tag"><span class="mdi mdi-memory"></span> ${escHtml(m.ram)}</span>`);
|
||||
const metrics = metricParts.length ? `<div class="backend-metrics-row">${metricParts.join('')}</div>` : '';
|
||||
const modelList = Array.isArray(b.models) && b.models.length ? ' Models: ' + b.models.slice(0, 4).join(', ') + '.' : '';
|
||||
const avail = b.available ? `<span class="backend-tag good">ready</span>` : `<span class="backend-tag warn">unavailable</span>`;
|
||||
return `<strong>${escHtml(b.label)}</strong> ${avail}${metrics}<div style="margin-top:4px;font-size:0.85em;opacity:.8">${escHtml(b.url)}${escHtml(modelList)}</div>`;
|
||||
const health = b.health || {};
|
||||
const state = b.available ? `<span class="backend-tag good">ready</span>`
|
||||
: b.configured ? `<span class="backend-tag warn">configured · unreachable</span>`
|
||||
: `<span class="backend-tag warn">not configured</span>`;
|
||||
const error = !b.available && health.error ? ` <span class="backend-health-error">${escHtml(health.error)}</span>` : '';
|
||||
return `<strong>${escHtml(b.label)}</strong> ${state}${metrics}<div style="margin-top:4px;font-size:0.85em;opacity:.8">${escHtml(b.url)}${escHtml(modelList)}${error}</div>`;
|
||||
}
|
||||
|
||||
function updateBackendHelp() {
|
||||
|
||||
@ -78,6 +78,16 @@ class SecurityValidationTests(unittest.TestCase):
|
||||
self.assertEqual(_transcription_text_from_response(ResponseStub({"segments": [{"text": " one "}, {"text": "two"}]})), "one two")
|
||||
self.assertEqual(_transcription_text_from_response(ResponseStub(" plain text ")), "plain text")
|
||||
|
||||
def test_provider_configuration_is_distinct_from_liveness(self):
|
||||
from routes.stt import _stt_backend_configured
|
||||
from routes.tts import _backend_configured
|
||||
self.assertFalse(_stt_backend_configured({}, "configured"))
|
||||
self.assertTrue(_stt_backend_configured({"whisper_url": "http://whisper"}, "configured"))
|
||||
self.assertFalse(_stt_backend_configured({}, "groq_whisper"))
|
||||
self.assertTrue(_stt_backend_configured({"groq_api_key": "configured"}, "groq_whisper"))
|
||||
self.assertFalse(_backend_configured({}, "customvoice", []))
|
||||
self.assertTrue(_backend_configured({"customvoice_url": "http://tts"}, "customvoice", []))
|
||||
|
||||
def test_provider_retry_policy_is_bounded_and_closes_discarded_responses(self):
|
||||
from unittest.mock import patch
|
||||
from core.retry import request_with_retries, retry_delay
|
||||
|
||||
Loading…
Reference in New Issue
Block a user