mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-26 06:03:19 +00:00
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:
commit
b40bd7bf03
5 changed files with 276 additions and 4 deletions
|
|
@ -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}. "
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
2
uv.lock
generated
|
|
@ -4500,7 +4500,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "litellm"
|
||||
version = "1.104.3"
|
||||
version = "1.104.4"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue