diff --git a/core/auth.py b/core/auth.py
index 80fce1825..5db2fed4c 100644
--- a/core/auth.py
+++ b/core/auth.py
@@ -31,11 +31,20 @@ DEFAULT_PRIVILEGES = {
"max_messages_per_day": 0,
"allowed_models": [],
"allowed_models_restricted": False,
+ # Explicit "block every model" sentinel. An empty `allowed_models` list is
+ # ambiguous — it's also what gets sent when the admin clicks "[All]" — so
+ # we need a dedicated flag to express "this user may use no models at all"
+ # distinctly from "this user has no restriction".
+ "block_all_models": False,
}
# Admins get everything
ADMIN_PRIVILEGES = {k: (True if isinstance(v, bool) else (0 if isinstance(v, int) else [])) for k, v in DEFAULT_PRIVILEGES.items()}
ADMIN_PRIVILEGES["allowed_models_restricted"] = False
+# Admins must never be blocked from using models — the generic dict
+# comprehension above flips every boolean default to True, which would be
+# backwards for this sentinel.
+ADMIN_PRIVILEGES["block_all_models"] = False
from src.constants import AUTH_FILE
DEFAULT_AUTH_PATH = AUTH_FILE
diff --git a/routes/chat_helpers.py b/routes/chat_helpers.py
index 5c04ab70e..0b1c5d8ba 100644
--- a/routes/chat_helpers.py
+++ b/routes/chat_helpers.py
@@ -88,6 +88,14 @@ def _enforce_chat_privileges(request, sess) -> None:
return
privs = auth_manager.get_privileges(user) or {}
+
+ # Explicit "block everything" sentinel takes precedence over the
+ # allowlist — it's the only way to distinguish "user clicked [None]"
+ # (block all) from "user clicked [All]" (no restriction), since both
+ # otherwise produce an empty `allowed_models` list.
+ if privs.get("block_all_models"):
+ raise HTTPException(403, f"Your account is not allowed to use model '{sess.model}'.")
+
allowed_raw = privs.get("allowed_models")
allowed = allowed_raw if isinstance(allowed_raw, list) else []
restricted = bool(privs.get("allowed_models_restricted")) or bool(allowed)
diff --git a/static/js/admin.js b/static/js/admin.js
index 4c1add6ed..e4a39adf3 100644
--- a/static/js/admin.js
+++ b/static/js/admin.js
@@ -93,6 +93,7 @@ async function loadUsers() {
: [];
const allowedSet = new Set(allowedModels);
const modelsRestricted = !!(u.privileges && u.privileges.allowed_models_restricted);
+ const blockAllModels = !!(u.privileges && u.privileges.block_all_models);
html += `
Allowed models
@@ -101,7 +102,7 @@ async function loadUsers() {
None
- ${!modelsRestricted ? 'All models allowed (no restrictions)' : (allowedSet.size === 0 ? 'No models allowed' : allowedSet.size + ' model(s) allowed')}
+ ${blockAllModels ? 'No models allowed' : (!modelsRestricted ? 'All models allowed (no restrictions)' : (allowedSet.size === 0 ? 'No models allowed' : allowedSet.size + ' model(s) allowed'))}
Loading models...
@@ -123,7 +124,7 @@ async function loadUsers() {
// Load models list on first expand
if (!_modelsLoaded && !privPanel.classList.contains('hidden')) {
_modelsLoaded = true;
- _loadModelsForUser(u.username, allowedSet, modelsRestricted, privPanel);
+ _loadModelsForUser(u.username, allowedSet, modelsRestricted, blockAllModels, privPanel);
}
});
@@ -203,17 +204,22 @@ async function loadUsers() {
} catch (e) { list.innerHTML = 'Failed to load users
'; }
}
-async function _loadModelsForUser(username, allowedSet, modelsRestricted, privPanel) {
+async function _loadModelsForUser(username, allowedSet, modelsRestricted, blockAllModels, privPanel) {
const listEl = privPanel.querySelector(`.priv-models-list[data-user="${username}"]`);
if (!listEl) return;
try {
- const res = await fetch('/api/models', { credentials: 'same-origin' });
+ // Use /api/model-endpoints rather than /api/models — the latter is
+ // backed by `cached_models`, so endpoints that haven't been probed yet
+ // (e.g. a freshly-added cloud API like DeepSeek) simply don't show up
+ // until some other endpoint happens to trigger a cache refresh. The
+ // endpoints listing always reflects every configured endpoint.
+ const res = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
const data = await res.json();
const allModels = [];
- (data.items || []).forEach(item => {
- if (item.offline) return;
- (item.models || []).forEach(mid => {
- allModels.push({ mid, epName: item.endpoint_name || '', display: mid.split('/').pop() });
+ (Array.isArray(data) ? data : []).forEach(ep => {
+ if (!ep.online) return;
+ (ep.models || []).forEach(mid => {
+ allModels.push({ mid, epName: ep.name || '', display: mid.split('/').pop() });
});
});
if (!allModels.length) {
@@ -221,8 +227,9 @@ async function _loadModelsForUser(username, allowedSet, modelsRestricted, privPa
return;
}
let restricted = modelsRestricted;
+ let blockAll = blockAllModels;
listEl.innerHTML = sortModelObjects(allModels).map(m => {
- const checked = !restricted || allowedSet.has(m.mid) ? 'checked' : '';
+ const checked = !blockAll && (!restricted || allowedSet.has(m.mid)) ? 'checked' : '';
return `
${esc(m.display)}
@@ -236,15 +243,33 @@ async function _loadModelsForUser(username, allowedSet, modelsRestricted, privPa
listEl.querySelectorAll('.priv-model-cb').forEach(cb => {
if (cb.checked) checked.push(cb.dataset.mid);
});
- // All checked means unrestricted; zero checked means explicitly no models.
- restricted = checked.length !== allModels.length;
- const value = restricted ? checked : [];
+ // Three distinct states the backend must be able to tell apart:
+ // - all checked -> no restriction (allowed_models: [], block_all_models: false)
+ // - none checked -> block everything (allowed_models: [], block_all_models: true)
+ // - some checked -> allowlist (allowed_models: checked, block_all_models: false)
+ let value, hintText;
+ if (checked.length === allModels.length) {
+ restricted = false;
+ blockAll = false;
+ value = [];
+ hintText = 'All models allowed (no restrictions)';
+ } else if (checked.length === 0) {
+ restricted = true;
+ blockAll = true;
+ value = [];
+ hintText = 'No models allowed';
+ } else {
+ restricted = true;
+ blockAll = false;
+ value = checked;
+ hintText = value.length + ' model(s) allowed';
+ }
const hint = privPanel.querySelector('.priv-models-list[data-user]')?.previousElementSibling?.querySelector('div[style*="opacity"]');
- if (hint) hint.textContent = !restricted ? 'All models allowed (no restrictions)' : (value.length === 0 ? 'No models allowed' : value.length + ' model(s) allowed');
+ if (hint) hint.textContent = hintText;
fetch(`/api/auth/users/${encodeURIComponent(username)}/privileges`, {
method: 'PUT', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ allowed_models: value, allowed_models_restricted: restricted }),
+ body: JSON.stringify({ allowed_models: value, allowed_models_restricted: restricted, block_all_models: blockAll }),
}).catch(() => {});
}
listEl.querySelectorAll('.priv-model-cb').forEach(cb => cb.addEventListener('change', _saveModels));
diff --git a/tests/test_chat_helpers.py b/tests/test_chat_helpers.py
index 1c2b060ed..2a559db93 100644
--- a/tests/test_chat_helpers.py
+++ b/tests/test_chat_helpers.py
@@ -69,6 +69,64 @@ def test_allowed_models_nonempty_list_still_restricts_without_new_flag(monkeypat
)
+def test_no_restriction_allows_any_model(monkeypatch):
+ monkeypatch.setattr("routes.chat_helpers.get_current_user", lambda request: "alice")
+
+ privs = {"allowed_models": [], "block_all_models": False, "max_messages_per_day": 0}
+ _enforce_chat_privileges(_Request(privs), _Session("provider/model-a"))
+ _enforce_chat_privileges(_Request(privs), _Session("provider/model-z"))
+
+
+def test_specific_allowlist_blocks_models_outside_it(monkeypatch):
+ monkeypatch.setattr("routes.chat_helpers.get_current_user", lambda request: "alice")
+
+ privs = {
+ "allowed_models": ["gpt-4"],
+ "block_all_models": False,
+ "max_messages_per_day": 0,
+ }
+ _enforce_chat_privileges(_Request(privs), _Session("gpt-4"))
+ with pytest.raises(HTTPException) as exc:
+ _enforce_chat_privileges(_Request(privs), _Session("gpt-3.5"))
+ assert exc.value.status_code == 403
+
+
+def test_block_all_models_blocks_regardless_of_allowed_models_contents(monkeypatch):
+ monkeypatch.setattr("routes.chat_helpers.get_current_user", lambda request: "alice")
+
+ # Even if allowed_models contains entries, block_all_models wins.
+ privs = {
+ "allowed_models": ["gpt-4", "gpt-3.5"],
+ "block_all_models": True,
+ "max_messages_per_day": 0,
+ }
+ with pytest.raises(HTTPException) as exc:
+ _enforce_chat_privileges(_Request(privs), _Session("gpt-4"))
+ assert exc.value.status_code == 403
+
+ with pytest.raises(HTTPException):
+ _enforce_chat_privileges(_Request(privs), _Session("anything-else"))
+
+
+def test_admin_user_is_never_blocked(monkeypatch):
+ from core.auth import ADMIN_PRIVILEGES
+
+ monkeypatch.setattr("routes.chat_helpers.get_current_user", lambda request: "admin")
+
+ class _AdminAuthManager:
+ def get_privileges(self, username):
+ assert username == "admin"
+ return dict(ADMIN_PRIVILEGES)
+
+ class _AdminRequest:
+ def __init__(self):
+ self.app = type("App", (), {})()
+ self.app.state = type("State", (), {"auth_manager": _AdminAuthManager()})()
+
+ _enforce_chat_privileges(_AdminRequest(), _Session("provider/model-a"))
+ _enforce_chat_privileges(_AdminRequest(), _Session("anything-else"))
+
+
class _FakeSession:
def __init__(self, model="selected-model"):
self.model = model