Merge pull request #47344 from BerriAI/litellm_backport_47067_stable_1_104_x

fix(proxy): backport #47067 team-scoped batch model access to stable/1.104.x for v1.104.4
This commit is contained in:
Mateo Wang 2026-10-19 18:29:05 +00:00 • committed by GitHub
commit b40bd7bf03
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 276 additions and 4 deletions

View file

@ -4352,6 +4352,24 @@ def _check_model_access_helper(
return True
def _owning_team_names_for_team_deployment(
model: str,
llm_router: Router | None,
team_id: str | None,
team_model_aliases: Mapping[str, str] | None,
) -> tuple[str, ...]:
if llm_router is None or team_id is None or not model.startswith(f"model_name_{team_id}_"):
return ()
deployment: Final = llm_router.get_deployment_by_model_group_name(model_group_name=model)
public_name: Final = (
deployment.model_info.team_public_model_name
if deployment is not None and deployment.model_info.team_id == team_id
else None
)
legacy_aliases: Final = tuple(alias for alias, target in (team_model_aliases or {}).items() if target == model)
return (*((public_name,) if public_name else ()), *legacy_aliases)
def _can_object_call_model(
model: str | list[str],
llm_router: Router | None,
@ -4423,6 +4441,13 @@ def _can_object_call_model(
*((global_or_router_alias_target,) if global_or_router_alias_target else ()),
)
)
owning_team_names: Final = (
()
if key_alias_applied
else _owning_team_names_for_team_deployment(
model=model, llm_router=llm_router, team_id=team_id, team_model_aliases=team_model_aliases
)
)
## check model access for alias + underlying model - allow if either is in allowed models
for m in potential_models:
@ -4434,6 +4459,11 @@ def _can_object_call_model(
team_id=team_id,
):
return True
if any(
_check_model_access_helper(model=name, llm_router=llm_router, models=models, team_id=team_id)
for name in owning_team_names
):
return True
internal_message: Final = (
f"{object_type} not allowed to access model. This {object_type} can only access models={models}. "

View file

@ -1,6 +1,6 @@
[project]
name = "litellm"
version = "1.104.3"
version = "1.104.4"
description = "Library to easily interface with LLM API providers"
readme = "README.md"
requires-python = ">=3.10, <3.15"
@ -355,7 +355,7 @@ litellm-enterprise = { workspace = true }
members = ["enterprise", "litellm-proxy-extras"]
[tool.commitizen]
version = "1.104.3"
version = "1.104.4"
version_files = [
"pyproject.toml:^version",
]

View file

@ -30,6 +30,7 @@ from litellm.proxy.auth.auth_utils import (
log_once_if_budget_reservation_disabled,
warn_once_if_custom_auth_skips_common_checks,
)
from litellm.router import Router
class TestCustomAuthCommonChecksWarning:
@ -1167,6 +1168,124 @@ async def test_managed_batch_routes_pass_team_model_access_check(route, request_
)
_TEAM_DEPLOYMENT_ID: Final = "team-a-deployment-id"
_TEAM_INTERNAL_MODEL_NAME: Final = "model_name_team-a_6d1f3c2e-0000-4000-8000-000000000000"
_TEAM_PUBLIC_MODEL_NAME: Final = "team-batch-model"
_TEAM_MANAGED_BATCH_ID: Final = _encode_managed_id(
f"litellm_proxy;model_id:{_TEAM_DEPLOYMENT_ID};llm_batch_id:provider-batch-789"
)
def _team_scoped_router(public_model_name: str | None = _TEAM_PUBLIC_MODEL_NAME) -> Router:
return Router(
model_list=[
{
"model_name": _TEAM_INTERNAL_MODEL_NAME,
"litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "test-key"},
"model_info": {
"id": _TEAM_DEPLOYMENT_ID,
"team_id": "team-a",
**({"team_public_model_name": public_model_name} if public_model_name else {}),
},
},
]
)
def _team_scoped_character_id() -> str:
from litellm.types.videos.utils import encode_character_id_with_provider
return encode_character_id_with_provider(character_id="char_team", provider="openai", model_id=_TEAM_DEPLOYMENT_ID)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"route, request_data",
[
pytest.param("/v1/batches/{batch_id}", {"batch_id": _TEAM_MANAGED_BATCH_ID}, id="batch-retrieve"),
pytest.param("/v1/batches/{batch_id}/cancel", {"batch_id": _TEAM_MANAGED_BATCH_ID}, id="batch-cancel"),
pytest.param(
"/v1/fine_tuning/jobs/{fine_tuning_job_id}",
{
"fine_tuning_job_id": _encode_managed_id(
f"litellm_proxy;model_id:{_TEAM_DEPLOYMENT_ID};generic_response_id:ftjob-1"
)
},
id="fine-tuning-job",
),
pytest.param(
"/v1/vector_stores/{vector_store_id}",
{
"vector_store_id": _encode_managed_id(
"litellm_proxy:vector_store;unified_id,vs-unified;"
f"target_model_names,{_TEAM_PUBLIC_MODEL_NAME};resource_id,vs_1;model_id,{_TEAM_DEPLOYMENT_ID}"
)
},
id="vector-store",
),
pytest.param(
"/v1/videos/characters/{character_id}",
{"character_id": _team_scoped_character_id()},
id="character",
),
],
)
async def test_team_scoped_managed_ids_authorize_only_the_owning_team_by_public_name(
route: str, request_data: dict[str, str]
) -> None:
from litellm.proxy._types import ProxyException
from litellm.proxy.auth.auth_checks import can_key_call_resolved_model
llm_router: Final = _team_scoped_router()
async def check(team_id: str) -> None:
await can_key_call_resolved_model(
model=get_model_from_request(
request_data=request_data, route=route, llm_router=llm_router, team_id=team_id
),
llm_model_list=None,
valid_token=UserAPIKeyAuth(api_key="sk-team", team_id=team_id, team_models=[_TEAM_PUBLIC_MODEL_NAME]),
llm_router=llm_router,
)
await check("team-a")
with pytest.raises(ProxyException) as exc_info:
await check("team-b")
assert exc_info.value.code == "403"
@pytest.mark.asyncio
async def test_legacy_team_alias_authorizes_a_team_batch_only_for_keys_granted_the_alias() -> None:
from litellm.proxy._types import ProxyException
from litellm.proxy.auth.auth_checks import can_key_call_resolved_model
llm_router: Final = _team_scoped_router(public_model_name=None)
async def check(key_models: list[str]) -> None:
await can_key_call_resolved_model(
model=get_model_from_request(
request_data={"batch_id": _TEAM_MANAGED_BATCH_ID},
route="/v1/batches/{batch_id}",
llm_router=llm_router,
team_id="team-a",
),
llm_model_list=None,
valid_token=UserAPIKeyAuth(
api_key="sk-team",
team_id="team-a",
models=key_models,
team_models=["legacy-team-model"],
team_model_aliases={"legacy-team-model": _TEAM_INTERNAL_MODEL_NAME},
),
llm_router=llm_router,
)
await check(["legacy-team-model"])
with pytest.raises(ProxyException) as exc_info:
await check(["some-other-model"])
assert exc_info.value.code == "403"
def test_get_model_from_request_resolves_character_id_model_with_router():
from litellm.types.videos.utils import encode_character_id_with_provider

View file

@ -35,7 +35,7 @@ import logging
from contextlib import ExitStack
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional
from typing import Any, Dict, Final, Optional
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
@ -3258,6 +3258,129 @@ async def test_cancel__unified_batch_id_rejects_key_without_model_grant(cancel_h
cancel_harness.router_acancel.assert_not_called()
TEAM_INTERNAL_MODEL_NAME: Final = "model_name_team-a_00000000-0000-0000-0000-000000000000"
TEAM_PUBLIC_MODEL_NAME: Final = "team-gpt4o-mini"
def _team_scoped_deployment() -> Deployment:
return Deployment(
model_name=TEAM_INTERNAL_MODEL_NAME,
litellm_params=LiteLLM_Params(model="gpt-4o-mini"),
model_info=ModelInfo(id="dep-team-a-1", team_id="team-a", team_public_model_name=TEAM_PUBLIC_MODEL_NAME),
)
def _route_unified_batch_to_team_deployment(
harness: RetrieveHarness | CancelHarness, deployment: Deployment | None = None
) -> None:
target: Final = deployment or _team_scoped_deployment()
harness.router.resolve_model_name_from_model_id = MagicMock(return_value=target.model_name)
harness.router.get_deployment_by_model_group_name = MagicMock(return_value=target)
@pytest.mark.asyncio
async def test_retrieve__team_scoped_deployment_authorizes_owning_team_by_public_name(
retrieve_harness: RetrieveHarness,
) -> None:
_route_unified_batch_to_team_deployment(retrieve_harness)
resp: Final = await call_retrieve(
retrieve_harness, UNIFIED_BATCH_ID_FOR_GPT4O_MINI, user=_key_restricted_to(TEAM_PUBLIC_MODEL_NAME)
)
assert retrieve_harness.router_aretrieve.call_count == 1
assert resp._hidden_params["model_id"] == "gpt-4o-mini"
@pytest.mark.asyncio
async def test_cancel__team_scoped_deployment_authorizes_owning_team_by_public_name(
cancel_harness: CancelHarness,
) -> None:
_route_unified_batch_to_team_deployment(cancel_harness)
await call_cancel(cancel_harness, UNIFIED_BATCH_ID_FOR_GPT4O_MINI, user=_key_restricted_to(TEAM_PUBLIC_MODEL_NAME))
assert cancel_harness.router_acancel.call_count == 1
@pytest.mark.asyncio
@pytest.mark.parametrize(
"deployment, user",
[
pytest.param(
_team_scoped_deployment(), _key_restricted_to("some-other-model"), id="owning-team-key-without-the-model"
),
pytest.param(
_team_scoped_deployment(),
UserAPIKeyAuth(
api_key="sk-team-b",
team_id="team-b",
team_models=[TEAM_PUBLIC_MODEL_NAME],
models=[TEAM_PUBLIC_MODEL_NAME],
),
id="other-team-key-granted-the-same-public-name",
),
pytest.param(
Deployment(
model_name="model_name_team-a_ml_00000000-0000-0000-0000-000000000000",
litellm_params=LiteLLM_Params(model="gpt-4o-mini"),
model_info=ModelInfo(
id="dep-team-a-ml-1", team_id="team-a_ml", team_public_model_name=TEAM_PUBLIC_MODEL_NAME
),
),
_key_restricted_to(TEAM_PUBLIC_MODEL_NAME),
id="team-whose-id-prefixes-the-owning-team-id",
),
],
)
async def test_retrieve__team_scoped_deployment_rejects_keys_outside_its_grant(
retrieve_harness: RetrieveHarness, deployment: Deployment, user: UserAPIKeyAuth
) -> None:
_route_unified_batch_to_team_deployment(retrieve_harness, deployment)
with pytest.raises(ProxyException) as exc_info:
await call_retrieve(retrieve_harness, UNIFIED_BATCH_ID_FOR_GPT4O_MINI, user=user)
assert exc_info.value.code == "403"
retrieve_harness.router_aretrieve.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"deployment, user",
[
pytest.param(
_team_scoped_deployment(),
_key_restricted_to(TEAM_INTERNAL_MODEL_NAME),
id="owning-team-key-granted-the-internal-name",
),
pytest.param(
Deployment(
model_name=TEAM_INTERNAL_MODEL_NAME,
litellm_params=LiteLLM_Params(model="gpt-4o-mini"),
model_info=ModelInfo(id="dep-team-a-1", team_id="team-a"),
),
UserAPIKeyAuth(
api_key="sk-legacy",
team_id="team-a",
team_models=["legacy-team-model"],
models=["legacy-team-model"],
team_model_aliases={"legacy-team-model": TEAM_INTERNAL_MODEL_NAME},
),
id="legacy-team-model-reached-through-a-team-alias",
),
],
)
async def test_retrieve__team_scoped_deployment_authorizes_internal_name_and_legacy_alias_grants(
retrieve_harness: RetrieveHarness, deployment: Deployment, user: UserAPIKeyAuth
) -> None:
_route_unified_batch_to_team_deployment(retrieve_harness, deployment)
await call_retrieve(retrieve_harness, UNIFIED_BATCH_ID_FOR_GPT4O_MINI, user=user)
assert retrieve_harness.router_aretrieve.call_count == 1
@pytest.mark.asyncio
async def test_cancel__executed_batch_rejects_key_without_model_grant(cancel_harness, executed_runner):
runner, factory = executed_runner

2
uv.lock generated
View file

@ -4500,7 +4500,7 @@ wheels = [
[[package]]
name = "litellm"
version = "1.104.3"
version = "1.104.4"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },