From 82d01db3f4739abde24ddd77543a96dc1f69d067 Mon Sep 17 00:00:00 2001 From: Jean Carlos Nunez Date: Sun, 5 Apr 2026 14:50:14 -0500 Subject: [PATCH] fix: prevent sentinel from leaking into /v1/models API response (#23850) The fail-closed sentinel "__access_group_no_models__" was passing through get_complete_model_list() unchanged and reaching callers that return model lists directly to end-users, causing it to appear as a model ID in GET /v1/models responses. - Extract sentinel to named constant ACCESS_GROUP_NO_MODELS_SENTINEL in model_checks.py so all references stay in sync - Strip the sentinel from all_models before returning in utils.py - Strip it from all_models_str in proxy_server.py as a safety net Co-Authored-By: Claude Sonnet 4.6 --- litellm/proxy/auth/model_checks.py | 9 ++++++++- litellm/proxy/proxy_server.py | 3 +++ litellm/proxy/utils.py | 5 +++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/auth/model_checks.py b/litellm/proxy/auth/model_checks.py index e9066da2ea6..5d0c088d3d4 100644 --- a/litellm/proxy/auth/model_checks.py +++ b/litellm/proxy/auth/model_checks.py @@ -132,6 +132,13 @@ def get_key_models( return all_models +# Sentinel used by get_key_models_with_db_access_groups to signal "key has +# access_group_ids but none resolved to any model" so that get_complete_model_list() +# does not fall through to team_models (fail-closed). Callers that return model +# lists to end-users MUST strip this value before responding. +ACCESS_GROUP_NO_MODELS_SENTINEL = "__access_group_no_models__" + + async def get_key_models_with_db_access_groups( user_api_key_dict: UserAPIKeyAuth, proxy_model_list: List[str], @@ -168,7 +175,7 @@ async def get_key_models_with_db_access_groups( # Fail-closed: if the access group is configured but resolves to nothing # (deleted group, DB unavailable, or empty group), use a sentinel so # get_complete_model_list() does NOT fall through to team_models. - key_models = db_models if db_models else ["__access_group_no_models__"] + key_models = db_models if db_models else [ACCESS_GROUP_NO_MODELS_SENTINEL] return key_models diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 398022e01a2..058fbf1ab93 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -275,6 +275,7 @@ from litellm.proxy.auth.auth_utils import check_response_size_is_safe from litellm.proxy.auth.handle_jwt import JWTHandler from litellm.proxy.auth.litellm_license import LicenseCheck from litellm.proxy.auth.model_checks import ( + ACCESS_GROUP_NO_MODELS_SENTINEL, get_all_fallbacks, get_complete_model_list, get_key_models, @@ -10871,6 +10872,8 @@ async def model_info_v1( # noqa: PLR0915 infer_model_from_keys=general_settings.get("infer_model_from_keys", False), llm_router=llm_router, ) + # Strip the fail-closed sentinel — it must never appear in the /v1/models response. + all_models_str = [m for m in all_models_str if m != ACCESS_GROUP_NO_MODELS_SENTINEL] if len(all_models_str) > 0: _relevant_models = [] diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index 28e246713b7..2e0f4f7d2bc 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -5516,6 +5516,7 @@ async def get_available_models_for_user( """ from litellm.proxy.auth.auth_checks import get_team_object from litellm.proxy.auth.model_checks import ( + ACCESS_GROUP_NO_MODELS_SENTINEL, get_complete_model_list, get_key_models_with_db_access_groups, get_team_models, @@ -5581,6 +5582,10 @@ async def get_available_models_for_user( only_model_access_groups=only_model_access_groups, ) + # Strip the fail-closed sentinel before returning to callers — it must never + # appear in a model listing response (e.g. GET /v1/models). + all_models = [m for m in all_models if m != ACCESS_GROUP_NO_MODELS_SENTINEL] + return all_models