feat: save account model and thinking settings for Hermes
Rebuild & Deploy / rebuild (push) Successful in 1m9s

This commit is contained in:
snowspeeder committed 2026-10-01 16:38:34 -04:00
1 parent a45569f8c1
commit 6cb4427210
13 files changed
+798 -16

No files matched your search

+22
View File
@@ -77,6 +77,28 @@ also passes `model_options.reasoning.enabled: false` and forwards content only.
assertion races; synchronization fixed, focused settings/large-type/account
switch tests passed. No claim of one fully green expanded UI run.
## Account model and thinking settings
- The authenticated Hope settings sheet loads the Manifest upstream `/v1/models`
catalog through the trusted control plane. Only public IDs/names and application
effort choices are returned; the browser never receives provider credentials.
- `GET` / `PUT /api/settings/model` operate only on the signed-in account. Preferences
persist in a separate SQLite table with account-deletion cascade. Existing accounts
default to `auto` / `none`. Cookie mutations require a trusted Origin.
- The speech bridge resolves authorized room settings at every request, including
existing conversations, and snapshots them for that turn. No interruption/replay.
It sends the chosen model with explicit `provider: custom:hope-upstream`; a bare
model would be ignored by the deployed Hermes API's default policy.
- Thinking choices are requested efforts, not guarantees of model support. Manifest
currently provides no capability metadata; Hermes may clamp levels or omit a
disabled reasoning field where an upstream model cannot represent `none`.
- Reasoning content is still excluded from transcripts/speech. Discovery failures
preserve saved settings and disable changes rather than silently selecting a model.
- Hermes gateway `/model` session overrides have higher precedence than ordinary
OpenAI-compatible request overrides. Hope does not expose that session command.
The deployed configuration inspected has no fallback chain. OpenAI SSE `model`
echoes the request and is not independent proof of the upstream executing model.
## Operational behavior and boundaries
- Each user has separate persistent state, API credential, bridge network,
+9 -1
View File
@@ -111,9 +111,17 @@ async def entrypoint(ctx: JobContext):
await stop_notice()
notice_task = spawn(play_notice(text))
async def model_settings():
# Fetch at each turn, not just on join/poll: saved account changes apply
# immediately to the next request even in already-connected rooms.
current = await resolve_room(ctx.room.name)
if current["user"]["id"] != user_id:
raise RuntimeError("Account mismatch")
return current["model_settings"]
bridge = HermesLLM(base_url=account["hermes_url"], api_key=account["hermes_api_key"], user_id=user_id,
room_id=ctx.room.name, on_progress=progress, on_wait_notice=wait_notice,
on_answer_start=stop_notice)
on_answer_start=stop_notice, get_model_settings=model_settings)
tts = azure.TTS(voice=DEFAULT_VOICE, sample_rate=24000,
speech_key=os.getenv("AZURE_SPEECH_KEY", ""), speech_region=os.getenv("AZURE_SPEECH_REGION", "eastus"))
session = AgentSession(stt=azure.STT(speech_key=os.getenv("AZURE_SPEECH_KEY", ""),
+19 -2
View File
@@ -103,6 +103,7 @@ class HermesLLM(lk_llm.LLM):
on_progress: ProgressCallback,
on_wait_notice: WaitNoticeCallback | None = None,
on_answer_start: Callable[[], Awaitable[None]] | None = None,
get_model_settings: Callable[[], Awaitable[dict[str, str]]] | None = None,
timeout_seconds: float = 600.0,
http_client: httpx.AsyncClient | None = None,
) -> None:
@@ -128,6 +129,7 @@ class HermesLLM(lk_llm.LLM):
self._on_wait_notice = on_wait_notice
self._last_wait_notice: str | None = None
self._on_answer_start = on_answer_start
self._get_model_settings = get_model_settings
self._timeout_seconds = timeout_seconds
self._owns_client = http_client is None
self._client = http_client if http_client is not None else httpx.AsyncClient(follow_redirects=False)
@@ -219,10 +221,25 @@ class HermesLLMStream(lk_llm.LLMStream):
await asyncio.gather(timer, return_exceptions=True)
async def _consume(self) -> None:
settings = {"model": "auto", "thinking_level": "none"}
if self._hermes._get_model_settings is not None:
try:
settings = await self._hermes._get_model_settings()
except Exception:
# Never fall back to stale preferences or run after revocation.
raise HermesLLMError("settings_unavailable") from None
model, effort = settings.get("model"), settings.get("thinking_level")
if (not isinstance(model, str) or not 1 <= len(model) <= 200
or not all(33 <= ord(c) <= 126 for c in model)
or effort not in ("none", "minimal", "low", "medium", "high", "xhigh")):
raise HermesLLMError("invalid_settings")
reasoning = {"enabled": False} if effort == "none" else {"enabled": True, "effort": effort}
payload = {
"model": "hermes-agent", "stream": True,
# Explicit provider is required: Hermes otherwise ignores bare model
# requests by default. Neither provider nor API origin is client input.
"model": model, "provider": "custom:hope-upstream", "stream": True,
"messages": _messages(self.chat_ctx),
"model_options": {"reasoning": {"enabled": False}},
"model_options": {"reasoning": reasoning},
}
finished = False
async with self._hermes._client.stream(
+38
View File
@@ -14,6 +14,8 @@ from pathlib import Path
PUBLIC_FIELDS = ('id', 'username', 'display_name', 'role', 'status', 'container_name', 'error')
SLUG = re.compile(r'[a-z][a-z0-9]*(?:-[a-z0-9]+)*\Z')
MODEL = re.compile(r'[!-~]{1,200}\Z')
THINKING_LEVELS = ('none', 'minimal', 'low', 'medium', 'high', 'xhigh')
def public_user(user):
@@ -74,6 +76,10 @@ class Accounts:
error TEXT, password_hash TEXT NOT NULL,
hermes_url TEXT, hermes_api_key TEXT, desired TEXT NOT NULL DEFAULT 'ready'
);
CREATE TABLE IF NOT EXISTS account_model_settings (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
model TEXT NOT NULL, thinking_level TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
hash TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at REAL NOT NULL
@@ -105,6 +111,38 @@ class Accounts:
row = self.db.execute('SELECT * FROM users WHERE id=?', (user_id,)).fetchone()
return dict(row) if row else None
def get_model_settings(self, user_id):
with self.lock:
# The join checks account availability and reads settings in one
# snapshot, including accounts that have not finished provisioning.
row = self.db.execute('''
SELECT COALESCE(s.model, 'auto') AS model,
COALESCE(s.thinking_level, 'none') AS thinking_level
FROM users u LEFT JOIN account_model_settings s ON s.user_id=u.id
WHERE u.id=? AND u.desired!='deleted'
''', (user_id,)).fetchone()
if row is None:
raise KeyError(user_id)
return dict(row)
def set_model_settings(self, user_id, model, thinking_level):
if not isinstance(model, str) or not MODEL.fullmatch(model):
raise ValueError('Model must contain 1–200 printable ASCII characters without whitespace')
if not isinstance(thinking_level, str) or thinking_level not in THINKING_LEVELS:
raise ValueError('Invalid thinking level')
with self.lock, self.db:
# Guard and upsert are one write statement: a delete on another
# connection cannot interleave between checking and storing.
cursor = self.db.execute('''
INSERT INTO account_model_settings(user_id,model,thinking_level)
SELECT id,?,? FROM users WHERE id=? AND desired!='deleted'
ON CONFLICT(user_id) DO UPDATE SET
model=excluded.model, thinking_level=excluded.thinking_level
''', (model, thinking_level, user_id))
if cursor.rowcount != 1:
raise KeyError(user_id)
return {'model': model, 'thinking_level': thinking_level}
def by_username(self, username):
with self.lock:
row = self.db.execute('SELECT * FROM users WHERE username=?', (username,)).fetchone()
+43 -2
View File
@@ -206,7 +206,7 @@ class Lifecycle:
await asyncio.gather(*tasks, return_exceptions=True)
def create_app(settings=None, provisioner=None):
def create_app(settings=None, provisioner=None, model_catalog=None):
settings = settings or Settings()
@asynccontextmanager
@@ -222,6 +222,9 @@ def create_app(settings=None, provisioner=None):
if adapter is None:
from .provisioner import Provisioner as HermesProvisioner
adapter = HermesProvisioner()
from .model_catalog import ModelCatalog
catalog = model_catalog if model_catalog is not None else ModelCatalog()
app.state.model_catalog = catalog
app.state.lifecycle = Lifecycle(store, adapter)
# Revalidate even previously-ready containers after control restart.
for user in store.users():
@@ -234,6 +237,8 @@ def create_app(settings=None, provisioner=None):
loop.cancel()
await asyncio.gather(loop, return_exceptions=True)
await app.state.lifecycle.stop()
if hasattr(catalog, 'aclose'):
await catalog.aclose()
if hasattr(adapter, 'aclose'):
await adapter.aclose()
finally:
@@ -315,6 +320,41 @@ def create_app(settings=None, provisioner=None):
async def me(user=Depends(current_user)):
return {'user': user}
@app.get('/api/settings/model')
async def model_settings(user=Depends(current_user)):
try:
saved = await asyncio.to_thread(app.state.accounts.get_model_settings, user['id'])
except KeyError:
raise HTTPException(401, 'Account unavailable') from None
try:
models = await app.state.model_catalog.models()
available, message = True, ''
except Exception:
models, available = [], False
message = 'Model list unavailable. Your saved settings are unchanged; try again.'
return {'settings': saved, 'models': models, 'catalog_available': available,
'message': message, 'thinking_note': 'Thinking levels are requests. Some models may limit or ignore them.'}
@app.put('/api/settings/model')
async def save_model_settings(request: Request, user=Depends(current_user)):
body = await bounded_json(request)
if set(body) != {'model', 'thinking_level'}:
raise HTTPException(400, 'Only model and thinking_level are accepted')
try:
models = await app.state.model_catalog.models()
except Exception:
raise HTTPException(503, 'Model list unavailable. Settings were not changed; try again.') from None
selected = next((m for m in models if m['id'] == body.get('model')), None)
if selected is None or body.get('thinking_level') not in selected['thinking_levels']:
raise HTTPException(400, 'Choose an available model and thinking level')
try:
saved = await asyncio.to_thread(app.state.accounts.set_model_settings, user['id'], body['model'], body['thinking_level'])
except ValueError:
raise HTTPException(400, 'Invalid model settings') from None
except KeyError:
raise HTTPException(401, 'Account unavailable') from None
return {'settings': saved, 'message': 'Saved for your account. Applies to your next request.'}
@app.get('/api/admin/users')
async def list_users(user=Depends(administrator)):
return {'users': [public_user(u) for u in await asyncio.to_thread(app.state.accounts.users)]}
@@ -366,7 +406,8 @@ def create_app(settings=None, provisioner=None):
raise HTTPException(404, 'User not found')
if user['status'] != 'ready' or not user['hermes_url'] or not user['hermes_api_key']:
raise HTTPException(503, 'User Hermes is not ready')
return {'user': public_user(user), 'hermes_url': user['hermes_url'], 'hermes_api_key': user['hermes_api_key']}
return {'user': public_user(user), 'hermes_url': user['hermes_url'], 'hermes_api_key': user['hermes_api_key'],
'model_settings': app.state.accounts.get_model_settings(user['id'])}
@app.get('/internal/users/{user_id}', dependencies=[Depends(internal_authorized)])
async def internal_user(user_id: str):
+63
View File
@@ -0,0 +1,63 @@
"""Trusted upstream discovery. Never return provider URLs, keys or raw metadata."""
from __future__ import annotations
import asyncio
import os
import time
import httpx
class CatalogUnavailable(Exception):
pass
class ModelCatalog:
def __init__(self, *, base_url=None, api_key=None, client=None):
self.base_url = (base_url or os.getenv('HERMES_UPSTREAM_URL', 'http://192.168.86.5:2099/v1')).rstrip('/')
self.api_key = api_key if api_key is not None else os.getenv('HERMES_UPSTREAM_API_KEY', '')
self.client = client or httpx.AsyncClient(timeout=10, trust_env=False, follow_redirects=False)
self._owns_client = client is None
self._cache = None
self._expires = 0
self._lock = asyncio.Lock()
async def aclose(self):
if self._owns_client:
await self.client.aclose()
async def models(self):
async with self._lock:
if self._cache is not None and time.monotonic() < self._expires:
return self._cache
try:
if not self.api_key:
raise CatalogUnavailable()
async with self.client.stream('GET', self.base_url + '/models', headers={'Authorization': 'Bearer ' + self.api_key}) as response:
response.raise_for_status()
body = bytearray()
async for chunk in response.aiter_bytes():
body.extend(chunk)
if len(body) > 2 * 1024 * 1024:
raise CatalogUnavailable()
import json
data = json.loads(body).get('data')
if not isinstance(data, list):
raise CatalogUnavailable()
models = []
seen = set()
for item in data[:1000]:
if not isinstance(item, dict):
continue
model = item.get('id')
if not isinstance(model, str) or not 1 <= len(model) <= 200 or not all(33 <= ord(c) <= 126 for c in model) or model in seen:
continue
seen.add(model)
models.append({'id': model, 'name': model, 'thinking_levels': ['none', 'minimal', 'low', 'medium', 'high', 'xhigh']})
if not models:
raise CatalogUnavailable()
self._cache = models
self._expires = time.monotonic() + 60
return models
except (httpx.HTTPError, ValueError, TypeError, AttributeError, CatalogUnavailable):
raise CatalogUnavailable() from None
+27 -1
View File
@@ -99,7 +99,7 @@ class HermesLLMTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(request.headers["x-hermes-session-id"], "stable-room")
self.assertEqual(request.headers["x-hermes-session-key"], "hope:alice")
self.assertEqual(json.loads(request.content), {
"model": "hermes-agent", "stream": True,
"model": "auto", "provider": "custom:hope-upstream", "stream": True,
"messages": [
{"role": "system", "content": "EXACT system prompt\nDo not alter."},
{"role": "user", "content": "hello\nworld"},
@@ -347,6 +347,32 @@ class HermesLLMTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(result.text, "Done")
self.assertEqual(len(self.requests), 1)
async def test_saved_model_and_thinking_refreshed_per_turn(self):
selected = {'model': 'openai/gpt-example', 'thinking_level': 'high'}
async def settings():
return dict(selected)
bridge = self.bridge(FakeSSE([data({'content': 'Done'}), b'data: [DONE]\n\n']), get_model_settings=settings)
for effort in ('high', 'none', 'low'):
selected['thinking_level'] = effort
await bridge.chat(chat_ctx=self.context(), extra_kwargs={'provider': 'evil', 'model': 'evil'}).collect()
body = json.loads(self.requests[-1].content)
self.assertEqual(body['model'], 'openai/gpt-example')
self.assertEqual(body['provider'], 'custom:hope-upstream')
expected = {'enabled': False} if effort == 'none' else {'enabled': True, 'effort': effort}
self.assertEqual(body['model_options']['reasoning'], expected)
selected['model'] = 'intel-b70-1/Qwen3.8-27b'
await bridge.chat(chat_ctx=self.context()).collect()
self.assertEqual(json.loads(self.requests[-1].content)['model'], selected['model'])
async def test_settings_failure_does_not_call_hermes_or_leak(self):
async def broken():
raise RuntimeError('private-key')
with self.assertRaises(HermesLLMError) as caught:
await self.bridge(FakeSSE([]), get_model_settings=broken).chat(chat_ctx=self.context()).collect()
self.assertEqual(caught.exception.code, 'settings_unavailable')
self.assertNotIn('private-key', str(caught.exception))
self.assertEqual(self.requests, [])
async def test_transport_exception_redacted(self):
def broken(request):
self.requests.append(request)
+173
View File
@@ -0,0 +1,173 @@
"""Offline account-wide model preferences; no HTTP, Docker, or provider calls."""
from concurrent.futures import ThreadPoolExecutor
import importlib.util
from pathlib import Path
import sqlite3
import threading
import pytest
# Load only the stdlib store; control.__init__ imports the optional FastAPI app.
_spec = importlib.util.spec_from_file_location(
'model_preferences_accounts', Path(__file__).resolve().parents[2] / 'control' / 'accounts.py'
)
_accounts = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_accounts)
Accounts = _accounts.Accounts
public_user = _accounts.public_user
PASSWORD = 'correct-horse-password'
DEFAULTS = {'model': 'auto', 'thinking_level': 'none'}
@pytest.fixture
def accounts(tmp_path):
store = Accounts(str(tmp_path / 'accounts.sqlite3'))
try:
yield store
finally:
store.close()
def create_user(store, username='alice'):
return store.create(username, username.title(), 'user', PASSWORD)['id']
def test_defaults_and_saved_state_survive_reopen(tmp_path):
path = str(tmp_path / 'accounts.sqlite3')
store = Accounts(path)
try:
user_id = create_user(store)
assert store.get_model_settings(user_id) == DEFAULTS
assert store.get(user_id)['status'] == 'provisioning'
saved = {'model': 'openai/gpt-5.4', 'thinking_level': 'xhigh'}
assert store.set_model_settings(user_id, **saved) == saved
finally:
store.close()
reopened = Accounts(path)
try:
assert reopened.get_model_settings(user_id) == saved
updated = {'model': 'anthropic/claude-sonnet-4.6', 'thinking_level': 'low'}
reopened.set_model_settings(user_id, **updated)
assert reopened.get_model_settings(user_id) == updated
assert reopened.db.execute('SELECT count(*) FROM account_model_settings').fetchone()[0] == 1
finally:
reopened.close()
def test_existing_database_migrates_with_default_preferences(tmp_path):
path = str(tmp_path / 'legacy.sqlite3')
with sqlite3.connect(path) as legacy:
legacy.executescript('''
CREATE TABLE users (
id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL, role TEXT NOT NULL,
status TEXT NOT NULL, container_name TEXT NOT NULL,
error TEXT, password_hash TEXT NOT NULL,
hermes_url TEXT, hermes_api_key TEXT,
desired TEXT NOT NULL DEFAULT 'ready'
);
CREATE TABLE rooms (
room_name TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at REAL NOT NULL
);
INSERT INTO users(id,username,display_name,role,status,container_name,password_hash)
VALUES('existing','existing','Existing','user','error','hope-hermes-existing','unused');
''')
store = Accounts(path)
try:
assert store.get_model_settings('existing') == DEFAULTS
assert store.get('existing')['status'] == 'error'
assert store.db.execute('SELECT count(*) FROM account_model_settings').fetchone()[0] == 0
store.set_model_settings('existing', 'openai/gpt-5', 'medium')
assert store.get_model_settings('existing') == {'model': 'openai/gpt-5', 'thinking_level': 'medium'}
finally:
store.close()
def test_tenant_isolation_and_public_schema_unchanged(accounts):
alice = create_user(accounts)
bob = create_user(accounts, 'bob')
before = public_user(accounts.get(alice))
accounts.set_model_settings(alice, 'openai/gpt-5', 'high')
assert accounts.get_model_settings(bob) == DEFAULTS
accounts.set_model_settings(bob, 'local/model:v2', 'minimal')
assert accounts.get_model_settings(alice) == {'model': 'openai/gpt-5', 'thinking_level': 'high'}
assert accounts.get_model_settings(bob) == {'model': 'local/model:v2', 'thinking_level': 'minimal'}
assert public_user(accounts.get(alice)) == before
assert set(before) == {'id', 'username', 'display_name', 'role', 'status', 'container_name', 'error'}
result = accounts.get_model_settings(alice)
result['model'] = 'tampered'
assert accounts.get_model_settings(alice)['model'] == 'openai/gpt-5'
def test_missing_deleting_and_deleted_accounts_reject_and_cascade(accounts):
with pytest.raises(KeyError):
accounts.get_model_settings('missing')
with pytest.raises(KeyError):
accounts.set_model_settings('missing', 'auto', 'none')
user_id = create_user(accounts)
accounts.set_model_settings(user_id, 'openai/gpt-5', 'high')
accounts.begin_delete(user_id)
with pytest.raises(KeyError):
accounts.get_model_settings(user_id)
with pytest.raises(KeyError):
accounts.set_model_settings(user_id, 'auto', 'none')
accounts.finish_delete(user_id)
assert accounts.db.execute('SELECT * FROM account_model_settings WHERE user_id=?', (user_id,)).fetchone() is None
with pytest.raises(KeyError):
accounts.get_model_settings(user_id)
with pytest.raises(KeyError):
accounts.set_model_settings(user_id, 'auto', 'none')
assert accounts.db.execute('SELECT count(*) FROM account_model_settings').fetchone()[0] == 0
@pytest.mark.parametrize('model', [None, 123, [], '', 'a' * 201, 'two models', ' auto', 'auto ',
'auto\n', 'auto\t', 'auto\r', 'a\x00b', 'a\x1fb', 'a\x7fb', 'café', 'a\u00a0b'])
def test_invalid_models_reject_without_changing_saved_state(accounts, model):
user_id = create_user(accounts)
accounts.set_model_settings(user_id, 'valid/model', 'low')
with pytest.raises(ValueError):
accounts.set_model_settings(user_id, model, 'high')
assert accounts.get_model_settings(user_id) == {'model': 'valid/model', 'thinking_level': 'low'}
@pytest.mark.parametrize('thinking_level', [None, 1, [], '', 'NONE', 'disabled', 'max', ' high', 'high ', 'high\n'])
def test_invalid_thinking_levels_reject_without_creating_settings(accounts, thinking_level):
user_id = create_user(accounts)
with pytest.raises(ValueError):
accounts.set_model_settings(user_id, 'auto', thinking_level)
assert accounts.get_model_settings(user_id) == DEFAULTS
assert accounts.db.execute('SELECT count(*) FROM account_model_settings').fetchone()[0] == 0
@pytest.mark.parametrize('thinking_level', ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'])
def test_all_thinking_levels_and_model_length_boundary(accounts, thinking_level):
user_id = create_user(accounts)
accounts.set_model_settings(user_id, 'a' * 200, thinking_level)
assert accounts.get_model_settings(user_id) == {'model': 'a' * 200, 'thinking_level': thinking_level}
def test_set_is_guarded_against_deletion_on_another_connection(accounts, tmp_path):
user_id = create_user(accounts)
accounts.set_model_settings(user_id, 'original/model', 'none')
other = Accounts(str(tmp_path / 'accounts.sqlite3'))
attempted = threading.Event()
other.db.set_trace_callback(lambda sql: attempted.set() if 'INSERT INTO account_model_settings' in sql else None)
try:
with ThreadPoolExecutor(max_workers=1) as executor:
with accounts.db:
accounts.db.execute('BEGIN IMMEDIATE')
accounts.db.execute("UPDATE users SET desired='deleted',status='deleting' WHERE id=?", (user_id,))
future = executor.submit(other.set_model_settings, user_id, 'new/model', 'high')
assert attempted.wait(timeout=2), 'Writer did not attempt its guarded statement'
with pytest.raises(KeyError):
future.result(timeout=5)
row = accounts.db.execute('SELECT model,thinking_level FROM account_model_settings WHERE user_id=?', (user_id,)).fetchone()
assert dict(row) == {'model': 'original/model', 'thinking_level': 'none'}
accounts.finish_delete(user_id)
assert accounts.db.execute('SELECT count(*) FROM account_model_settings').fetchone()[0] == 0
finally:
other.close()
+105
View File
@@ -0,0 +1,105 @@
"""Offline authenticated model settings/discovery contracts."""
import json
import pytest
import httpx
from fastapi.testclient import TestClient
from control.app import Settings, create_app
from control.model_catalog import ModelCatalog, CatalogUnavailable
PASSWORD = 'correct-horse-password'
SECRET = 'internal-secret-' + 'x' * 32
class Provisioner:
async def ensure(self, user):
return {'container_name': user['container_name'], 'hermes_url': 'http://127.0.0.1:1234', 'hermes_api_key': 'private-hermes-key'}
async def ready(self, user): return True
async def remove(self, user): pass
class Catalog:
unavailable = False
async def models(self):
if self.unavailable: raise RuntimeError('secret-upstream-key')
return [{'id': 'auto', 'name': 'auto', 'thinking_levels': ['none', 'high']},
{'id': 'openai/test', 'name': 'openai/test', 'thinking_levels': ['none', 'high']}]
@pytest.fixture
def env(tmp_path):
catalog = Catalog()
app = create_app(Settings(db_path=str(tmp_path/'accounts.db'), admin_password=PASSWORD,
service_secret=SECRET, allowed_origins=('https://testserver',), reconcile_interval=3600), Provisioner(), catalog)
with TestClient(app, base_url='https://testserver') as client:
yield client, app, catalog
def login(client, name='admin'):
result = client.post('/api/auth/login', json={'username': name, 'password': PASSWORD}, headers={'Origin':'https://testserver'})
assert result.status_code == 200
return {'Authorization': 'Bearer ' + result.json()['token']}, result.json()['user']['id']
def test_authenticated_saved_settings_are_account_scoped(env):
client, app, catalog = env
assert client.get('/api/settings/model').status_code == 401
headers, uid = login(client)
assert client.get('/api/settings/model', headers=headers).json()['settings'] == {'model':'auto', 'thinking_level':'none'}
saved = {'model':'openai/test', 'thinking_level':'high'}
assert client.put('/api/settings/model', headers=headers, json=saved).json()['settings'] == saved
created = client.post('/api/admin/users', headers=headers, json={'username':'other', 'display_name':'Other', 'password':PASSWORD})
assert created.status_code == 201
other, _ = login(client, 'other')
assert client.get('/api/settings/model', headers=other).json()['settings'] == {'model':'auto', 'thinking_level':'none'}
assert client.get('/api/settings/model', headers=headers).json()['settings'] == saved
app.state.accounts.update(uid, status='ready', hermes_url='http://127.0.0.1:1234', hermes_api_key='private-hermes-key')
response = client.get('/internal/users/'+uid, headers={'X-Control-Secret':SECRET})
assert response.status_code == 200
assert response.json()['model_settings'] == saved
assert 'secret' not in json.dumps(client.get('/api/settings/model', headers=headers).json())
@pytest.mark.parametrize('body', [
{'model':'unlisted', 'thinking_level':'none'},
{'model':'auto', 'thinking_level':'ultra'},
{'model':'auto', 'thinking_level':'none', 'provider':'evil'},
{'model':'auto', 'thinking_level':'none', 'user_id':'another'},
{'model':'auto', 'thinking_level':{'enabled':False}},
])
def test_rejects_untrusted_model_effort_and_routing(env, body):
client, app, catalog = env
headers, uid = login(client)
assert client.put('/api/settings/model', headers=headers, json=body).status_code == 400
assert app.state.accounts.get_model_settings(uid) == {'model':'auto', 'thinking_level':'none'}
def test_csrf_and_catalog_failure_preserve_settings(env):
client, app, catalog = env
headers, uid = login(client)
saved = {'model':'openai/test', 'thinking_level':'high'}
assert client.put('/api/settings/model', headers=headers, json=saved).status_code == 200
assert client.put('/api/settings/model', json=saved).status_code == 403
assert client.put('/api/settings/model', headers={'Origin':'https://evil'}, json=saved).status_code == 403
catalog.unavailable = True
get = client.get('/api/settings/model', headers=headers)
assert get.status_code == 200 and not get.json()['catalog_available']
assert get.json()['settings'] == saved
put = client.put('/api/settings/model', headers=headers, json={'model':'auto','thinking_level':'none'})
assert put.status_code == 503 and 'secret' not in put.text
assert app.state.accounts.get_model_settings(uid) == saved
@pytest.mark.asyncio
async def test_catalog_projects_metadata_and_caches():
calls = []
def handle(request):
calls.append(request)
return httpx.Response(200,json={'data':[{'id':'auto','api_key':'NEVER'}, {'id':'openai/test'}, {'id':'bad model'}, {'id':'auto'}]})
async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as client:
catalog = ModelCatalog(base_url='https://trusted/v1', api_key='SECRET', client=client)
first = await catalog.models()
assert [m['id'] for m in first] == ['auto','openai/test']
assert 'NEVER' not in json.dumps(first)
assert await catalog.models() == first and len(calls) == 1
assert calls[0].headers['Authorization'] == 'Bearer SECRET'
assert str(calls[0].url) == 'https://trusted/v1/models'
@pytest.mark.asyncio
async def test_catalog_errors_are_redacted():
async with httpx.AsyncClient(transport=httpx.MockTransport(lambda r:httpx.Response(401,text='SECRET'))) as client:
catalog = ModelCatalog(api_key='SECRET', client=client)
with pytest.raises(CatalogUnavailable) as exc:
await catalog.models()
assert 'SECRET' not in str(exc.value)
+69
View File
@@ -0,0 +1,69 @@
"""Offline Chromium checks of actual Hope assets with mocked account APIs."""
import json
from pathlib import Path
from playwright.sync_api import sync_playwright, expect
ROOT = Path(__file__).resolve().parents[2]
def test_model_settings_load_save_reload_and_failure():
saved = {'model': 'auto', 'thinking_level': 'none'}
account = {'id':'alice','username':'alice','display_name':'Alice','role':'user','status':'ready','error':None}
calls = []
available = True
models = [{'id':m,'name':m,'thinking_levels':['none','low','high']} for m in ['auto','openai/gpt-6.1-sol-subscription']]
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):
nonlocal saved
url = req.request.url
if '/api/auth/me' in url:
req.fulfill(json={'user':account})
elif '/api/settings/model' in url:
calls.append(req.request.method)
if req.request.method == 'PUT':
saved = req.request.post_data_json
req.fulfill(json={'settings':saved,'message':'Saved for your account. Applies to your next request.'})
else:
req.fulfill(json={'settings':saved,'models':models if available else [],'catalog_available':available,'message':''})
else:
name = url.split('/',3)[-1].split('?')[0] or 'index.html'
asset = ROOT/'web'/name
if asset.is_file():
types = {'.html':'text/html','.js':'application/javascript','.css':'text/css','.svg':'image/svg+xml'}
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.goto('https://hope.test/')
page.wait_for_load_state('networkidle')
assert calls == []
page.locator('#settingsBtn').click()
expect(page.locator('#modelSelect')).to_be_enabled()
expect(page.locator('#saveModelSettings')).to_be_disabled()
page.locator('#modelSelect').select_option('openai/gpt-6.1-sol-subscription')
page.locator('#thinkingLevelSelect').select_option('high')
page.locator('#saveModelSettings').click()
expect(page.locator('#modelSettingsStatus')).to_contain_text('Saved')
assert saved == {'model':'openai/gpt-6.1-sol-subscription','thinking_level':'high'}
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
page.locator('#closeSheetBtn').click()
page.reload(); page.wait_for_load_state('networkidle')
page.locator('#settingsBtn').click()
expect(page.locator('#modelSelect')).to_have_value(saved['model'])
expect(page.locator('#thinkingLevelSelect')).to_have_value('high')
page.screenshot(path='/tmp/hope-model-settings-mobile.png',full_page=True)
page.locator('#closeSheetBtn').click()
available = False
page.locator('#settingsBtn').click()
expect(page.locator('#modelSelect')).to_be_disabled()
expect(page.locator('#saveModelSettings')).to_be_disabled()
expect(page.locator('#retryModelSettings')).to_be_visible()
available = True
page.locator('#retryModelSettings').click()
expect(page.locator('#modelSelect')).to_be_enabled()
assert errors == []
browser.close()
+171 -3
View File
@@ -586,6 +586,163 @@ function handleDataPacket(payload, participant, topic) {
}
}
// ── Account model settings (server saved; never browser persisted) ──────────
const modelSettingsForm = document.getElementById("modelSettingsForm");
const modelSelect = document.getElementById("modelSelect");
const thinkingLevelSelect = document.getElementById("thinkingLevelSelect");
const saveModelSettingsBtn = document.getElementById("saveModelSettings");
const retryModelSettingsBtn = document.getElementById("retryModelSettings");
const modelSettingsStatus = document.getElementById("modelSettingsStatus");
const thinkingLevelNote = document.getElementById("thinkingLevelNote");
const THINKING_LEVELS = [
["none", "Off"], ["minimal", "Minimal"], ["low", "Low"],
["medium", "Medium"], ["high", "High"], ["xhigh", "Extra high"],
];
const THINKING_NOTE = "Thinking requests may be limited or ignored by the selected model.";
let settingsAccount = null;
let settingsGeneration = 0;
let settingsAbort = null;
let settingsBusy = false;
let modelCatalog = [];
let catalogAvailable = false;
let savedModelSettings = null;
function accountIdentity() {
return HopeAuth.user ? String(HopeAuth.user.id) : null;
}
function settingsResponseIsCurrent(account, generation) {
return account !== null && account === accountIdentity() && account === settingsAccount && generation === settingsGeneration;
}
function settingsOption(value, label) {
const option = document.createElement("option");
option.value = value; option.textContent = label;
return option;
}
function setSettingsStatus(message, state = "") {
modelSettingsStatus.textContent = message;
modelSettingsStatus.dataset.state = state;
}
function updateSettingsControls() {
const available = !!HopeAuth.user && catalogAvailable && !settingsBusy;
modelSelect.disabled = !available;
thinkingLevelSelect.disabled = !available;
const model = modelCatalog.find(item => item.id === modelSelect.value);
const valid = model && model.thinking_levels.includes(thinkingLevelSelect.value);
const changed = savedModelSettings && (modelSelect.value !== savedModelSettings.model || thinkingLevelSelect.value !== savedModelSettings.thinking_level);
saveModelSettingsBtn.disabled = !available || !valid || !changed;
retryModelSettingsBtn.disabled = settingsBusy;
saveModelSettingsBtn.textContent = settingsBusy && modelSettingsForm.dataset.operation === "save" ? "Saving…" : "Save";
modelSettingsForm.setAttribute("aria-busy", String(settingsBusy));
}
function renderThinkingLevels(preferred) {
const model = modelCatalog.find(item => item.id === modelSelect.value);
const levels = model?.thinking_levels || [];
thinkingLevelSelect.replaceChildren();
for (const [value, label] of THINKING_LEVELS) {
if (levels.includes(value) || value === preferred) thinkingLevelSelect.append(settingsOption(value, label));
}
if (!thinkingLevelSelect.options.length) thinkingLevelSelect.append(settingsOption("", "No thinking levels available"));
if ([...thinkingLevelSelect.options].some(option => option.value === preferred)) thinkingLevelSelect.value = preferred;
updateSettingsControls();
}
function renderSavedModelSettings() {
modelSelect.replaceChildren();
for (const model of modelCatalog) modelSelect.append(settingsOption(model.id, model.name || model.id));
if (savedModelSettings && !modelCatalog.some(model => model.id === savedModelSettings.model)) {
modelSelect.prepend(settingsOption(savedModelSettings.model, `${savedModelSettings.model} (saved; unavailable)`));
}
if (!modelSelect.options.length) modelSelect.append(settingsOption("", "Model catalog unavailable"));
if (savedModelSettings) modelSelect.value = savedModelSettings.model;
renderThinkingLevels(savedModelSettings?.thinking_level);
}
function resetModelSettings(user) {
settingsGeneration++;
settingsAbort?.abort(); settingsAbort = null;
settingsAccount = user ? String(user.id) : null;
settingsBusy = false; catalogAvailable = false; modelCatalog = []; savedModelSettings = null;
modelSettingsForm.dataset.operation = "";
modelSelect.replaceChildren(settingsOption("", "Open settings to load models"));
thinkingLevelSelect.replaceChildren(settingsOption("", "Open settings to load thinking levels"));
thinkingLevelNote.textContent = THINKING_NOTE;
retryModelSettingsBtn.hidden = true;
setSettingsStatus("Open settings to load your saved preferences.");
updateSettingsControls();
}
function readSavedSettings(data) {
if (!data?.settings || typeof data.settings.model !== "string" || !THINKING_LEVELS.some(([level]) => level === data.settings.thinking_level)) {
throw new Error("The server did not return valid saved settings.");
}
return { model: data.settings.model, thinking_level: data.settings.thinking_level };
}
async function loadModelSettings() {
const account = accountIdentity();
if (!account || settingsBusy) return;
if (settingsAccount !== account) resetModelSettings(HopeAuth.user);
const generation = ++settingsGeneration;
settingsAbort?.abort(); settingsAbort = new AbortController();
settingsBusy = true; modelSettingsForm.dataset.operation = "load";
retryModelSettingsBtn.hidden = true;
setSettingsStatus("Loading your saved model settings…"); updateSettingsControls();
try {
const data = await HopeAuth.request("/api/settings/model", { signal: settingsAbort.signal });
if (!settingsResponseIsCurrent(account, generation)) return;
savedModelSettings = readSavedSettings(data);
modelCatalog = Array.isArray(data.models) ? data.models.filter(model => typeof model?.id === "string" && Array.isArray(model.thinking_levels)).map(model => ({
id: model.id, name: typeof model.name === "string" ? model.name : model.id,
thinking_levels: model.thinking_levels.filter(level => THINKING_LEVELS.some(([value]) => value === level)),
})) : [];
catalogAvailable = data.catalog_available === true && modelCatalog.length > 0;
thinkingLevelNote.textContent = typeof data.thinking_note === "string" && data.thinking_note ? data.thinking_note : THINKING_NOTE;
renderSavedModelSettings();
retryModelSettingsBtn.hidden = catalogAvailable;
setSettingsStatus(catalogAvailable ? (data.message || "Your saved settings are loaded. Choose preferences, then Save.") : `${data.message || "Model catalog unavailable."} Your saved preferences are shown; retry to make changes.`, catalogAvailable ? "" : "error");
} catch (error) {
if (!settingsResponseIsCurrent(account, generation)) return;
catalogAvailable = false;
retryModelSettingsBtn.hidden = false;
// Keep this account's last saved display when a refresh fails.
renderSavedModelSettings();
setSettingsStatus(`Could not load model settings: ${error.message} Retry to load your saved preferences.`, "error");
} finally {
if (settingsResponseIsCurrent(account, generation)) {
settingsBusy = false; settingsAbort = null; updateSettingsControls();
}
}
}
modelSelect.addEventListener("change", () => {
const previous = thinkingLevelSelect.value;
const model = modelCatalog.find(item => item.id === modelSelect.value);
renderThinkingLevels(model?.thinking_levels.includes(previous) ? previous : model?.thinking_levels[0]);
setSettingsStatus("Unsaved changes. Save to apply them to your account.");
});
thinkingLevelSelect.addEventListener("change", () => {
updateSettingsControls(); setSettingsStatus("Unsaved changes. Save to apply them to your account.");
});
retryModelSettingsBtn.addEventListener("click", loadModelSettings);
modelSettingsForm.addEventListener("submit", async event => {
event.preventDefault(); updateSettingsControls();
if (saveModelSettingsBtn.disabled) return;
const account = accountIdentity(), generation = ++settingsGeneration;
const settings = { model: modelSelect.value, thinking_level: thinkingLevelSelect.value };
settingsAbort = new AbortController(); settingsBusy = true; modelSettingsForm.dataset.operation = "save";
retryModelSettingsBtn.hidden = true;
setSettingsStatus("Saving your model settings…"); updateSettingsControls();
try {
const data = await HopeAuth.request("/api/settings/model", { method: "PUT", body: JSON.stringify(settings), signal: settingsAbort.signal });
if (!settingsResponseIsCurrent(account, generation)) return;
savedModelSettings = readSavedSettings(data); renderSavedModelSettings();
setSettingsStatus(data.message || "Saved. Your next request will use these account settings; your conversation continues uninterrupted.", "success");
} catch (error) {
if (!settingsResponseIsCurrent(account, generation)) return;
retryModelSettingsBtn.hidden = false;
setSettingsStatus(`Could not save settings: ${error.message} Your changes are not confirmed saved. Try Save again, or Retry to reload saved preferences.`, "error");
} finally {
if (settingsResponseIsCurrent(account, generation)) {
settingsBusy = false; settingsAbort = null; updateSettingsControls();
}
}
});
// ── Voice settings sheet ────────────────────────────────────────────────────
function currentVoiceId() {
const saved = localStorage.getItem("preferred_voice");
@@ -631,13 +788,16 @@ function selectVoice(voiceId) {
}
function openSheet() {
if (!HopeAuth.user) return;
renderVoiceList();
settingsSheet.hidden = false; settingsSheet.inert = false;
document.getElementById("authenticatedApp").inert = true;
sheetBackdrop.hidden = false;
settingsSheet.classList.add("open");
settingsBtn.setAttribute("aria-expanded", "true");
settingsSheet.querySelector(".voice-card")?.focus();
loadModelSettings();
// The catalog is loading, so the first stable enabled control is Close.
sheetFocusableControls()[0]?.focus();
}
function closeSheet() {
@@ -649,15 +809,22 @@ function closeSheet() {
settingsBtn.focus();
}
function sheetFocusableControls() {
return [...settingsSheet.querySelectorAll('button, select, input, textarea, a[href], [tabindex]')].filter(el =>
!el.disabled && el.tabIndex >= 0 && !el.closest('[hidden], [inert]') &&
el.getClientRects().length > 0 && getComputedStyle(el).visibility !== "hidden"
);
}
settingsBtn.setAttribute("aria-expanded", "false");
settingsBtn.addEventListener("click", openSheet);
closeSheetBtn.addEventListener("click", closeSheet);
sheetBackdrop.addEventListener("click", closeSheet);
document.addEventListener("keydown", (event) => {
if (event.key === "Tab" && settingsSheet.classList.contains("open")) {
const focusable = [...settingsSheet.querySelectorAll('button:not(:disabled), [tabindex="0"]')];
const focusable = sheetFocusableControls();
const first = focusable[0], last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
if (!focusable.includes(document.activeElement)) { event.preventDefault(); first?.focus(); }
else if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
}
if (event.key !== "Escape") return;
@@ -961,6 +1128,7 @@ function sendModeMessage(mode, targetRoom) {
HopeAuth.onChange((user, changed) => {
if (changed || !user || user.status !== "ready") stopConversation();
if (changed || settingsAccount !== (user ? String(user.id) : null)) resetModelSettings(user);
if (changed) {
historyKey = user ? `hope_history:${location.origin}:${encodeURIComponent(user.id)}` : null;
history = loadHistory(); renderHistory(); progress.clear(); tasksList.replaceChildren(); tasksBadge.hidden = true;
+24 -4
View File
@@ -103,7 +103,7 @@
<button id="muteBtn" class="btn btn-icon" aria-label="Mute microphone" title="Mute microphone" disabled>
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="8" y1="22" x2="16" y2="22"></line></svg>
</button>
<button id="settingsBtn" class="btn btn-icon" aria-label="Voice settings" title="Voice settings">
<button id="settingsBtn" class="btn btn-icon" aria-label="Hope settings" title="Hope settings">
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h2M10 17h10"></path><circle cx="16" cy="7" r="2"></circle><circle cx="8" cy="17" r="2"></circle></svg>
</button>
</div>
@@ -146,14 +146,34 @@
<div class="sheet-header">
<div>
<h2>Hope’s settings</h2>
<p>Your voice preference applies to this conversation. Reasoning is managed by your private assistant.</p>
<p>Your voice preference applies to this conversation.</p>
</div>
<button id="closeSheetBtn" class="btn btn-icon" aria-label="Close settings">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"></path></svg>
</button>
</div>
<h3 class="sheet-section-title">Voice</h3>
<div class="voice-list" id="voiceList"></div>
<div class="sheet-content">
<form id="modelSettingsForm" class="account-form model-settings" aria-labelledby="modelSettingsHeading" aria-describedby="modelSettingsScope">
<h3 id="modelSettingsHeading">Model and thinking</h3>
<p id="modelSettingsScope" class="model-settings-note">Saved for your account. Applies to your next request, including in an active conversation, without interrupting it.</p>
<div>
<label for="modelSelect">Model</label>
<select id="modelSelect" name="model" required disabled aria-describedby="modelSettingsStatus"><option value="">Open settings to load models</option></select>
</div>
<div>
<label for="thinkingLevelSelect">Thinking level</label>
<select id="thinkingLevelSelect" name="thinking_level" required disabled aria-describedby="thinkingLevelNote"><option value="">Open settings to load thinking levels</option></select>
<p id="thinkingLevelNote" class="model-settings-note">Thinking requests may be limited or ignored by the selected model.</p>
</div>
<p id="modelSettingsStatus" class="model-settings-status" role="status" aria-live="polite" aria-atomic="true">Open settings to load your saved preferences.</p>
<div class="model-settings-actions">
<button id="saveModelSettings" type="submit" class="btn btn-primary" disabled>Save</button>
<button id="retryModelSettings" type="button" class="btn btn-secondary" hidden>Retry</button>
</div>
</form>
<h3 class="sheet-section-title">Voice</h3>
<div class="voice-list" id="voiceList" role="radiogroup" aria-label="Voice"></div>
</div>
</div>
<div class="tasks-panel" id="tasksPanel" hidden inert aria-label="Assistant activity">
+35 -3
View File
@@ -995,10 +995,42 @@ body[data-mode="text"] .mic-meter {
padding: 0.15rem 0.2rem 0 0;
}
/* Two pickers (voice + brain) share the settings sheet height. */
.settings-sheet .voice-list {
flex: 1 1 0;
/* One scroll area keeps both model controls and all voices reachable. */
.sheet-content {
min-height: 0;
overflow-y: auto;
padding: 0.15rem 0.2rem 0.25rem;
}
.settings-sheet .sheet-header { flex-shrink: 0; }
.settings-sheet .voice-list { overflow-y: visible; }
.model-settings {
min-width: 0;
padding-bottom: 1.25rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--line);
}
.model-settings h3 { font-size: 0.9rem; font-weight: 650; }
.model-settings select {
width: 100%;
max-width: 100%;
min-width: 0;
text-overflow: ellipsis;
font-size: 0.85rem;
}
.model-settings select:disabled { color: var(--ink-soft); cursor: not-allowed; }
.model-settings-note,
.model-settings-status {
color: var(--ink-soft);
font-size: 0.78rem;
line-height: 1.55;
overflow-wrap: anywhere;
}
#thinkingLevelNote { margin-top: 0.5rem; }
.model-settings-status[data-state="error"] { color: var(--danger); }
.model-settings-status[data-state="success"] { color: var(--mint); }
.model-settings-actions { display: flex; flex-wrap: wrap; gap: 0.65rem; }
.model-settings-actions .btn { min-width: 90px; }
.model-settings-actions .btn:disabled { display: inline-block; }
.voice-card {
min-height: 54px;