fix: declare Hermes auto image support and migrate running accounts
This commit is contained in:
1 parent
6b8d55fc92
commit
437745bcca
4 files changed
+189
-4
No files matched your search
@@ -15,6 +15,41 @@ credentials; requires manual iOS profile installation + full trust for WSS. Priv
|
||||
key URL remains404. Do not expose the entire cert directory. Initial container-copy
|
||||
profile disappeared during intervening deployment; persistent alias fixed it.
|
||||
|
||||
## Hermes auto image input — live receipt 2026-10-02
|
||||
|
||||
Verified upstream `/v1/responses` with model `auto`, an inline PNG containing
|
||||
red-left/blue-right panels, and reasoning effort `none`: HTTP200, served model
|
||||
`Qwen3.8-27b`, answer `Red Blue`, zero reasoning tokens. The initial control-exec
|
||||
probe was interrupted with exit137 during a control replacement; the successful
|
||||
probe ran from the stable administrator Hermes container.
|
||||
|
||||
The shared provisioner now declares only
|
||||
`providers.hope-upstream.models.auto.supports_vision: true`. Existing managed
|
||||
model lists migrate to metadata mappings, preserving other settings. A durable
|
||||
private reload marker ensures restart retries survive control interruption.
|
||||
No global vision override was added for other selectable models.
|
||||
|
||||
Deployed control image `hope-voice-control:vision-auto-v1`, also tagged `latest`,
|
||||
image `sha256:570319b3fd60c8161741a6c46cfc61f16a214383b8f8114c5692e00ca506197d`.
|
||||
Startup migrated and restarted both currently deployed accounts in place:
|
||||
|
||||
- `hope-hermes-admin-69101358`: ready; real authenticated gateway image request
|
||||
at port20000 returned HTTP200 and `Blue, red` for a blue-left/red-right PNG.
|
||||
- `hope-hermes-shane-fc44d140`: ready; real authenticated gateway image request
|
||||
at port20001 returned HTTP200 and `Green, red.` for a green-left/red-right PNG.
|
||||
|
||||
Both runtime capability lookups returned true for the custom provider's `auto`;
|
||||
both retained configs have the declaration and no pending reload marker. Account
|
||||
settings, credentials, container identities, ports and bind mounts were retained;
|
||||
voice remained healthy and control health returned OK. No unrelated deployment
|
||||
was changed. This qualifies gateway image input, not a browser upload workflow.
|
||||
|
||||
Protected config backups/control inspect receipt are under
|
||||
`/mnt/user/appdata/hope-voice/rollback/vision-20261002T201506Z`; original control is
|
||||
retained stopped as `hope-control-pre-vision-20261002T201506Z`. Clean source was
|
||||
staged separately; the NAS development checkout was not overwritten. Offline
|
||||
account/provisioning/bootstrap/deployment rollback regression:44 passed.
|
||||
|
||||
## Per-account Hermes WebUI — live receipt 2026-10-02
|
||||
|
||||
Installed pinned nesquena/hermes-webui `62e4d7b9e2560f9d49c0c29c2dc1414fbd4e2d12`
|
||||
|
||||
@@ -114,6 +114,28 @@ ports. Strict outbound-only enforcement needs a separate host ingress firewall
|
||||
policy; host networking alone cannot guarantee that. Host networking does not
|
||||
bypass LAN ACLs or configure mDNS/DNS.
|
||||
|
||||
## Image input capability
|
||||
|
||||
The managed upstream `auto` model is explicitly declared with
|
||||
`providers.hope-upstream.models.auto.supports_vision: true`. This is an exact-model
|
||||
capability, not a global `model.supports_vision` override: account-selected models
|
||||
are not automatically declared vision-capable. Non-auto `HERMES_UPSTREAM_MODEL`
|
||||
overrides require independent qualification before declaring image support.
|
||||
|
||||
New accounts receive this declaration. On control startup/reconciliation, retained
|
||||
accounts with an existing managed `auto` entry are migrated from a model list to a
|
||||
metadata mapping, preserving other model IDs, capability metadata and settings.
|
||||
Missing/custom provider declarations are not replaced with defaults. The migration
|
||||
only changes the verified `auto` capability and existing managed listener settings.
|
||||
|
||||
A private `config-reload-pending` marker is written before an existing config is
|
||||
changed. Running owned containers restart by immutable ID to load it; new/stopped
|
||||
containers load it on start. The marker clears only after authenticated gateway
|
||||
and (when enabled) WebUI health pass. Failed restarts/readiness retain the marker
|
||||
for retry, including across control restarts. Expect brief per-account downtime
|
||||
and interruption of active runs during migration. Credentials, ports, homes and
|
||||
workspaces remain unchanged. Readiness rejects accounts awaiting this migration.
|
||||
|
||||
## Per-account Hermes WebUI
|
||||
|
||||
Build `hermes-webui.Dockerfile` as `hope-hermes-webui:62e4d7b`. This packages the
|
||||
|
||||
+60
-3
@@ -252,6 +252,40 @@ class Provisioner:
|
||||
raise ProvisioningError('Invalid persisted Hermes API credential')
|
||||
return key
|
||||
|
||||
@staticmethod
|
||||
def _declare_auto_vision(value):
|
||||
"""Patch only the verified managed auto model, preserving other metadata."""
|
||||
providers = value.get('providers', {})
|
||||
if not isinstance(providers, dict):
|
||||
raise ValueError('Invalid provider configuration')
|
||||
provider = providers.get('hope-upstream')
|
||||
if provider is None:
|
||||
return False
|
||||
if not isinstance(provider, dict):
|
||||
raise ValueError('Invalid managed provider')
|
||||
models = provider.get('models', {})
|
||||
if isinstance(models, list):
|
||||
if not all(isinstance(model, str) for model in models):
|
||||
raise ValueError('Invalid model list')
|
||||
if 'auto' not in models:
|
||||
return False
|
||||
models = {model: {} for model in models}
|
||||
provider['models'] = models
|
||||
if not isinstance(models, dict):
|
||||
raise ValueError('Invalid model metadata')
|
||||
if 'auto' not in models:
|
||||
return False
|
||||
metadata = models['auto']
|
||||
if not isinstance(metadata, dict):
|
||||
raise ValueError('Invalid auto metadata')
|
||||
if metadata.get('supports_vision') is True:
|
||||
return False
|
||||
metadata['supports_vision'] = True
|
||||
return True
|
||||
|
||||
def _reload_marker(self, user):
|
||||
return self._paths(user)[0] / 'config-reload-pending'
|
||||
|
||||
def _prepare(self, user, exists, port):
|
||||
private, home, workspace = self._paths(user)
|
||||
self._directory(self.state_dir)
|
||||
@@ -270,7 +304,7 @@ class Provisioner:
|
||||
elif config.is_symlink():
|
||||
raise ProvisioningError('Hermes config cannot be a symlink')
|
||||
else:
|
||||
# Preserve user configuration; only control-owned listener settings change.
|
||||
# Preserve user configuration except managed listeners/verified auto capability.
|
||||
try:
|
||||
value = yaml.safe_load(config.read_text())
|
||||
if not isinstance(value, dict):
|
||||
@@ -278,8 +312,13 @@ class Provisioner:
|
||||
server = value.setdefault('gateway', {}).setdefault('api_server', {})
|
||||
if not isinstance(server, dict):
|
||||
raise ValueError()
|
||||
changed = self._declare_auto_vision(value)
|
||||
if server.get('host') != '127.0.0.1' or server.get('port') != port:
|
||||
server.update(host='127.0.0.1', port=port)
|
||||
changed = True
|
||||
if changed:
|
||||
# Durable before writing: retry must reload even after a crash.
|
||||
self._write_private(self._reload_marker(user), 'pending\n')
|
||||
self._write_private(config, yaml.safe_dump(value, sort_keys=False))
|
||||
except (ValueError, TypeError, AttributeError, yaml.YAMLError) as exc:
|
||||
raise ProvisioningError('Invalid Hermes runtime configuration') from exc
|
||||
@@ -298,7 +337,7 @@ class Provisioner:
|
||||
'model': {'default': model, 'provider': 'custom:hope-upstream'},
|
||||
'providers': {'hope-upstream': {'api': os.environ.get('HERMES_UPSTREAM_URL', 'http://192.168.86.5:2099/v1'),
|
||||
'key_env': 'HERMES_UPSTREAM_API_KEY', 'transport': 'codex_responses', 'default_model': model,
|
||||
'models': [model], 'discover_models': False}},
|
||||
'models': {'auto': {'supports_vision': True}} if model == 'auto' else [model], 'discover_models': False}},
|
||||
'agent': {'reasoning_effort': 'none'},
|
||||
'terminal': {'backend': 'local', 'cwd': '/workspace'},
|
||||
'platform_toolsets': {'api_server': ['terminal', 'file', 'web', 'browser', 'memory', 'skills', 'delegation']},
|
||||
@@ -422,8 +461,13 @@ class Provisioner:
|
||||
# Never start a host-networked container with an unexpected listener.
|
||||
self._connection(user, info, key)
|
||||
await self._remove_legacy_network(user)
|
||||
marker = self._reload_marker(user)
|
||||
if marker.is_symlink():
|
||||
raise ProvisioningError('Reload marker cannot be a symlink')
|
||||
if not info.get('State', {}).get('Running'):
|
||||
await self._request('POST', f'/containers/{info["Id"]}/start', allowed=(204, 304))
|
||||
elif marker.exists():
|
||||
await self._request('POST', f'/containers/{info["Id"]}/restart', allowed=(204,))
|
||||
# One overall deadline also bounds Docker inspect / health I/O; no
|
||||
# deletion on failed health, so a retry starts/adopts the same state.
|
||||
try:
|
||||
@@ -436,6 +480,10 @@ class Provisioner:
|
||||
if info.get('State', {}).get('Running'):
|
||||
connection = self._connection(user, info, key)
|
||||
if await self._healthy(connection):
|
||||
if marker.exists():
|
||||
if marker.is_symlink() or marker.resolve() != self._paths(user)[0].resolve() / 'config-reload-pending':
|
||||
raise ProvisioningError('Invalid reload marker path')
|
||||
marker.unlink()
|
||||
return connection
|
||||
await asyncio.sleep(self.poll_interval)
|
||||
except TimeoutError as exc:
|
||||
@@ -446,9 +494,18 @@ class Provisioner:
|
||||
info = await self._inspect(container_name(user))
|
||||
if not info or not info.get('State', {}).get('Running'):
|
||||
return False
|
||||
marker = self._reload_marker(user)
|
||||
if marker.exists() or marker.is_symlink():
|
||||
return False
|
||||
config = self._paths(user)[1] / 'config.yaml'
|
||||
if config.is_symlink():
|
||||
return False
|
||||
value = yaml.safe_load(config.read_text())
|
||||
if not isinstance(value, dict) or self._declare_auto_vision(value):
|
||||
return False
|
||||
key = self._read_key(user)
|
||||
return bool(key) and await self._healthy(self._connection(user, info, key))
|
||||
except (ProvisioningError, ValueError, OSError):
|
||||
except (ProvisioningError, ValueError, OSError, yaml.YAMLError):
|
||||
return False
|
||||
|
||||
async def remove(self, user: dict):
|
||||
|
||||
@@ -19,6 +19,8 @@ class DockerBoundary:
|
||||
def __init__(self):
|
||||
self.containers, self.networks, self.created, self.deleted = {}, {}, [], []
|
||||
self.fail_start = False
|
||||
self.restarted = []
|
||||
self.fail_restart = False
|
||||
|
||||
def __call__(self, request):
|
||||
path, method = request.url.path, request.method
|
||||
@@ -51,6 +53,13 @@ class DockerBoundary:
|
||||
if method == 'GET':
|
||||
return httpx.Response(200, json=info)
|
||||
if method == 'POST':
|
||||
if path.endswith('/restart'):
|
||||
self.restarted.append(key)
|
||||
if self.fail_restart:
|
||||
self.fail_restart = False
|
||||
return httpx.Response(500)
|
||||
elif not path.endswith('/start'):
|
||||
raise AssertionError(path)
|
||||
if self.fail_start:
|
||||
self.fail_start = False
|
||||
return httpx.Response(500)
|
||||
@@ -120,7 +129,7 @@ def test_account_isolation_and_runtime_configuration(boundary):
|
||||
assert config['model'] == {'default': 'auto', 'provider': 'custom:hope-upstream'}
|
||||
assert config['providers']['hope-upstream'] == {'api': 'http://192.168.86.5:2099/v1',
|
||||
'key_env': 'HERMES_UPSTREAM_API_KEY', 'transport': 'codex_responses', 'default_model': 'auto',
|
||||
'models': ['auto'], 'discover_models': False}
|
||||
'models': {'auto': {'supports_vision': True}}, 'discover_models': False}
|
||||
assert config['agent']['reasoning_effort'] == 'none'
|
||||
assert config['terminal'] == {'backend': 'local', 'cwd': '/workspace'}
|
||||
assert {'memory', 'browser', 'web', 'terminal', 'file', 'skills'} <= set(config['platform_toolsets']['api_server'])
|
||||
@@ -209,6 +218,68 @@ def test_owned_container_cannot_rotate_missing_private_secret(boundary):
|
||||
assert len(docker.created) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize('models', [
|
||||
['auto', 'text-only'],
|
||||
{'auto': {'supports_vision': False, 'context_window': 32768}, 'text-only': {'supports_vision': False}},
|
||||
])
|
||||
def test_auto_vision_migration_preserves_settings_and_reloads_once(boundary, models):
|
||||
p, docker, _ = boundary
|
||||
original = asyncio.run(p.ensure(ALICE))
|
||||
private, home, workspace = p._paths(ALICE)
|
||||
config = yaml.safe_load((home / 'config.yaml').read_text())
|
||||
config['providers']['hope-upstream']['models'] = models
|
||||
config['model']['default'] = 'text-only'
|
||||
config['agent']['reasoning_effort'] = 'high'
|
||||
config['retained'] = {'private': True}
|
||||
(home / 'config.yaml').write_text(yaml.safe_dump(config))
|
||||
(workspace / 'work.txt').write_text('retained')
|
||||
assert not asyncio.run(p.ready(ALICE))
|
||||
assert asyncio.run(p.ensure(ALICE)) == original
|
||||
result = yaml.safe_load((home / 'config.yaml').read_text())
|
||||
expected = copy.deepcopy(config)
|
||||
if isinstance(models, list):
|
||||
expected['providers']['hope-upstream']['models'] = {m: {} for m in models}
|
||||
expected['providers']['hope-upstream']['models']['auto']['supports_vision'] = True
|
||||
assert result == expected
|
||||
assert 'supports_vision' not in result['model']
|
||||
assert docker.restarted == ['id-' + container_name(ALICE)]
|
||||
assert not docker.deleted and len(docker.created) == 1
|
||||
assert (workspace / 'work.txt').read_text() == 'retained'
|
||||
assert not p._reload_marker(ALICE).exists()
|
||||
assert asyncio.run(p.ready(ALICE))
|
||||
asyncio.run(p.ensure(ALICE))
|
||||
assert len(docker.restarted) == 1
|
||||
|
||||
|
||||
def test_failed_reload_is_retried_after_control_restart(boundary):
|
||||
p, docker, _ = boundary
|
||||
original = asyncio.run(p.ensure(ALICE))
|
||||
_, home, _ = p._paths(ALICE)
|
||||
config = yaml.safe_load((home / 'config.yaml').read_text())
|
||||
config['providers']['hope-upstream']['models'] = ['auto']
|
||||
(home / 'config.yaml').write_text(yaml.safe_dump(config))
|
||||
docker.fail_restart = True
|
||||
with pytest.raises(ProvisioningError, match='HTTP 500'):
|
||||
asyncio.run(p.ensure(ALICE))
|
||||
assert p._reload_marker(ALICE).exists()
|
||||
assert not asyncio.run(p.ready(ALICE))
|
||||
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)) == original
|
||||
assert len(docker.restarted) == 2 and not docker.deleted
|
||||
assert not p._reload_marker(ALICE).exists()
|
||||
|
||||
|
||||
def test_vision_patch_skips_unverified_models_and_preserves_metadata():
|
||||
for config in ({'retained': True}, {'providers': {'hope-upstream': {'models': ['text-only']}}}):
|
||||
original = copy.deepcopy(config)
|
||||
assert Provisioner._declare_auto_vision(config) is False
|
||||
assert config == original
|
||||
for models in ('auto', [None], {'auto': None}):
|
||||
with pytest.raises(ValueError):
|
||||
Provisioner._declare_auto_vision({'providers': {'hope-upstream': {'models': models}}})
|
||||
|
||||
|
||||
def test_upstream_runtime_overrides(monkeypatch):
|
||||
monkeypatch.setenv('HERMES_UPSTREAM_URL', 'http://127.0.0.1:9999/v1')
|
||||
monkeypatch.setenv('HERMES_UPSTREAM_MODEL', 'served-model')
|
||||
|
||||
Reference in new issue
Block a user