From 060dbf06811d9a5f5fd1c519a55d350d225f6a84 Mon Sep 17 00:00:00 2001 From: Jakub Grula Date: Tue, 23 Jun 2026 23:06:45 +0200 Subject: [PATCH] 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 --- routes/model_routes.py | 10 ++ src/settings.py | 4 + static/app.js | 4 +- static/index.html | 10 ++ static/js/admin.js | 24 ++++- tests/test_model_defaults.py | 173 +++++++++++++++++++++++++++++++++++ 6 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 tests/test_model_defaults.py diff --git a/routes/model_routes.py b/routes/model_routes.py index dd5656f6a..50aad1e16 100644 --- a/routes/model_routes.py +++ b/routes/model_routes.py @@ -2108,6 +2108,16 @@ def setup_model_routes(model_discovery): ep_id = (_user_prefs.get("default_endpoint_id") or "").strip() model = (_user_prefs.get("default_model") or "").strip() _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: ep_id = settings.get("default_endpoint_id", "") model = settings.get("default_model", "") diff --git a/src/settings.py b/src/settings.py index 064181299..2c6ffcfd3 100644 --- a/src/settings.py +++ b/src/settings.py @@ -141,6 +141,10 @@ DEFAULT_SETTINGS = { # before producing output (endpoint offline / errors), the chat # dispatch retries the next entry in order. "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_model": "", # Ordered fallback chain for the Utility model (summarization, naming, diff --git a/static/app.js b/static/app.js index 84e6c8c6e..0ab11194f 100644 --- a/static/app.js +++ b/static/app.js @@ -91,7 +91,7 @@ async function _createDirectChatFromPreferredModel() { if (!sessionModule) return false; 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); return true; } @@ -99,7 +99,7 @@ async function _createDirectChatFromPreferredModel() { const sessions = sessionModule.getSessions(); const currentId = sessionModule.getCurrentSessionId(); 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); return true; } diff --git a/static/index.html b/static/index.html index 28bb250c4..d43652f0c 100644 --- a/static/index.html +++ b/static/index.html @@ -2065,6 +2065,16 @@ +
+

Model Defaults

+
+
+
Share defaults with users
+
When on, users without a personal default inherit the global default model (only if those models are allowed for them).
+
+ +
+

Users

Loading...
diff --git a/static/js/admin.js b/static/js/admin.js index 58b8765a5..62b0108a6 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -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() { fetch('/api/auth/policy', { credentials: 'same-origin' }) .then(r => r.ok ? r.json() : null) @@ -2986,7 +3008,7 @@ function initLogsView() { function initAll() { modalEl = el('settings-modal'); const inits = [ - initSignupToggle, initAddUser, initEndpointForm, initMcpForm, + initSignupToggle, initShareDefaultsToggle, initAddUser, initEndpointForm, initMcpForm, initCalDAV, initBackup, initDangerZone, initTokenForm, initLogsView, () => settingsModule.initIntegrations() ]; diff --git a/tests/test_model_defaults.py b/tests/test_model_defaults.py new file mode 100644 index 000000000..9ff6b3123 --- /dev/null +++ b/tests/test_model_defaults.py @@ -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" \ No newline at end of file