fix: apply custom model fallback before the base-model access check

With ENABLE_CUSTOM_MODEL_FALLBACK enabled, chatting with a custom
workspace model whose base model was removed still failed with "Model
not found" for non-admin users. The access check ran before the
fallback resolution and walked the preset's base_model_id chain;
has_base_model_access treats a base model without a model-table row as
admin-only, so regular users were rejected before the fallback to the
default (Selected) model could ever apply. Admins bypass the access
check by default, which is why the fallback only appeared to work for
them.

Resolve the fallback model first, and when one applies, skip the
missing base-model hop on the requested preset and enforce access on
the fallback model that will actually serve the chat instead.
check_model_access gains a check_base_model flag for this; all other
callers keep the default chain enforcement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sc922g12Yp16TdcCt74AWi
This commit is contained in:
Claude 2026-08-12 11:38:16 +00:00
parent 29541cbb52
commit ae8ef975ea
No known key found for this signature in database
2 changed files with 35 additions and 18 deletions

View file

@ -1081,6 +1081,7 @@ async def chat_completion(
metadata = {}
try:
model_info = None
fallback_model = None
if not model_item.get('direct', False):
if model_id not in request.app.state.MODELS:
raise Exception('Model not found')
@ -1088,10 +1089,32 @@ async def chat_completion(
model = request.app.state.MODELS[model_id]
model_info = await Models.get_model_by_id(model_id)
# Resolve the custom-model fallback before the access check: the
# base-model access hop treats a missing base model as admin-only
# and would reject non-admin users before the fallback could apply.
if (
ENABLE_CUSTOM_MODEL_FALLBACK
and model_info
and model_info.base_model_id
and model_info.base_model_id not in request.app.state.MODELS
):
default_models = ((await Config.get('ui.default_models')) or '').split(',')
fallback_model_id = default_models[0].strip() if default_models[0] else None
if fallback_model_id and fallback_model_id in request.app.state.MODELS:
fallback_model = request.app.state.MODELS[fallback_model_id]
# Check if user has access to the model
if not BYPASS_MODEL_ACCESS_CONTROL and (user.role != 'admin' or not BYPASS_ADMIN_ACCESS_CONTROL):
try:
await check_model_access(user, model, model_info=model_info)
if fallback_model is not None:
# The requested custom model still gates entry, but its
# missing base model is skipped — access is enforced on
# the fallback model that will actually serve the chat.
await check_model_access(user, model, model_info=model_info, check_base_model=False)
await check_model_access(user, fallback_model)
else:
await check_model_access(user, model, model_info=model_info)
except Exception as e:
raise e
else:
@ -1113,19 +1136,11 @@ async def chat_completion(
# Check base model existence for custom models
if model_info and model_info.base_model_id:
base_model_id = model_info.base_model_id
if base_model_id not in request.app.state.MODELS:
if ENABLE_CUSTOM_MODEL_FALLBACK:
default_models = ((await Config.get('ui.default_models')) or '').split(',')
fallback_model_id = default_models[0].strip() if default_models[0] else None
if fallback_model_id and fallback_model_id in request.app.state.MODELS:
# Update model and form_data so routing uses the fallback model's type
model = request.app.state.MODELS[fallback_model_id]
form_data['model'] = fallback_model_id
else:
raise Exception('Model not found')
if model_info.base_model_id not in request.app.state.MODELS:
if fallback_model is not None:
# Update model and form_data so routing uses the fallback model's type
model = fallback_model
form_data['model'] = fallback_model['id']
else:
raise Exception('Model not found')

View file

@ -428,7 +428,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
return models
async def check_model_access(user, model, model_info=None, db=None):
async def check_model_access(user, model, model_info=None, db=None, check_base_model=True):
if model.get('arena'):
meta = model.get('info', {}).get('meta', {})
access_grants = meta.get('access_grants', [])
@ -449,7 +449,7 @@ async def check_model_access(user, model, model_info=None, db=None):
# One group-membership fetch shared by the direct check and every
# base-model hop; skipped when no check below needs it.
user_group_ids = None
if user.id != model_info.user_id or model_info.base_model_id:
if user.id != model_info.user_id or (check_base_model and model_info.base_model_id):
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
if not (
@ -465,8 +465,10 @@ async def check_model_access(user, model, model_info=None, db=None):
):
raise Exception('Model not found')
# Enforce access on chained base models
if not await has_base_model_access(
# Enforce access on chained base models. Callers substituting the base
# model (custom-model fallback) skip this hop and must enforce access
# on the substitute model instead.
if check_base_model and not await has_base_model_access(
user.id, model_info, user_role=user.role, user_group_ids=user_group_ids, db=db
):
raise Exception('Model not found')