feat: save account model and thinking settings for Hermes
Rebuild & Deploy / rebuild (push) Successful in 1m9s
Rebuild & Deploy / rebuild (push) Successful in 1m9s
This commit is contained in:
1 parent
a45569f8c1
commit
6cb4427210
13 files changed
+798
-16
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user