diff --git a/routes/docker.py b/routes/docker.py
index d25e6ff..d089568 100644
--- a/routes/docker.py
+++ b/routes/docker.py
@@ -154,3 +154,52 @@ async def probe_url(url: str, type: str = ""):
except Exception as e:
last_err = str(e)
return {"ok": False, "error": last_err}
+
+@router.get("/api/engine-models")
+async def engine_models(url: str, type: str = ""):
+ """Return model IDs from an OpenAI-style engine URL without exposing host-only URLs to the browser."""
+ base = _normalize_service_url(url).rstrip("/")
+ if not base:
+ raise HTTPException(400, "URL is required")
+ candidates: list[str]
+ if base.endswith("/v1"):
+ candidates = [base + "/models", base[:-3].rstrip("/") + "/v1/models"]
+ else:
+ candidates = [base.rstrip("/") + "/v1/models", base.rstrip("/") + "/models"]
+ if type == "ollama":
+ candidates.append(base.rstrip("/") + "/api/tags")
+ last_err = "No response"
+ seen: set[str] = set()
+ models: list[str] = []
+ for endpoint in candidates:
+ try:
+ r = requests.get(endpoint, timeout=6, headers={"Authorization": "Bearer no-key", "User-Agent": "TTS-Voice-Creator/models"})
+ if r.status_code >= 400:
+ last_err = f"HTTP {r.status_code} from {endpoint}"
+ continue
+ payload = r.json()
+ data = payload.get("data") if isinstance(payload, dict) else None
+ if data is None and isinstance(payload, dict):
+ data = payload.get("models")
+ if data is None and isinstance(payload, list):
+ data = payload
+ if not isinstance(data, list):
+ last_err = f"Unexpected model response from {endpoint}"
+ continue
+ for item in data:
+ mid = ""
+ if isinstance(item, str):
+ mid = item
+ elif isinstance(item, dict):
+ mid = str(item.get("id") or item.get("name") or item.get("model") or "")
+ mid = mid.strip()
+ if mid and mid not in seen:
+ seen.add(mid)
+ models.append(mid)
+ if models:
+ return {"ok": True, "url": base, "endpoint": endpoint, "models": models}
+ except requests.exceptions.Timeout:
+ last_err = "Timeout fetching models"
+ except Exception as e:
+ last_err = str(e)
+ return {"ok": False, "url": base, "models": models, "error": last_err}
diff --git a/static/js/benchmark.js b/static/js/benchmark.js
index b27f25e..08e6938 100644
--- a/static/js/benchmark.js
+++ b/static/js/benchmark.js
@@ -257,6 +257,105 @@
return out;
}
+
+ function benchmarkModelList(engine) {
+ const values = [];
+ const add = (v) => {
+ const s = String(v || '').trim();
+ if (s && !values.includes(s)) values.push(s);
+ };
+ add(engine.model);
+ (engine.models || []).forEach(add);
+ if (!values.length) add('whisper-1');
+ return values;
+ }
+
+ async function fetchBenchmarkModels(engine) {
+ if (!engine?.url) return [];
+ try {
+ const d = await fetchJson('/api/engine-models?type=stt&url=' + encodeURIComponent(engine.url));
+ return Array.isArray(d.models) ? d.models.filter(Boolean) : [];
+ } catch (e) {
+ console.warn('[benchmark models]', engine.label || engine.id, e);
+ return [];
+ }
+ }
+
+ async function hydrateBenchmarkModels(engines) {
+ await Promise.all(engines.map(async (engine) => {
+ const found = await fetchBenchmarkModels(engine);
+ engine.models = benchmarkModelList({ ...engine, models: found.length ? found : engine.models });
+ if (!engine.models.includes(engine.model)) engine.model = engine.models[0] || engine.model || 'whisper-1';
+ }));
+ }
+
+ function renderBenchmarkModelOptions(index, filter = '') {
+ const engine = sttEngines[index];
+ const picker = document.querySelector(`.bench-model-picker[data-index="${index}"]`);
+ const list = picker?.querySelector('.bench-model-options');
+ if (!engine || !list) return;
+ const qv = String(filter || '').trim().toLowerCase();
+ const models = benchmarkModelList(engine).filter(m => !qv || m.toLowerCase().includes(qv));
+ list.innerHTML = models.length ? models.map(m => `
+ `).join('') : '
No matching models
';
+ }
+
+ function setBenchmarkModel(index, model) {
+ const engine = sttEngines[index];
+ if (!engine) return;
+ engine.model = String(model || '').trim() || engine.model || 'whisper-1';
+ const picker = document.querySelector(`.bench-model-picker[data-index="${index}"]`);
+ const hidden = picker?.querySelector('.bench-engine-model');
+ const label = picker?.querySelector('.bench-model-label');
+ if (hidden) hidden.value = engine.model;
+ if (label) label.textContent = engine.model;
+ picker?.querySelector('.bench-model-menu')?.setAttribute('hidden', '');
+ picker?.querySelector('.bench-model-trigger')?.classList.remove('open');
+ }
+
+ function closeBenchmarkModelPickers(except = null) {
+ document.querySelectorAll('.bench-model-picker').forEach(picker => {
+ if (except && picker === except) return;
+ picker.querySelector('.bench-model-menu')?.setAttribute('hidden', '');
+ picker.querySelector('.bench-model-trigger')?.classList.remove('open');
+ });
+ }
+
+ function bindBenchmarkModelPickers() {
+ document.querySelectorAll('.bench-model-picker').forEach(picker => {
+ picker.addEventListener('click', (ev) => ev.stopPropagation());
+ const index = Number(picker.dataset.index);
+ const trigger = picker.querySelector('.bench-model-trigger');
+ const menu = picker.querySelector('.bench-model-menu');
+ const search = picker.querySelector('.bench-model-search');
+ renderBenchmarkModelOptions(index);
+ trigger?.addEventListener('click', (ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ const opening = menu?.hasAttribute('hidden');
+ closeBenchmarkModelPickers(picker);
+ if (opening) {
+ menu?.removeAttribute('hidden');
+ trigger.classList.add('open');
+ search.value = '';
+ renderBenchmarkModelOptions(index);
+ setTimeout(() => search?.focus(), 0);
+ } else {
+ menu?.setAttribute('hidden', '');
+ trigger.classList.remove('open');
+ }
+ });
+ search?.addEventListener('input', () => renderBenchmarkModelOptions(index, search.value));
+ picker.querySelector('.bench-model-options')?.addEventListener('click', (ev) => {
+ const opt = ev.target.closest('.bench-model-option');
+ if (!opt) return;
+ setBenchmarkModel(index, opt.dataset.model || '');
+ });
+ });
+ }
+
async function loadBenchmarkSttEngines() {
const list = q('bench-stt-engines');
const count = q('bench-stt-engine-count');
@@ -274,6 +373,7 @@
label: b.label || b.id,
url: b.url,
model: (b.models && b.models[0]) || b.model || 'whisper-1',
+ models: b.models || [],
available: !!b.available,
source: 'configured',
}));
@@ -291,6 +391,7 @@
label: c.label || c.name,
url,
model,
+ models: c.models || [],
available: c.running === true || c.status === 'running',
source: c.stack || 'docker',
});
@@ -300,6 +401,8 @@
}
sttEngines = dedupeEngines(items);
+ if (count) count.textContent = sttEngines.length ? `Loading models for ${sttEngines.length} engine${sttEngines.length === 1 ? '' : 's'}...` : 'No engines found.';
+ await hydrateBenchmarkModels(sttEngines);
if (count) count.textContent = sttEngines.length ? `${sttEngines.length} engine${sttEngines.length === 1 ? '' : 's'} loaded.` : 'No engines found.';
if (!sttEngines.length) {
list.innerHTML = 'No STT engines found. Check Engines -> Speech to Text.
';
@@ -313,8 +416,19 @@
${esc(e.url || '')}
${e.available ? 'Running' : 'Stopped'}
-
+
+
+
+
+
`).join('');
+ bindBenchmarkModelPickers();
}
function selectedSttEngines() {
@@ -617,6 +731,7 @@
if (initialized || !q('bench-stt-run')) return;
initialized = true;
document.querySelectorAll('.bench-tab').forEach(btn => btn.addEventListener('click', () => setBenchTab(btn.dataset.benchTab)));
+ document.addEventListener('click', () => closeBenchmarkModelPickers());
q('bench-stt-refresh')?.addEventListener('click', loadBenchmarkSttEngines);
q('bench-stt-run')?.addEventListener('click', runSttBenchmark);
q('bench-tts-run')?.addEventListener('click', runTtsBenchmark);
diff --git a/static/style.css b/static/style.css
index 69d9576..a7a2405 100644
--- a/static/style.css
+++ b/static/style.css
@@ -2363,7 +2363,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.bench-tabs {
display: flex; gap: 4px; flex-wrap: wrap; align-items: flex-end;
- margin: 0 0 14px; padding: 0; border-bottom: 1px solid var(--border);
+ margin: 0; padding: 0 0 0 0; border-bottom: none;
}
.bench-tab {
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
@@ -2378,7 +2378,8 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.bench-tab .mdi { font-size: 18px; }
.bench-tab span:last-child { font-size: 11px; font-weight: 600; color: var(--subtext); }
.bench-pane { display: none; }
-.bench-pane.active { display: flex; flex-direction: column; gap: 14px; }
+.bench-pane.active { display: flex; flex-direction: column; gap: 14px; margin-top: 0; }
+.bench-pane.active > .card:first-child { border-top-left-radius: 0; }
.bench-card { gap: 14px; }
.bench-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.bench-card-head h3 { margin: 0; font-size: 15px; color: var(--text); }
@@ -2387,7 +2388,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.bench-engine-panel { border: 1px solid var(--border); border-radius: var(--radius); background: var(--panel); overflow: hidden; }
.bench-engine-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; border-bottom: 1px solid var(--border); font-size: 13px; }
.bench-engine-head span { color: var(--subtext); }
-.bench-engine-list { max-height: 320px; overflow: auto; display: flex; flex-direction: column; }
+.bench-engine-list { max-height: 420px; min-height: 180px; overflow: auto; display: flex; flex-direction: column; resize: vertical; }
.bench-engine-empty, .bench-empty { color: var(--subtext); font-size: 13px; padding: 16px; text-align: left; }
.bench-engine-item {
display: grid; grid-template-columns: auto minmax(180px, 1fr) auto minmax(160px, .45fr);
@@ -2402,7 +2403,28 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.bench-engine-state { font-size: 11px; font-weight: 700; padding: 2px 7px; border-radius: 999px; background: var(--panel); color: var(--subtext); border: 1px solid var(--border); }
.bench-engine-item.is-online .bench-engine-state { color: var(--green); background: rgba(22,163,74,.09); border-color: rgba(22,163,74,.28); }
.bench-engine-item.is-offline .bench-engine-state { color: var(--red); background: rgba(220,38,38,.07); border-color: rgba(220,38,38,.24); }
-.bench-engine-model { min-width: 0; background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 5px; padding: 6px 8px; font-family: monospace; font-size: 12px; }
+.bench-engine-model { min-width: 0; }
+.bench-model-picker { position: relative; min-width: 0; }
+.bench-model-trigger {
+ width: 100%; min-height: 32px; display: flex; align-items: center; justify-content: space-between; gap: 8px;
+ background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 5px;
+ padding: 6px 8px; font-family: monospace; font-size: 12px; cursor: pointer; text-align: left;
+}
+.bench-model-trigger.open, .bench-model-trigger:hover { border-color: var(--accent); filter: none; }
+.bench-model-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.bench-model-menu {
+ position: absolute; z-index: 240; top: calc(100% + 4px); left: 0; right: 0;
+ background: var(--surface); border: 1px solid var(--border); border-radius: 7px; box-shadow: var(--shadow);
+ overflow: hidden; min-width: min(520px, 90vw);
+}
+.bench-model-menu[hidden] { display: none !important; }
+.bench-model-search-row { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--border); color: var(--subtext); }
+.bench-model-search { flex: 1; min-width: 0; border: none; outline: none; background: transparent; color: var(--text); font-size: 13px; }
+.bench-model-options { max-height: 260px; overflow: auto; padding: 4px; }
+.bench-model-option { width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 9px; border: 0; border-radius: 5px; background: transparent; color: var(--text); font-family: monospace; font-size: 12px; cursor: pointer; text-align: left; }
+.bench-model-option:hover, .bench-model-option.selected { background: var(--hover); filter: none; }
+.bench-model-option.selected { color: var(--accent); font-weight: 700; }
+.bench-model-empty { color: var(--subtext); font-size: 12px; padding: 12px; }
.bench-status { font-size: 13px; color: var(--subtext); }
.bench-summary { display: flex; gap: 10px 18px; flex-wrap: wrap; min-height: 20px; }
.bench-table td strong { display: block; }