Merge pull request #32587 from BerriAI/litellm_fix_batch_model_access_hash_32580

fix(auth): resolve managed batch/file deployment model_id to model name for team access checks
This commit is contained in:
Mateo Wang 2026-07-29 18:43:39 -07:00 committed by GitHub
commit 52fc276f05
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 125 additions and 3 deletions

View file

@ -1432,7 +1432,7 @@ def _extract_models_from_managed_resource_id(
)
_append_model_candidates(
candidates=candidates,
value=get_model_id_from_unified_batch_id(unified_file_id),
value=_resolve_model_id_with_router(get_model_id_from_unified_batch_id(unified_file_id), llm_router),
)
except Exception as e:
verbose_proxy_logger.debug("Unable to extract model from managed file/batch ID: %s", str(e))
@ -1442,7 +1442,10 @@ def _extract_models_from_managed_resource_id(
parsed_id = parse_unified_id(resource_id)
if parsed_id:
_append_model_candidates(candidates=candidates, value=parsed_id.get("model_id"))
_append_model_candidates(
candidates=candidates,
value=_resolve_model_id_with_router(parsed_id.get("model_id"), llm_router),
)
_append_model_candidates(candidates=candidates, value=parsed_id.get("target_model_names"))
except Exception as e:
verbose_proxy_logger.debug("Unable to extract model from unified managed resource ID: %s", str(e))

View file

@ -9560,7 +9560,12 @@ class Router:
return None
# Strategy 1: Check if model_id directly matches a model_name or deployment ID
if model_id in self.model_names or self.has_model_id(model_id):
if model_id in self.model_names:
return model_id
if self.has_model_id(model_id):
deployment = self.get_deployment(model_id=model_id)
if deployment is not None and deployment.model_name:
return deployment.model_name
return model_id
# Strategy 2: Search through router's model_list to find by litellm_params.model

View file

@ -2659,6 +2659,23 @@ def test_resolve_model_name_from_model_id():
result = router.resolve_model_name_from_model_id("gpt-5-mini")
assert result == "gpt-5-mini"
# Test case 10: model_id is a deployment ID (hash) that differs from the
# public model_name. Regression for #32580: managed batch/file IDs embed the
# deployment model_id, and it must resolve back to the public model_name so
# team model-access checks compare against the model group, not the hash.
model_list = [
{
"model_name": "bedrock-batch-model",
"litellm_params": {
"model": "bedrock/global.anthropic.claude-haiku-4-5-20251001-v1:0",
},
"model_info": {"id": "8d0eaa7e6c6f54a425dfd0062cb6b0dc"},
},
]
router = Router(model_list=model_list)
result = router.resolve_model_name_from_model_id("8d0eaa7e6c6f54a425dfd0062cb6b0dc")
assert result == "bedrock-batch-model"
def test_get_valid_args():
"""Test get_valid_args static method returns valid Router.__init__ arguments"""

View file

@ -569,6 +569,103 @@ def test_get_model_from_request_resolves_video_id_model_with_router():
)
_BATCH_DEPLOYMENT_ID = "8d0eaa7e6c6f54a425dfd0062cb6b0dc"
def _managed_batch_router():
from litellm.router import Router
return Router(
model_list=[
{
"model_name": "bedrock-batch-model",
"litellm_params": {
"model": "bedrock/global.anthropic.claude-haiku-4-5-20251001-v1:0",
},
"model_info": {"id": _BATCH_DEPLOYMENT_ID},
},
{
"model_name": "some-other-model",
"litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "test-key"},
"model_info": {"id": "a-different-deployment-id"},
},
]
)
def _encode_managed_id(decoded: str) -> str:
return base64.urlsafe_b64encode(decoded.encode()).decode().rstrip("=")
_MANAGED_BATCH_ID = _encode_managed_id(
f"litellm_proxy;model_id:{_BATCH_DEPLOYMENT_ID};llm_batch_id:provider-batch-123"
)
_MANAGED_BATCH_OUTPUT_FILE_ID = _encode_managed_id(
f"litellm_proxy;model_id:{_BATCH_DEPLOYMENT_ID};llm_batch_id:provider-batch-123;"
"llm_output_file_id:provider-file-456"
)
@pytest.mark.parametrize(
"route, request_data",
[
("/v1/batches/{batch_id}", {"batch_id": _MANAGED_BATCH_ID}),
("/v1/batches/{batch_id}/cancel", {"batch_id": _MANAGED_BATCH_ID}),
("/v1/files/{file_id}", {"file_id": _MANAGED_BATCH_OUTPUT_FILE_ID}),
("/v1/files/{file_id}/content", {"file_id": _MANAGED_BATCH_OUTPUT_FILE_ID}),
],
)
def test_get_model_from_request_resolves_batch_id_deployment_to_model_name(route, request_data):
"""Regression for #32580: managed batch retrieve/cancel and managed batch output
file reads encode the deployment model_id into the resource id. The auth layer must
resolve that id back to the public model group name so model-access checks compare
against the model group, not the raw deployment id."""
assert (
get_model_from_request(
request_data=request_data,
route=route,
llm_router=_managed_batch_router(),
)
== "bedrock-batch-model"
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"route, request_data",
[
("/v1/batches/{batch_id}", {"batch_id": _MANAGED_BATCH_ID}),
("/v1/batches/{batch_id}/cancel", {"batch_id": _MANAGED_BATCH_ID}),
("/v1/files/{file_id}/content", {"file_id": _MANAGED_BATCH_OUTPUT_FILE_ID}),
],
)
async def test_managed_batch_routes_pass_team_model_access_check(route, request_data):
"""End-to-end regression for #32580: a team scoped to the batch model group got
``team_model_access_denied`` on retrieve/cancel because the deployment id, not the
model group, was authorized. Fails pre-fix with the deployment id in the message."""
from litellm.proxy._types import LiteLLM_TeamTable
from litellm.proxy.auth.auth_checks import can_team_access_model
llm_router = _managed_batch_router()
model = get_model_from_request(request_data=request_data, route=route, llm_router=llm_router)
assert (
await can_team_access_model(
model=model,
team_object=LiteLLM_TeamTable(team_id="team-batch", models=["bedrock-batch-model"]),
llm_router=llm_router,
)
is True
)
with pytest.raises(Exception, match="team not allowed to access model"):
await can_team_access_model(
model=model,
team_object=LiteLLM_TeamTable(team_id="team-other", models=["some-other-model"]),
llm_router=llm_router,
)
def test_get_model_from_request_resolves_character_id_model_with_router():
from litellm.types.videos.utils import encode_character_id_with_provider