feat: distinguish provider configuration and health

This commit is contained in:
mARTin-B78 2026-09-04 11:35:52 +02:00
parent 7c14f41cb9
commit ae9df4f7f0
9 changed files with 91 additions and 20 deletions

View File

@ -8,6 +8,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased] ## [Unreleased]
### Changed ### 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 normalization extraction.** Same-speaker post-cast merging now lives in
`audiobook-cast.js`, including dialogue-only merging, split-sentence repair, and nearby-duplicate removal, `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 phantom-speaker filtering, and speaker-alias consolidation, keeping deterministic segment cleanup separate from

View File

@ -99,8 +99,8 @@ architecture change: the larger Audiobook/Rehearser/style split.
- [x] **M — Add structured logs with request/job IDs.** Report provider, endpoint, - [x] **M — Add structured logs with request/job IDs.** Report provider, endpoint,
duration and failure reason without logging prompts, keys or book contents. duration and failure reason without logging prompts, keys or book contents.
- [ ] **M — Separate provider health from configuration status.** Show actionable - [x] **M — Separate provider health from configuration status.** TTS/STT discovery now returns independent
connectivity and authentication errors. `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 - [x] **M — Add visible job history and retry controls.** Expose recent failures and
safe retry state to users. safe retry state to users.
- [x] **L — Add memory diagnostics.** Show browser heap where supported, rendered rows, - [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. - [ ] Add structured logs with request and job IDs.
- [ ] Report provider, endpoint, duration, and error reason clearly. - [ ] 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 - [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. 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. - [ ] Avoid logging prompts, API keys, or book contents by default.

View File

@ -1 +1 @@
1.24.42 1.24.43

View File

@ -2,6 +2,7 @@
## Current shape ## 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, …). - **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`). - **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 ## Already optimised

View File

@ -166,6 +166,22 @@ def _stt_backend_url(settings: dict, backend: str) -> str:
return settings.get("whisper_url") or _WHISPER_DEFAULT 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: def _stt_backend_model(backend: str) -> str:
backend = _clean_stt_backend(backend) backend = _clean_stt_backend(backend)
if backend in {"nvidia_parakeet", "nvidia_router"}: 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 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("/") base = _validate_http_url(url, allow_private=True).rstrip("/")
# Users commonly paste an OpenAI-style `/v1` base URL. WhisperX exposes # Users commonly paste an OpenAI-style `/v1` base URL. WhisperX exposes
# health at the service root, while its transcription route is under # 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): elif isinstance(item, str):
models.append(item) models.append(item)
if not ok: 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 # 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 # 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 # seconds; while it runs, even /health blocks and the UI reports the
# otherwise healthy service as unreachable. The service health endpoint is # otherwise healthy service as unreachable. The service health endpoint is
# the authoritative liveness check; the real transcription path is tested # the authoritative liveness check; the real transcription path is tested
# when the user actually transcribes audio. # 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: def _transcription_text_from_response(resp: requests.Response) -> str:
@ -487,13 +504,16 @@ async def stt_backends():
ok = bool(api_key) ok = bool(api_key)
models: list[str] = ["whisper-large-v3-turbo", "whisper-large-v3", "distil-whisper-large-v3-en"] models: list[str] = ["whisper-large-v3-turbo", "whisper-large-v3", "distil-whisper-large-v3-en"]
else: 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({ items.append({
"id": backend, "id": backend,
"label": _stt_backend_label(backend, url), "label": _stt_backend_label(backend, url),
"url": url, "url": url,
"port": _backend_port_label(url), "port": _backend_port_label(url),
"available": ok, "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), "model": _stt_backend_model(backend),
"models": models, "models": models,
"metrics": _STT_BACKEND_METRICS.get(backend, {}), "metrics": _STT_BACKEND_METRICS.get(backend, {}),
@ -509,7 +529,8 @@ async def stt_backends():
if (card_id, url) in seen_urls: if (card_id, url) in seen_urls:
continue continue
seen_urls.add((card_id, url)) 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) port = _backend_port_label(url)
label = card.get("label") or card.get("name") or "Custom STT" label = card.get("label") or card.get("name") or "Custom STT"
if port: if port:
@ -520,6 +541,8 @@ async def stt_backends():
"url": url, "url": url,
"port": port, "port": port,
"available": ok, "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", "model": "whisper-1",
"models": models, "models": models,
"metrics": {}, "metrics": {},

View File

@ -321,8 +321,26 @@ def _backend_capabilities(backend: str) -> dict:
return caps.get(_clean_preview_backend(backend), {}) 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: 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: 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"): 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("/") url = _validate_http_url(_preview_backend_base_url(settings, backend), allow_private=True).rstrip("/")
voices = _fetch_backend_voices(settings, backend) 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) available = _backend_available(backend, voices, health)
items.append({ items.append({
"id": backend, "id": backend,
@ -619,6 +638,13 @@ async def tts_backends():
"url": url, "url": url,
"port": _backend_port_label(url), "port": _backend_port_label(url),
"available": available, "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, "voice_count": len(voices) if isinstance(voices, list) else 0,
**_backend_capabilities(backend), **_backend_capabilities(backend),
}) })

View File

@ -10,7 +10,7 @@
<meta name="format-detection" content="telephone=no"> <meta name="format-detection" content="telephone=no">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#2563EB"> <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="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml"> <link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icon.svg"> <link rel="apple-touch-icon" href="/static/icon.svg">
@ -27,10 +27,10 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── --> <!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css"> <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/style.css?v=1.24.43">
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.24.42"> <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.42"> <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.42"> <link rel="stylesheet" href="/static/css/voice-design.css?v=1.24.43">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -395,7 +395,7 @@ window.toggleNavTree = function(treeId, chevronId) {
</script> </script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton --> <!-- 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> </body>
</html> </html>

View File

@ -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>`); 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 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 || '')}`; 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) { 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>`); 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 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 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>`; const health = b.health || {};
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 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() { function updateBackendHelp() {

View File

@ -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({"segments": [{"text": " one "}, {"text": "two"}]})), "one two")
self.assertEqual(_transcription_text_from_response(ResponseStub(" plain text ")), "plain text") 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): def test_provider_retry_policy_is_bounded_and_closes_discarded_responses(self):
from unittest.mock import patch from unittest.mock import patch
from core.retry import request_with_retries, retry_delay from core.retry import request_with_retries, retry_delay