mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-30 00:22:10 -04:00
feat: Allow admins to choose if they want to share defaults (#4752)
* First bare fix * Adding the option toggle * toggle function fix * Final fix, added missing /auth/ * Extended toggle text & added tests * Comments change * Description toggle change * br tag fix * description change based on suggestion
This commit is contained in:
@@ -2108,6 +2108,16 @@ def setup_model_routes(model_discovery):
|
|||||||
ep_id = (_user_prefs.get("default_endpoint_id") or "").strip()
|
ep_id = (_user_prefs.get("default_endpoint_id") or "").strip()
|
||||||
model = (_user_prefs.get("default_model") or "").strip()
|
model = (_user_prefs.get("default_model") or "").strip()
|
||||||
_fallbacks = _user_prefs.get("default_model_fallbacks") or []
|
_fallbacks = _user_prefs.get("default_model_fallbacks") or []
|
||||||
|
# If user has no personal default, fall back to global default
|
||||||
|
# But only based on the "share_defaults_with_users" flag
|
||||||
|
# (only if share_defaults_with_users is enabled)
|
||||||
|
if settings.get("share_defaults_with_users", False):
|
||||||
|
if not ep_id:
|
||||||
|
ep_id = settings.get("default_endpoint_id", "")
|
||||||
|
if not model:
|
||||||
|
model = settings.get("default_model", "")
|
||||||
|
if not _fallbacks:
|
||||||
|
_fallbacks = settings.get("default_model_fallbacks") or []
|
||||||
else:
|
else:
|
||||||
ep_id = settings.get("default_endpoint_id", "")
|
ep_id = settings.get("default_endpoint_id", "")
|
||||||
model = settings.get("default_model", "")
|
model = settings.get("default_model", "")
|
||||||
|
|||||||
@@ -141,6 +141,10 @@ DEFAULT_SETTINGS = {
|
|||||||
# before producing output (endpoint offline / errors), the chat
|
# before producing output (endpoint offline / errors), the chat
|
||||||
# dispatch retries the next entry in order.
|
# dispatch retries the next entry in order.
|
||||||
"default_model_fallbacks": [],
|
"default_model_fallbacks": [],
|
||||||
|
# When True, non-admin users inherit global default model/endpoint/fallbacks
|
||||||
|
# when they have no personal defaults. When False, users only use their
|
||||||
|
# personal defaults (no global fallback). Default is False.
|
||||||
|
"share_defaults_with_users": False,
|
||||||
"utility_endpoint_id": "",
|
"utility_endpoint_id": "",
|
||||||
"utility_model": "",
|
"utility_model": "",
|
||||||
# Ordered fallback chain for the Utility model (summarization, naming,
|
# Ordered fallback chain for the Utility model (summarization, naming,
|
||||||
|
|||||||
+2
-2
@@ -91,7 +91,7 @@ async function _createDirectChatFromPreferredModel() {
|
|||||||
if (!sessionModule) return false;
|
if (!sessionModule) return false;
|
||||||
|
|
||||||
const pending = sessionModule.getPendingChat && sessionModule.getPendingChat();
|
const pending = sessionModule.getPendingChat && sessionModule.getPendingChat();
|
||||||
if (pending && pending.url && pending.modelId) {
|
if (pending && pending.url && pending.modelId && pending.endpointId) {
|
||||||
sessionModule.createDirectChat(pending.url, pending.modelId, pending.endpointId);
|
sessionModule.createDirectChat(pending.url, pending.modelId, pending.endpointId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -99,7 +99,7 @@ async function _createDirectChatFromPreferredModel() {
|
|||||||
const sessions = sessionModule.getSessions();
|
const sessions = sessionModule.getSessions();
|
||||||
const currentId = sessionModule.getCurrentSessionId();
|
const currentId = sessionModule.getCurrentSessionId();
|
||||||
const current = sessions.find(s => s.id === currentId);
|
const current = sessions.find(s => s.id === currentId);
|
||||||
if (current && current.endpoint_url && current.model) {
|
if (current && current.endpoint_url && current.model && current.endpoint_id) {
|
||||||
sessionModule.createDirectChat(current.endpoint_url, current.model, current.endpoint_id);
|
sessionModule.createDirectChat(current.endpoint_url, current.model, current.endpoint_id);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2065,6 +2065,16 @@
|
|||||||
<label class="admin-switch"><input type="checkbox" id="adm-signupToggle"><span class="admin-slider"></span></label>
|
<label class="admin-switch"><input type="checkbox" id="adm-signupToggle"><span class="admin-slider"></span></label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="admin-card">
|
||||||
|
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M12 15v3m-3-3h6M12 3v2m0 16v-2M4.93 4.93l1.41 1.41m11.32 11.32l1.41 1.41M3 12h2m16 0h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/><circle cx="12" cy="12" r="3"/></svg>Model Defaults</h2>
|
||||||
|
<div class="admin-toggle-row">
|
||||||
|
<div>
|
||||||
|
<div class="admin-toggle-label">Share defaults with users</div>
|
||||||
|
<div class="admin-toggle-sub">When on, users without a personal default inherit the global default model (only if those models are allowed for them).</div>
|
||||||
|
</div>
|
||||||
|
<label class="admin-switch"><input type="checkbox" id="adm-shareDefaultsToggle"><span class="admin-slider"></span></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="admin-card">
|
<div class="admin-card">
|
||||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>Users</h2>
|
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>Users</h2>
|
||||||
<div id="adm-userList"><div class="admin-empty">Loading...</div></div>
|
<div id="adm-userList"><div class="admin-empty">Loading...</div></div>
|
||||||
|
|||||||
+23
-1
@@ -343,6 +343,28 @@ function initSignupToggle() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initShareDefaultsToggle() {
|
||||||
|
const toggle = el('adm-shareDefaultsToggle');
|
||||||
|
fetch('/api/auth/settings', { credentials: 'same-origin' })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => { toggle.checked = !!d.share_defaults_with_users; })
|
||||||
|
.catch(e => console.warn('Settings fetch failed:', e));
|
||||||
|
toggle.addEventListener('change', async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ share_defaults_with_users: toggle.checked }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
toggle.checked = !!data.share_defaults_with_users;
|
||||||
|
} catch (e) {
|
||||||
|
toggle.checked = !toggle.checked;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function initAddUser() {
|
function initAddUser() {
|
||||||
fetch('/api/auth/policy', { credentials: 'same-origin' })
|
fetch('/api/auth/policy', { credentials: 'same-origin' })
|
||||||
.then(r => r.ok ? r.json() : null)
|
.then(r => r.ok ? r.json() : null)
|
||||||
@@ -2986,7 +3008,7 @@ function initLogsView() {
|
|||||||
function initAll() {
|
function initAll() {
|
||||||
modalEl = el('settings-modal');
|
modalEl = el('settings-modal');
|
||||||
const inits = [
|
const inits = [
|
||||||
initSignupToggle, initAddUser, initEndpointForm, initMcpForm,
|
initSignupToggle, initShareDefaultsToggle, initAddUser, initEndpointForm, initMcpForm,
|
||||||
initCalDAV, initBackup, initDangerZone, initTokenForm, initLogsView,
|
initCalDAV, initBackup, initDangerZone, initTokenForm, initLogsView,
|
||||||
() => settingsModule.initIntegrations()
|
() => settingsModule.initIntegrations()
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""Tests for share_defaults_with_users setting"""
|
||||||
|
import pytest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from tests.helpers.import_state import preserve_import_state
|
||||||
|
from tests.helpers.db_stubs import make_core_db_stub
|
||||||
|
|
||||||
|
with preserve_import_state("core.database", "src.database", "routes.model_routes", "routes.prefs_routes"):
|
||||||
|
import routes.model_routes as model_routes
|
||||||
|
import routes.prefs_routes as prefs_routes
|
||||||
|
import src.auth_helpers as auth_helpers
|
||||||
|
|
||||||
|
|
||||||
|
### Helper Classes
|
||||||
|
|
||||||
|
class _FakeEndpoint:
|
||||||
|
"""Minimal fake endpoint for testing"""
|
||||||
|
def __init__(self, id, base_url, is_enabled=True, owner=None):
|
||||||
|
self.id = id
|
||||||
|
self.base_url = base_url
|
||||||
|
self.is_enabled = is_enabled
|
||||||
|
self.owner = owner
|
||||||
|
self.cached_models = None
|
||||||
|
self.hidden_models = None
|
||||||
|
self.pinned_models = None
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeQuery:
|
||||||
|
"""Fake query object for testing"""
|
||||||
|
def __init__(self, endpoints, user=None, include_shared=True):
|
||||||
|
self._endpoints = endpoints
|
||||||
|
self._user = user
|
||||||
|
self._include_shared = include_shared
|
||||||
|
|
||||||
|
def filter(self, *conditions):
|
||||||
|
for cond in conditions:
|
||||||
|
cond_str = str(cond)
|
||||||
|
print(f"Filter condition: {cond_str}")
|
||||||
|
if 'owner' in cond_str and 'IS NULL' not in cond_str:
|
||||||
|
self._include_shared = False
|
||||||
|
return self
|
||||||
|
|
||||||
|
def first(self):
|
||||||
|
"""Return first endpoint respecting owner filter"""
|
||||||
|
if not self._endpoints:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if self._user:
|
||||||
|
for ep in self._endpoints:
|
||||||
|
ep_owner = getattr(ep, 'owner', None)
|
||||||
|
if ep_owner == self._user:
|
||||||
|
return ep
|
||||||
|
if self._include_shared and ep_owner is None:
|
||||||
|
return ep
|
||||||
|
return None
|
||||||
|
return self._endpoints[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _make_db_session(endpoints, user=None):
|
||||||
|
"""Create a fake DB session that returns our fake query"""
|
||||||
|
fake_session = MagicMock()
|
||||||
|
fake_query = _FakeQuery(endpoints, user)
|
||||||
|
fake_session.query.return_value = fake_query
|
||||||
|
return fake_session
|
||||||
|
|
||||||
|
|
||||||
|
def _get_default_chat_route(router):
|
||||||
|
"""Extract the /api/default-chat GET route from the router"""
|
||||||
|
for route in router.routes:
|
||||||
|
if getattr(route, "path", "") == "/api/default-chat" and "GET" in getattr(route, "methods", set()):
|
||||||
|
return route.endpoint
|
||||||
|
raise AssertionError("GET /api/default-chat route not found")
|
||||||
|
|
||||||
|
|
||||||
|
def _make_request(user=None, auth_manager=None):
|
||||||
|
"""Create a fake request for testing"""
|
||||||
|
return SimpleNamespace(
|
||||||
|
state=SimpleNamespace(current_user=user),
|
||||||
|
app=SimpleNamespace(state=SimpleNamespace(auth_manager=auth_manager)),
|
||||||
|
client=SimpleNamespace(host="127.0.0.1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
### Shared test logic
|
||||||
|
def _run_get_default_chat_test(monkeypatch, share_defaults_enabled, second_endpoint_only=False):
|
||||||
|
"""Helper function that runs get_default_chat with the given share_defaults_with_users setting."""
|
||||||
|
|
||||||
|
global_settings = {
|
||||||
|
"default_endpoint_id": "global-ep-123",
|
||||||
|
"default_model": "qwen-3.6",
|
||||||
|
"default_model_fallbacks": [
|
||||||
|
{"endpoint_id": "fallback-ep", "model": "fallback-model"}
|
||||||
|
],
|
||||||
|
"share_defaults_with_users": share_defaults_enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(model_routes, "_load_settings", lambda: global_settings)
|
||||||
|
monkeypatch.setattr(prefs_routes, "_load_for_user", lambda user: {})
|
||||||
|
|
||||||
|
fake_auth_manager = MagicMock()
|
||||||
|
fake_auth_manager.is_admin = lambda user: False
|
||||||
|
|
||||||
|
endpoints = [
|
||||||
|
_FakeEndpoint(
|
||||||
|
id="global-ep-123",
|
||||||
|
base_url="http://global-endpoint:8000/v1",
|
||||||
|
is_enabled=True
|
||||||
|
),
|
||||||
|
_FakeEndpoint(
|
||||||
|
id="fallback-ep",
|
||||||
|
base_url="http://fallback-endpoint:8000/v1",
|
||||||
|
is_enabled=True
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
# When testing fallback scenario, removes the primary endpoint
|
||||||
|
if second_endpoint_only:
|
||||||
|
endpoints = [endpoints[1]]
|
||||||
|
|
||||||
|
fake_db = _make_db_session(endpoints, user="regular_user")
|
||||||
|
monkeypatch.setattr(model_routes, "SessionLocal", lambda: fake_db)
|
||||||
|
monkeypatch.setattr(model_routes, "_normalize_base", lambda url: url)
|
||||||
|
monkeypatch.setattr(model_routes, "build_chat_url", lambda base: f"{base}/chat")
|
||||||
|
|
||||||
|
router = model_routes.setup_model_routes(model_discovery=None)
|
||||||
|
get_default_chat = _get_default_chat_route(router)
|
||||||
|
fake_request = _make_request(user="regular_user", auth_manager=fake_auth_manager)
|
||||||
|
|
||||||
|
result = get_default_chat(fake_request)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
### Test Functions
|
||||||
|
|
||||||
|
def test_get_default_chat_user_no_prefs_share_disabled_resolves_nothing(monkeypatch):
|
||||||
|
"""
|
||||||
|
Non-admin user without personal preferences should resolve to empty
|
||||||
|
ep_id, model, and fallbacks when share_defaults_with_users is disabled.
|
||||||
|
"""
|
||||||
|
|
||||||
|
test_data = _run_get_default_chat_test(monkeypatch, share_defaults_enabled=False)
|
||||||
|
|
||||||
|
assert test_data["endpoint_id"] == "", "Should get empty endpoint_id"
|
||||||
|
assert test_data["model"] == "", "Should get empty model"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_default_chat_user_no_prefs_share_enabled_resolves_global_defaults_fallbacks(monkeypatch):
|
||||||
|
"""
|
||||||
|
Non-admin user without personal preferences should resolve to global
|
||||||
|
defaults for ep_id, model, and fallbacks when share_defaults_with_users is enabled.
|
||||||
|
"""
|
||||||
|
|
||||||
|
test_data = _run_get_default_chat_test(monkeypatch, share_defaults_enabled=True)
|
||||||
|
|
||||||
|
assert test_data["model"] == "qwen-3.6", \
|
||||||
|
"model should be resolved from global default_model"
|
||||||
|
|
||||||
|
assert test_data["endpoint_id"] == "global-ep-123", \
|
||||||
|
"Should get global endpoint_id"
|
||||||
|
|
||||||
|
def test_get_default_chat_user_no_prefs_share_enabled_resolves_global_defaults(monkeypatch):
|
||||||
|
"""
|
||||||
|
Non-admin user without personal preferences should resolve to global
|
||||||
|
defaults for ep_id, model, and fallbacks when share_defaults_with_users is enabled.
|
||||||
|
"""
|
||||||
|
|
||||||
|
test_data = _run_get_default_chat_test(monkeypatch, share_defaults_enabled=True, second_endpoint_only=True)
|
||||||
|
|
||||||
|
assert test_data["model"] == "qwen-3.6", \
|
||||||
|
"model should be resolved from global default_model"
|
||||||
|
|
||||||
|
assert test_data["endpoint_id"] == "fallback-ep", \
|
||||||
|
"Should get global endpoint_id"
|
||||||
Reference in New Issue
Block a user