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

+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