feat: add Hermes WebUI provisioning and English Dragon HD Omni voices
Rebuild & Deploy / rebuild (push) Successful in 2m17s
Rebuild & Deploy / rebuild (push) Successful in 2m17s
This commit is contained in:
1 parent
47cc9c88a0
commit
6b8d55fc92
59 files changed
+1175
-70
No files matched your search
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user