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
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user