feat: add Hermes WebUI provisioning and English Dragon HD Omni voices
Rebuild & Deploy / rebuild (push) Successful in 2m17s

This commit is contained in:
snowspeeder committed 2026-10-02 16:07:24 -04:00
1 parent 47cc9c88a0
commit 6b8d55fc92
59 files changed
+1175 -70

No files matched your search

+14 -1
View File
@@ -4,13 +4,26 @@
```sh
python -m venv .venv-tests
.venv-tests/bin/python -m pip install pytest fastapi httpx
.venv-tests/bin/python -m pip install pytest pytest-asyncio -r control/requirements.txt
.venv-tests/bin/python -m pytest --noconftest tests/unit/test_accounts.py tests/unit/test_provisioner.py tests/unit/test_token_bootstrap.py tests/unit/test_deploy_rollback.py -q
agent/.venv/bin/python -m unittest discover -s tests/unit -p test_hermes_llm.py -v
```
Covers authorization, revocation, CSRF, lifecycle/restart failures, Docker ownership, authenticated server-derived room routing, SSE framing, content-only output, cancellation, redacted errors, and wait/final-answer callbacks.
## Azure English voice regression
```sh
.venv-tests/bin/python -m pytest --noconftest tests/unit/test_voice_catalog.py tests/unit/test_voice_settings_ui.py -q
```
Catalog tests mock Azure and the authenticated account boundary. Browser tests
require Playwright Chromium and use local assets with mocked APIs, never the NAS.
They cover the Ava Omni default, legacy HD preference migration, English accents,
search/selection, cache/failure recovery, and anonymous/revoked-account denial.
Derived Omni personas follow Microsoft's documented naming convention; individual
voice synthesis and regional availability still require live Azure qualification.
## Legacy tests
Top-level `test_*.py` and `conftest.py` describe the former OpenWebUI/MCP architecture and default to live Unraid. **Do not run them as the migrated regression suite.** Retained only as historical references: weather/dispatch/local memory and anonymous-token assumptions no longer apply. Hope Music remains an independent deployment.
+147 -6
View File
@@ -6,6 +6,8 @@ import os
import httpx
import pytest
import yaml
import socket
from control.provisioner import OwnershipError, Provisioner, ProvisioningError, container_name
@@ -39,6 +41,7 @@ class DockerBoundary:
spec = json.loads(request.content)
self.created.append(spec)
self.containers[name] = {'Id': 'id-' + name, 'Config': copy.deepcopy(spec),
'HostConfig': copy.deepcopy(spec['HostConfig']),
'State': {'Running': False}, 'NetworkSettings': {'Ports': {}}}
return httpx.Response(201, json={'Id': 'id-' + name})
key = path.split('/')[2]
@@ -99,11 +102,12 @@ def test_account_isolation_and_runtime_configuration(boundary):
assert env['HERMES_UPSTREAM_API_KEY'] == 'runtime-provider-secret'
assert env['API_SERVER_KEY'] == connection['hermes_api_key']
assert env['API_SERVER_ENABLED'] == 'true'
assert env['API_SERVER_HOST'] == '0.0.0.0'
assert env['API_SERVER_PORT'] == '8642'
assert env['API_SERVER_HOST'] == '127.0.0.1'
assert int(env['API_SERVER_PORT']) == int(connection['hermes_url'].rsplit(':', 1)[1])
host = spec['HostConfig']
assert host['NetworkMode'] == 'hope-hermes-net-' + user['id']
assert host['PortBindings']['8642/tcp'] == [{'HostIp': '127.0.0.1', 'HostPort': ''}]
assert host['NetworkMode'] == 'host'
assert 'PortBindings' not in host
assert 'ExposedPorts' not in spec
assert host['Memory'] > 0 and host['NanoCpus'] > 0 and host['PidsLimit'] > 0
assert host['Privileged'] is False and host['Init'] is True
assert host['CapDrop'] == ['ALL']
@@ -143,7 +147,8 @@ def test_retries_restart_reuse_key_and_preserve_home(boundary):
restarted = Provisioner(p.state_dir, docker_client=p.docker, health_client=p.health,
upstream_api_key='runtime-provider-secret', runtime_uid=p.runtime_uid, runtime_gid=p.runtime_gid)
assert asyncio.run(restarted.ensure(ALICE)) == result
assert json.loads((home / 'config.yaml').read_text()) == {'retained': True}
assert yaml.safe_load((home / 'config.yaml').read_text()) == {
'retained': True, 'gateway': {'api_server': {'host': '127.0.0.1', 'port': p._ports()[ALICE['id']]}}}
asyncio.run(restarted.remove(ALICE))
asyncio.run(restarted.remove(ALICE))
assert docker.deleted == [('id-' + container_name(ALICE), {'force': 'true', 'v': 'false'})]
@@ -185,7 +190,8 @@ def test_untrusted_paths_and_public_bind_fail_closed(boundary):
asyncio.run(p.ensure(user))
asyncio.run(p.ensure(ALICE))
info = docker.containers[container_name(ALICE)]
info['NetworkSettings']['Ports']['8642/tcp'][0]['HostIp'] = '0.0.0.0'
info['Config']['Env'] = [e.replace('API_SERVER_HOST=127.0.0.1', 'API_SERVER_HOST=0.0.0.0')
for e in info['Config']['Env']]
assert not asyncio.run(p.ready(ALICE))
with pytest.raises(ProvisioningError, match='loopback'):
asyncio.run(p.ensure(ALICE))
@@ -212,3 +218,138 @@ def test_upstream_runtime_overrides(monkeypatch):
assert provider['api'] == 'http://127.0.0.1:9999/v1'
assert provider['models'] == ['served-model']
assert provider['default_model'] == 'served-model'
def test_migrate_legacy_container_preserves_state_and_key(boundary):
p, docker, _ = boundary
old = asyncio.run(p.ensure(ALICE))
_, home, workspace = p._paths(ALICE)
(workspace / 'retained.txt').write_text('account work')
(home / 'config.yaml').write_text('custom: retained\ngateway:\n api_server:\n host: 0.0.0.0\n port: 8642\n')
info = docker.containers[container_name(ALICE)]
info['HostConfig']['NetworkMode'] = 'hope-hermes-net-' + ALICE['id']
net = info['HostConfig']['NetworkMode']
docker.networks[net] = {'Id': net, 'Name': net, 'Labels': info['Config']['Labels']}
assert not asyncio.run(p.ready(ALICE))
result = asyncio.run(p.ensure(ALICE))
assert result == old
assert len(docker.created) == 2 and len(docker.deleted) == 1
assert not docker.networks
assert (workspace / 'retained.txt').read_text() == 'account work'
config = yaml.safe_load((home / 'config.yaml').read_text())
assert config['custom'] == 'retained'
assert config['gateway']['api_server'] == {'host': '127.0.0.1', 'port': p._ports()[ALICE['id']]}
asyncio.run(p.ensure(ALICE))
assert len(docker.created) == 2
def test_ports_unique_concurrent_persisted_and_skip_busy_port(boundary):
p, docker, _ = boundary
with socket.socket() as occupied:
occupied.bind(('127.0.0.1', 0))
start = occupied.getsockname()[1]
p.api_port_range = (start, min(start + 10, 65535))
async def create():
return await asyncio.gather(p.ensure(ALICE), p.ensure(BOB))
alice, bob = asyncio.run(create())
ports = p._ports()
assert ports[ALICE['id']] != start
assert ports[ALICE['id']] != ports[BOB['id']]
assert (p.state_dir / 'api-ports.json').stat().st_mode & 0o777 == 0o600
restarted = Provisioner(p.state_dir, docker_client=p.docker, health_client=p.health,
upstream_api_key='runtime-provider-secret', runtime_uid=p.runtime_uid, runtime_gid=p.runtime_gid)
assert asyncio.run(restarted.ensure(ALICE)) == alice
def test_port_exhaustion_and_corrupt_registry_fail_closed(boundary):
p, docker, _ = boundary
with socket.socket() as occupied:
occupied.bind(('127.0.0.1', 0))
p.api_port_range = (occupied.getsockname()[1], occupied.getsockname()[1])
with pytest.raises(ProvisioningError, match='No available'):
asyncio.run(p.ensure(ALICE))
p._directory(p.state_dir)
p._write_private(p.state_dir / 'api-ports.json', json.dumps({ALICE['id']: 20000, BOB['id']: 20000}))
with pytest.raises(ProvisioningError, match='Duplicate'):
asyncio.run(p.ensure(ALICE))
assert not docker.created
def test_migration_start_failure_retries_without_losing_data(boundary):
p, docker, _ = boundary
result = asyncio.run(p.ensure(ALICE))
docker.containers[container_name(ALICE)]['HostConfig']['NetworkMode'] = 'legacy'
docker.fail_start = True
with pytest.raises(ProvisioningError, match='HTTP 500'):
asyncio.run(p.ensure(ALICE))
assert asyncio.run(p.ensure(ALICE)) == result
assert len(docker.created) == 2
def test_webui_migration_unique_ports_passwords_and_cookies(boundary, monkeypatch):
p, docker, _ = boundary
original = asyncio.run(p.ensure(ALICE))
p.webui_enabled = True
p.webui_host = '127.0.0.1'
p.image = 'hope-hermes-webui:62e4d7b'
async def healthy(connection):
return True
monkeypatch.setattr(p, '_healthy', healthy)
alice, bob = asyncio.run(p.ensure(ALICE)), asyncio.run(p.ensure(BOB))
assert alice == original
assert len(docker.created) == 3
a = dict(e.split('=', 1) for e in docker.created[1]['Env'])
b = dict(e.split('=', 1) for e in docker.created[2]['Env'])
assert a['HERMES_WEBUI_PORT'] != b['HERMES_WEBUI_PORT']
assert a['HERMES_WEBUI_PASSWORD'] != b['HERMES_WEBUI_PASSWORD']
assert a['HERMES_WEBUI_COOKIE_NAME'] != b['HERMES_WEBUI_COOKIE_NAME']
assert a['HERMES_WEBUI_PASSWORD'] != alice['hermes_api_key']
assert a['HERMES_WEBUI_GATEWAY_API_KEY'] == alice['hermes_api_key']
assert a['HERMES_WEBUI_GATEWAY_BASE_URL'] == alice['hermes_url']
assert a['HERMES_WEBUI_SECURE'] == '1'
assert a['HERMES_WEBUI_CHAT_BACKEND'] == 'gateway'
password = (p.state_dir / ALICE['id'] / 'webui-password')
assert password.stat().st_mode & 0o777 == 0o600
asyncio.run(p.ensure(ALICE))
assert len(docker.created) == 3
def test_webui_password_symlink_fails_closed(boundary, monkeypatch):
p, docker, _ = boundary
asyncio.run(p.ensure(ALICE))
private, _, _ = p._paths(ALICE)
(private / 'webui-password').symlink_to(private / 'api-key')
p.webui_enabled = True
p.webui_host = '127.0.0.1'
with pytest.raises(ProvisioningError, match='symlink'):
asyncio.run(p.ensure(ALICE))
assert len(docker.created) == 1
@pytest.mark.parametrize('password', ['x', 'short passphrase'])
def test_webui_accepts_user_chosen_short_password(boundary, monkeypatch, password):
p, docker, _ = boundary
asyncio.run(p.ensure(ALICE))
private, _, _ = p._paths(ALICE)
(private / 'webui-password').write_text(password + '\n')
p.webui_enabled = True
p.webui_host = '127.0.0.1'
async def healthy(connection):
return True
monkeypatch.setattr(p, '_healthy', healthy)
asyncio.run(p.ensure(ALICE))
env = dict(entry.split('=', 1) for entry in docker.created[-1]['Env'])
assert env['HERMES_WEBUI_PASSWORD'] == password
@pytest.mark.parametrize('password', ['', ' ', 'first\nsecond'])
def test_webui_rejects_blank_or_multiline_password_before_recreation(boundary, password):
p, docker, _ = boundary
asyncio.run(p.ensure(ALICE))
private, _, _ = p._paths(ALICE)
(private / 'webui-password').write_text(password)
p.webui_enabled = True
p.webui_host = '127.0.0.1'
with pytest.raises(ProvisioningError, match='nonempty and single-line'):
asyncio.run(p.ensure(ALICE))
assert not docker.deleted
+77
View File
@@ -0,0 +1,77 @@
"""Offline Azure catalog tests: no Azure credentials or live services needed."""
import asyncio
from unittest.mock import AsyncMock, patch
import httpx
from functools import wraps
def async_test(fn):
@wraps(fn)
def run(*args, **kwargs):
return asyncio.run(fn(*args, **kwargs))
return run
from web.voice_catalog import DEFAULT_VOICE, VoiceCatalog, english_voices, normalize_default_voice
from web import token_server
def test_english_catalog_and_omni_variants():
voices = english_voices([
{'ShortName': 'en-GB-SoniaNeural', 'DisplayName': 'Sonia', 'Locale': 'en-GB', 'Gender': 'Female'},
{'ShortName': 'en-US-Ava:DragonHDOmniLatestNeural', 'DisplayName': 'Ava', 'Locale': 'en-US', 'Gender': 'Female'},
{'ShortName': 'fr-FR-RemyMultilingualNeural', 'Locale': 'fr-FR', 'SecondaryLocaleList': ['en-US']},
{'ShortName': 'fr-FR-DeniseNeural', 'Locale': 'fr-FR'},
{'ShortName': 'en-US-MultiTalker-Ava-Andrew:DragonHDLatestNeural', 'Locale': 'en-US'},
])
ids = [v['id'] for v in voices]
assert ids[0] == DEFAULT_VOICE
assert 'en-GB-Sonia:DragonHDOmniLatestNeural' in ids
assert 'en-GB-SoniaNeural' in ids
assert 'fr-FR-RemyMultilingualNeural' in ids
assert not any('Denise' in v or 'MultiTalker' in v for v in ids)
assert len(ids) == len(set(ids))
def test_default_migration():
assert normalize_default_voice('') == DEFAULT_VOICE
assert normalize_default_voice('en-US-Ava:DragonHDLatestNeural') == DEFAULT_VOICE
assert normalize_default_voice('en-GB-SoniaNeural') == 'en-GB-SoniaNeural'
@async_test
async def test_cache_and_safe_fallback():
catalog = VoiceCatalog('eastus', 'private-key')
response = httpx.Response(200, json=[{'ShortName': 'en-US-AvaNeural', 'Locale': 'en-US', 'Gender': 'Female'}], request=httpx.Request('GET', 'https://example.test'))
with patch('httpx.AsyncClient.get', new=AsyncMock(return_value=response)) as get:
a, b = await asyncio.gather(catalog.get(), catalog.get())
assert a == b and a['catalog_available']
assert get.await_count == 1
a['voices'].clear()
assert (await catalog.get())['voices']
catalog._expires = 0
with patch('httpx.AsyncClient.get', new=AsyncMock(side_effect=httpx.ConnectError('private-key'))):
result = await catalog.get()
assert not result['catalog_available'] and len(result['voices']) > 1
assert 'private-key' not in str(result)
invalid = VoiceCatalog('evil.test/path', 'private-key')
with patch('httpx.AsyncClient.get', new=AsyncMock()) as get:
assert not (await invalid.get())['catalog_available']
get.assert_not_awaited()
@async_test
async def test_catalog_requires_authenticated_account(monkeypatch):
monkeypatch.setattr(token_server, 'CONTROL_SECRET', 'control-secret')
transport = httpx.ASGITransport(app=token_server.app)
async with httpx.AsyncClient(transport=transport, base_url='https://hope.test') as client:
assert (await client.get('/voices')).status_code == 401
response = httpx.Response(200, json={'user': {'id': 'alice'}}, request=httpx.Request('POST', 'https://example.test'))
with patch('httpx.AsyncClient.post', new=AsyncMock(return_value=response)), patch.object(token_server.catalog, 'get', new=AsyncMock(return_value={'voices': [], 'catalog_available': True})):
result = await client.get('/voices', headers={'Authorization': 'Bearer session'})
assert result.status_code == 200
assert result.headers['cache-control'] == 'no-store'
assert 'control-secret' not in result.text
response = httpx.Response(401, request=httpx.Request('POST', 'https://example.test'))
with patch('httpx.AsyncClient.post', new=AsyncMock(return_value=response)), patch.object(token_server.catalog, 'get', new=AsyncMock()) as get:
assert (await client.get('/voices', headers={'Authorization': 'Bearer revoked'})).status_code == 401
get.assert_not_awaited()
+63
View File
@@ -0,0 +1,63 @@
"""Offline browser coverage of the English voice picker using local assets."""
from pathlib import Path
from playwright.sync_api import sync_playwright, expect
ROOT = Path(__file__).resolve().parents[2]
def test_english_voice_search_selection_and_retry():
available = True
account = {'id': 'alice', 'username': 'alice', 'display_name': 'Alice', 'role': 'user', 'status': 'ready'}
voices = [
{'id': 'en-US-Ava:DragonHDOmniLatestNeural', 'name': 'Ava', 'locale': 'en-US', 'gender': 'Female', 'tag': 'Omni'},
{'id': 'en-GB-Sonia:DragonHDOmniLatestNeural', 'name': 'Sonia', 'locale': 'en-GB', 'gender': 'Female', 'tag': 'Omni'},
{'id': 'en-AU-WilliamNeural', 'name': 'William', 'locale': 'en-AU', 'gender': 'Male', 'tag': 'Neural'},
]
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={'width': 390, 'height': 844})
errors = []
page.on('pageerror', lambda e: errors.append(str(e)))
def route(req):
url = req.request.url
if '/api/auth/me' in url:
req.fulfill(json={'user': account})
elif url.endswith('/voices'):
if available:
req.fulfill(json={'voices': voices, 'default_voice': voices[0]['id'], 'catalog_available': True})
else:
req.fulfill(status=503, json={'error': 'Unavailable'})
elif '/api/settings/model' in url:
req.fulfill(json={'settings': {'model': 'auto', 'thinking_level': 'none'}, 'models': [], 'catalog_available': False})
else:
asset = ROOT / 'web' / (url.split('/', 3)[-1].split('?')[0] or 'index.html')
types = {'.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.svg': 'image/svg+xml'}
if asset.is_file():
req.fulfill(body=asset.read_bytes(), content_type=types.get(asset.suffix, 'application/octet-stream'))
else:
req.fulfill(status=404, body='')
page.route('https://hope.test/**', route)
page.add_init_script("localStorage.setItem('preferred_voice', 'en-US-Ava:DragonHDLatestNeural')")
page.goto('https://hope.test/'); page.wait_for_load_state('networkidle')
page.locator('#settingsBtn').click()
expect(page.locator('#voiceList [aria-checked=true]')).to_contain_text('Ava')
expect(page.locator('#voiceCatalogStatus')).to_contain_text('3 English voices')
page.locator('#voiceSearch').fill('en-GB')
expect(page.locator('#voiceList [role=radio]')).to_have_count(1)
page.locator('#voiceList [role=radio]').press('Enter')
assert page.evaluate("localStorage.getItem('preferred_voice')") == voices[1]['id']
page.locator('#voiceSearch').fill('no-matching-voice')
expect(page.locator('#voiceList')).to_contain_text('No matching voices')
page.locator('#voiceSearch').fill('')
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
page.screenshot(path='/tmp/hope-english-voices-mobile.png', full_page=True)
page.locator('#closeSheetBtn').click()
available = False
page.locator('#settingsBtn').click()
expect(page.locator('#retryVoices')).to_be_visible()
expect(page.locator('#voiceList [role=radio]')).to_have_count(3)
available = True
page.locator('#retryVoices').click()
expect(page.locator('#retryVoices')).to_be_hidden()
assert errors == []
browser.close()