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