From 25e7655ba9e5aa673acd381ab34739ab12f394d2 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 20 May 2026 20:03:05 -0700 Subject: [PATCH 1/5] fix(proxy): hydrate wildcard discovery credentials (#28284) (#28419) * fix(proxy): hydrate wildcard discovery credentials * fix(proxy): constrain wildcard credential hydration Co-authored-by: Dibyo Mukherjee (cherry picked from commit 37ef8d90599f516f127c4522f96dcc46f75598a7) --- litellm/proxy/auth/model_checks.py | 38 ++- litellm/proxy/utils.py | 3 + .../proxy/auth/test_model_checks.py | 238 ++++++++++++++++++ 3 files changed, 276 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/auth/model_checks.py b/litellm/proxy/auth/model_checks.py index bf76f99db69..dea79d84250 100644 --- a/litellm/proxy/auth/model_checks.py +++ b/litellm/proxy/auth/model_checks.py @@ -4,13 +4,17 @@ import litellm from litellm._logging import verbose_proxy_logger +from litellm.litellm_core_utils.credential_accessor import CredentialAccessor from litellm.proxy._types import SpecialModelNames, UserAPIKeyAuth from litellm.router import Router from litellm.router_utils.fallback_event_handlers import get_fallback_model_group -from litellm.types.router import LiteLLM_Params +from litellm.types.router import CredentialLiteLLMParams, LiteLLM_Params from litellm.utils import get_valid_models +_CREDENTIAL_LITELLM_PARAM_FIELDS = set(CredentialLiteLLMParams.model_fields) + + def _check_wildcard_routing(model: str) -> bool: """ Returns True if a model is a provider wildcard. @@ -178,6 +182,7 @@ def get_complete_model_list( model_access_groups: Dict[str, List[str]] = {}, include_model_access_groups: Optional[bool] = False, only_model_access_groups: Optional[bool] = False, + team_id: Optional[str] = None, ) -> List[str]: """Logic for returning complete model list for a given key + team pair""" @@ -222,6 +227,7 @@ def append_unique(models): unique_models=unique_models, return_wildcard_routes=return_wildcard_routes, llm_router=llm_router, + team_id=team_id, ) complete_model_list = unique_models + all_wildcard_models @@ -229,6 +235,29 @@ def append_unique(models): return complete_model_list +def _hydrate_litellm_credential_name( + litellm_params: Optional[LiteLLM_Params], +) -> Optional[LiteLLM_Params]: + if litellm_params is None or litellm_params.litellm_credential_name is None: + return litellm_params + + credential_values = CredentialAccessor.get_credential_values( + litellm_params.litellm_credential_name + ) + if not credential_values: + return litellm_params + + litellm_params = litellm_params.model_copy() + for key, value in credential_values.items(): + if ( + key in _CREDENTIAL_LITELLM_PARAM_FIELDS + and getattr(litellm_params, key, None) is None + ): + setattr(litellm_params, key, value) + litellm_params.litellm_credential_name = None + return litellm_params + + def get_known_models_from_wildcard( wildcard_model: str, litellm_params: Optional[LiteLLM_Params] = None ) -> List[str]: @@ -247,7 +276,7 @@ def get_known_models_from_wildcard( else: provider = wildcard_provider_prefix - # get all known provider models + litellm_params = _hydrate_litellm_credential_name(litellm_params) wildcard_models = get_provider_models( provider=provider, litellm_params=litellm_params @@ -285,6 +314,7 @@ def _get_wildcard_models( unique_models: List[str], return_wildcard_routes: Optional[bool] = False, llm_router: Optional[Router] = None, + team_id: Optional[str] = None, ) -> List[str]: models_to_remove = set() all_wildcard_models = [] @@ -297,7 +327,9 @@ def _get_wildcard_models( ## get litellm params from model if llm_router is not None: - model_list = llm_router.get_model_list(model_name=model) + model_list = llm_router.get_model_list( + model_name=model, team_id=team_id + ) if model_list: for router_model in model_list: wildcard_models = get_known_models_from_wildcard( diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index 32c887f17b2..36fd605cf72 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -6068,6 +6068,8 @@ async def get_available_models_for_user( include_model_access_groups=include_model_access_groups, ) + effective_team_id = team_id or user_api_key_dict.team_id + # Get complete model list all_models = get_complete_model_list( key_models=key_models, @@ -6080,6 +6082,7 @@ async def get_available_models_for_user( model_access_groups=model_access_groups, include_model_access_groups=include_model_access_groups, only_model_access_groups=only_model_access_groups, + team_id=effective_team_id, ) return all_models diff --git a/tests/test_litellm/proxy/auth/test_model_checks.py b/tests/test_litellm/proxy/auth/test_model_checks.py index 77aa03032a7..f38ac5c2000 100644 --- a/tests/test_litellm/proxy/auth/test_model_checks.py +++ b/tests/test_litellm/proxy/auth/test_model_checks.py @@ -249,3 +249,241 @@ def test_get_complete_model_list_byok_wildcard_expansion(): assert len(result) > 0 assert all(m.startswith("openai/") for m in result) assert "openai/*" not in result + + +def test_get_complete_model_list_expands_team_scoped_wildcard_with_stored_credential( + monkeypatch, +): + """ + Team-scoped BYOK wildcard deployments are stored under an internal model_name, + with the public wildcard name in model_info.team_public_model_name. + """ + import litellm + from litellm import Router + from litellm.proxy.auth import model_checks + from litellm.proxy.auth.model_checks import get_complete_model_list + from litellm.types.utils import CredentialItem + + monkeypatch.setattr( + litellm, + "credential_list", + [ + CredentialItem( + credential_name="openai-credential", + credential_info={"provider": "openai"}, + credential_values={ + "api_key": "stored-openai-key", + "api_base": "https://example.openai.test/v1", + }, + ) + ], + ) + + captured_params = {} + + def fake_get_provider_models(provider, litellm_params=None): + captured_params["provider"] = provider + captured_params["api_key"] = litellm_params.api_key + captured_params["api_base"] = litellm_params.api_base + captured_params["credential_name"] = litellm_params.litellm_credential_name + return ["gpt-4o"] + + monkeypatch.setattr(model_checks, "get_provider_models", fake_get_provider_models) + + router = Router( + model_list=[ + { + "model_name": "model_name_team-1_generated", + "litellm_params": { + "model": "openai/*", + "custom_llm_provider": "openai", + "litellm_credential_name": "openai-credential", + }, + "model_info": { + "team_id": "team-1", + "team_public_model_name": "openai/*", + }, + } + ] + ) + + result = get_complete_model_list( + key_models=[], + team_models=["openai/*"], + proxy_model_list=[], + user_model=None, + infer_model_from_keys=False, + llm_router=router, + team_id="team-1", + ) + + assert "openai/gpt-4o" in result + assert captured_params == { + "provider": "openai", + "api_key": "stored-openai-key", + "api_base": "https://example.openai.test/v1", + "credential_name": None, + } + + +def test_wildcard_credential_hydration_preserves_deployment_params( + monkeypatch, +): + import litellm + from litellm.proxy.auth import model_checks + from litellm.proxy.auth.model_checks import get_known_models_from_wildcard + from litellm.types.router import LiteLLM_Params + from litellm.types.utils import CredentialItem + + monkeypatch.setattr( + litellm, + "credential_list", + [ + CredentialItem( + credential_name="openai-credential", + credential_info={"provider": "openai"}, + credential_values={ + "api_key": "stored-openai-key", + "api_version": "credential-version", + "model": "openai/wrong-model", + "unexpected_field": "unexpected-value", + }, + ) + ], + ) + + captured_params = {} + + def fake_get_provider_models(provider, litellm_params=None): + captured_params["provider"] = provider + captured_params["model"] = litellm_params.model + captured_params["api_key"] = litellm_params.api_key + captured_params["api_version"] = litellm_params.api_version + captured_params["credential_name"] = litellm_params.litellm_credential_name + captured_params["has_unexpected_field"] = hasattr( + litellm_params, "unexpected_field" + ) + return ["gpt-4o"] + + monkeypatch.setattr(model_checks, "get_provider_models", fake_get_provider_models) + + result = get_known_models_from_wildcard( + wildcard_model="openai/*", + litellm_params=LiteLLM_Params( + model="openai/*", + custom_llm_provider="openai", + api_version="deployment-version", + litellm_credential_name="openai-credential", + ), + ) + + assert result == ["openai/gpt-4o"] + assert captured_params == { + "provider": "openai", + "model": "openai/*", + "api_key": "stored-openai-key", + "api_version": "deployment-version", + "credential_name": None, + "has_unexpected_field": False, + } + + +def test_wildcard_credential_hydration_preserves_missing_credential_name( + monkeypatch, +): + import litellm + from litellm.proxy.auth import model_checks + from litellm.proxy.auth.model_checks import get_known_models_from_wildcard + from litellm.types.router import LiteLLM_Params + + monkeypatch.setattr(litellm, "credential_list", []) + + captured_params = {} + + def fake_get_provider_models(provider, litellm_params=None): + captured_params["provider"] = provider + captured_params["api_key"] = litellm_params.api_key + captured_params["credential_name"] = litellm_params.litellm_credential_name + return ["gpt-4o"] + + monkeypatch.setattr(model_checks, "get_provider_models", fake_get_provider_models) + + result = get_known_models_from_wildcard( + wildcard_model="openai/*", + litellm_params=LiteLLM_Params( + model="openai/*", + custom_llm_provider="openai", + api_key=None, + litellm_credential_name="missing-credential", + ), + ) + + assert result == ["openai/gpt-4o"] + assert captured_params == { + "provider": "openai", + "api_key": None, + "credential_name": "missing-credential", + } + + +@pytest.mark.asyncio +async def test_get_available_models_for_user_expands_query_team_wildcard( + monkeypatch, +): + import litellm + from litellm import Router + from litellm.proxy.auth import model_checks + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.utils import get_available_models_for_user + from litellm.types.utils import CredentialItem + + monkeypatch.setattr( + litellm, + "credential_list", + [ + CredentialItem( + credential_name="openai-credential", + credential_info={"provider": "openai"}, + credential_values={"api_key": "stored-openai-key"}, + ) + ], + ) + + def fake_get_provider_models(provider, litellm_params=None): + assert litellm_params.api_key == "stored-openai-key" + assert litellm_params.litellm_credential_name is None + return ["gpt-4o-mini"] + + monkeypatch.setattr(model_checks, "get_provider_models", fake_get_provider_models) + + router = Router( + model_list=[ + { + "model_name": "model_name_team-1_generated", + "litellm_params": { + "model": "openai/*", + "custom_llm_provider": "openai", + "litellm_credential_name": "openai-credential", + }, + "model_info": { + "team_id": "team-1", + "team_public_model_name": "openai/*", + }, + } + ] + ) + + result = await get_available_models_for_user( + user_api_key_dict=UserAPIKeyAuth( + api_key="sk-test", + models=[], + team_id="team-1", + team_models=["openai/*"], + ), + llm_router=router, + general_settings={}, + user_model=None, + team_id="team-1", + ) + + assert "openai/gpt-4o-mini" in result From 1034e4ee21a44de497d2e1f2c08d0b17193a888f Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 23 May 2026 16:41:05 -0700 Subject: [PATCH 2/5] fix(team): refresh team cache on team_model_add/delete (LIT-3244) (#28683) * fix(team): refresh team cache on team_model_add/delete (LIT-3244) team_model_add and team_model_delete wrote to the DB but did not invalidate the in-memory LiteLLM_TeamTableCachedObj used by common_checks. After the v1.83.14 common_checks centralization made team.models authoritative on /v1/files and /v1/vector_stores/*, adding a Team-BYOK model silently failed to grant the new public model name to team members until the cache TTL expired (and a removed model kept working until then on the symmetric path). Extract the cache-refresh snippet from update_team into a small helper and apply it consistently at all three team-write sites. * test: also assert updated models in team-cache-refresh pin Strengthens the LIT-3244 regression test to also assert `call_kwargs["team_table"].models` matches the updated row, not just `team_id`. Both `existing_team` and `updated_team` share `team_id` in the test setup, so the previous assertion would have passed even if the implementation accidentally cached the pre-mutation row. Greptile review feedback. * fix(team): hydrate object_permission on cache-refreshing team updates The Prisma update calls in update_team, team_model_add, and team_model_delete returned a team row with object_permission_id set but object_permission=None (the relation was not requested via include=). _refresh_cached_team then wrote that to the in-memory LiteLLM_TeamTableCachedObj, and the cache-hit path in get_team_object returns the cached object without re-hydrating. Downstream consumers (validate_key_search_tools_against_team, the MCP/agent authz paths) treat a missing object_permission as no team-level restriction, so a team-write op silently dropped object-permission enforcement until the cache TTL expired or a DB-fetch path re-hydrated it. Add include={"object_permission": True} to all three updates so the refresh writes a complete cached team. Extend the LIT-3244 regression test to pin both the cached object_permission and the include shape on the Prisma call. Surfaced in PR review of LIT-3244. (cherry picked from commit 5f73ad4fe7dc45fce8bb780564170a49a458db15) --- .../management_endpoints/team_endpoints.py | 82 +++++++++-- .../test_team_endpoints.py | 139 +++++++++++++++++- 2 files changed, 208 insertions(+), 13 deletions(-) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 35e3d196e9e..53376d5762d 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -64,6 +64,7 @@ UserAPIKeyAuth, ) from litellm.proxy.auth.auth_checks import ( + _cache_team_object, allowed_route_check_inside_route, can_org_access_model, get_org_object, @@ -128,6 +129,33 @@ def _sanitize_for_log(value: Any) -> str: return text.replace("\r", "").replace("\n", "") +async def _refresh_cached_team( + team_row: Any, + user_api_key_cache: Any, + proxy_logging_obj: Any, +) -> None: + """ + Refresh the in-memory cached team object after a DB write. + + Every endpoint that mutates `litellm_teamtable` must call this so the + cached `LiteLLM_TeamTableCachedObj` used by `common_checks` stays in + sync. Without this, subsequent auth checks read a stale team and can + 403 on permissions the DB has already granted (or, symmetrically, + keep granting permissions the DB has already revoked). + + `team_row` is the Prisma row returned by `update`/`find_unique` on + `litellm_teamtable`. It is converted to `LiteLLM_TeamTableCachedObj` + via `model_dump()` to match the cache shape `_cache_team_object` + expects. + """ + await _cache_team_object( + team_id=team_row.team_id, + team_table=LiteLLM_TeamTableCachedObj(**team_row.model_dump()), + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + + async def _verify_team_access( team_obj: LiteLLM_TeamTable, user_api_key_dict: UserAPIKeyAuth, @@ -1580,7 +1608,6 @@ async def update_team( # noqa: PLR0915 ``` """ try: - from litellm.proxy.auth.auth_checks import _cache_team_object from litellm.proxy.proxy_server import ( litellm_proxy_admin_name, llm_router, @@ -1843,7 +1870,13 @@ async def update_team( # noqa: PLR0915 await prisma_client.db.litellm_teamtable.update( where={"team_id": data.team_id}, data=updated_kv, - include={"litellm_model_table": True}, # type: ignore + # `object_permission` is included so `_refresh_cached_team` + # doesn't write a cached team with the relation nulled out — + # see team_model_add for the full rationale. + include={ + "litellm_model_table": True, + "object_permission": True, + }, # type: ignore ) ) @@ -1856,9 +1889,8 @@ async def update_team( # noqa: PLR0915 verbose_proxy_logger.info( "Successfully updated team - %s, info", team_row.team_id ) - await _cache_team_object( - team_id=team_row.team_id, - team_table=LiteLLM_TeamTableCachedObj(**team_row.model_dump()), + await _refresh_cached_team( + team_row=team_row, user_api_key_cache=user_api_key_cache, proxy_logging_obj=proxy_logging_obj, ) @@ -4553,7 +4585,11 @@ async def team_model_add( }' ``` """ - from litellm.proxy.proxy_server import prisma_client + from litellm.proxy.proxy_server import ( + prisma_client, + proxy_logging_obj, + user_api_key_cache, + ) if prisma_client is None: raise HTTPException(status_code=500, detail={"error": "No db connected"}) @@ -4587,9 +4623,21 @@ async def team_model_add( ) updated_models = add_new_models_to_team(team_obj=team_obj, new_models=data.models) - # Update team + # Update team. `include` mirrors the relations the auth path consumes + # off the cached team object so that `_refresh_cached_team` doesn't + # null them out — see object_permission_utils.validate_key_search_tools_against_team + # and the MCP/agent authz paths, which treat a missing object_permission + # as "no team-level restriction". updated_team = await prisma_client.db.litellm_teamtable.update( - where={"team_id": data.team_id}, data={"models": updated_models} + where={"team_id": data.team_id}, + data={"models": updated_models}, + include={"object_permission": True}, # type: ignore + ) + + await _refresh_cached_team( + team_row=updated_team, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, ) return updated_team @@ -4624,7 +4672,11 @@ async def team_model_delete( }' ``` """ - from litellm.proxy.proxy_server import prisma_client + from litellm.proxy.proxy_server import ( + prisma_client, + proxy_logging_obj, + user_api_key_cache, + ) if prisma_client is None: raise HTTPException(status_code=500, detail={"error": "No db connected"}) @@ -4663,9 +4715,17 @@ async def team_model_delete( # Remove specified models updated_models = [m for m in current_models if m not in data.models] - # Update team + # Update team. See team_model_add for the rationale on `include`. updated_team = await prisma_client.db.litellm_teamtable.update( - where={"team_id": data.team_id}, data={"models": updated_models} + where={"team_id": data.team_id}, + data={"models": updated_models}, + include={"object_permission": True}, # type: ignore + ) + + await _refresh_cached_team( + team_row=updated_team, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, ) return updated_team diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index 5c7bbc46c95..1acf3176b6d 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -1540,6 +1540,137 @@ def test_add_new_models_to_team_with_existing_models(): assert updated_models.sort() == ["model1", "model2", "model3", "model4"].sort() +@pytest.mark.asyncio +@pytest.mark.parametrize( + "endpoint_name", + ["team_model_add", "team_model_delete"], +) +async def test_team_model_add_delete_refresh_team_cache(endpoint_name): + """ + Regression pin for LIT-3244 vector-store BYOK 403. + + `team_model_add` and `team_model_delete` mutate `team.models` in the + DB. Without a cache refresh, the in-memory `LiteLLM_TeamTableCachedObj` + used by `common_checks` stays stale and team members 403 on a model + the DB has just granted (or, symmetrically, keep using a model the DB + has just revoked). + + Pin: after the DB update, the endpoint must call `_cache_team_object` + with the updated team row so the cached team stays in sync. + """ + from unittest.mock import AsyncMock, MagicMock, Mock, patch + + from fastapi import Request + + from litellm.proxy._types import ( + LitellmUserRoles, + TeamModelAddRequest, + TeamModelDeleteRequest, + UserAPIKeyAuth, + ) + from litellm.proxy.management_endpoints.team_endpoints import ( + team_model_add, + team_model_delete, + ) + + mock_request = Mock(spec=Request) + mock_user_api_key_dict = UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, user_id="test_user_id" + ) + + existing_team = MagicMock() + existing_team.model_dump.return_value = { + "team_id": "team-1234", + "models": ["bedrock-claude-sonnet-4", "openai/*"], + "object_permission_id": "op-1234", + "object_permission": { + "object_permission_id": "op-1234", + "search_tools": ["allowed-tool-A"], + }, + } + + updated_team = MagicMock() + updated_team.team_id = "team-1234" + updated_team.model_dump.return_value = { + "team_id": "team-1234", + "models": ["bedrock-claude-sonnet-4", "openai/*", "team-byok-1"], + # The Prisma update must come back with `object_permission` populated + # (via `include={"object_permission": True}`), otherwise the cache + # write below would null it out — see LIT-3244 follow-up. + "object_permission_id": "op-1234", + "object_permission": { + "object_permission_id": "op-1234", + "search_tools": ["allowed-tool-A"], + }, + } + + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client, + patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, + patch("litellm.proxy.proxy_server.proxy_logging_obj") as mock_logging, + patch( + "litellm.proxy.management_endpoints.team_endpoints._cache_team_object" + ) as mock_cache_team, + ): + mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock( + return_value=existing_team + ) + mock_prisma_client.db.litellm_teamtable.update = AsyncMock( + return_value=updated_team + ) + mock_cache_team.return_value = None + + if endpoint_name == "team_model_add": + await team_model_add( + data=TeamModelAddRequest(team_id="team-1234", models=["team-byok-1"]), + http_request=mock_request, + user_api_key_dict=mock_user_api_key_dict, + ) + else: + await team_model_delete( + data=TeamModelDeleteRequest(team_id="team-1234", models=["openai/*"]), + http_request=mock_request, + user_api_key_dict=mock_user_api_key_dict, + ) + + # The pin: cache refresh must run with the updated team row. + assert mock_cache_team.await_count == 1, ( + f"{endpoint_name} must call _cache_team_object exactly once " + f"after the DB update (LIT-3244 regression pin); " + f"got await_count={mock_cache_team.await_count}" + ) + call_kwargs = mock_cache_team.await_args.kwargs + assert call_kwargs["team_id"] == "team-1234" + # The cached object must be built from the *updated* row, not the + # pre-mutation `existing_team` — that's the whole point. Both rows + # share team_id, so the only assertion that actually pins this is + # against the field that differs between them: `models`. + assert call_kwargs["team_table"].team_id == "team-1234" + assert call_kwargs["team_table"].models == [ + "bedrock-claude-sonnet-4", + "openai/*", + "team-byok-1", + ] + # And the cached object MUST carry the `object_permission` relation + # (LIT-3244 follow-up). If the Prisma update were missing + # `include={"object_permission": True}`, the cached team would have + # object_permission=None, and downstream consumers like + # `validate_key_search_tools_against_team` would treat that as + # "no team-level restriction" and stop enforcing the team's + # search-tool allowlist on key issuance. + assert call_kwargs["team_table"].object_permission is not None + assert call_kwargs["team_table"].object_permission.search_tools == [ + "allowed-tool-A" + ] + # Pin the Prisma call shape too — the regression is in *what the + # update returns*, so the contract that the update asks for + # `object_permission` belongs in this test. + update_call_kwargs = ( + mock_prisma_client.db.litellm_teamtable.update.call_args.kwargs + ) + assert update_call_kwargs.get("include", {}).get("object_permission") is True + + @pytest.mark.asyncio async def test_update_team_team_member_budget_not_passed_to_db(): """ @@ -1568,7 +1699,9 @@ async def test_update_team_team_member_budget_not_passed_to_db(): patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, patch("litellm.proxy.proxy_server.proxy_logging_obj") as mock_logging, patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), - patch("litellm.proxy.auth.auth_checks._cache_team_object") as mock_cache_team, + patch( + "litellm.proxy.management_endpoints.team_endpoints._cache_team_object" + ) as mock_cache_team, patch( "litellm.proxy.management_endpoints.team_endpoints.TeamMemberBudgetHandler.upsert_team_member_budget_table" ) as mock_upsert_budget, @@ -1999,7 +2132,9 @@ async def test_update_team_with_team_member_budget_duration(): patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, patch("litellm.proxy.proxy_server.proxy_logging_obj") as mock_logging, patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), - patch("litellm.proxy.auth.auth_checks._cache_team_object") as mock_cache_team, + patch( + "litellm.proxy.management_endpoints.team_endpoints._cache_team_object" + ) as mock_cache_team, patch( "litellm.proxy.management_endpoints.team_endpoints.TeamMemberBudgetHandler.upsert_team_member_budget_table" ) as mock_upsert_budget, From c1ed8fd6099e19038e7ef35ec3257c9be1def231 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 23 May 2026 19:35:06 -0700 Subject: [PATCH 3/5] fix(team): keep team_alias cache in sync on _cache_team_object writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _cache_team_object wrote only to the team_id: cache key, but the JWT auth path that uses team_alias_jwt_field reads from a separate team_alias: key (get_team_object_by_alias caches under both keys on miss, but reads only the alias-keyed one). After any team-mutation endpoint (team_model_add, team_model_delete, update_team, the two access-group writes) the team_id cache was refreshed but the team_alias cache stayed stale until TTL — JWT callers using team_alias_jwt_field kept seeing the pre-mutation team for the full cache window. Mirror the write under the alias key inside _cache_team_object so every existing caller stays in sync without further changes. Skip the alias write when team_alias is None/empty so we don't collide across alias-less teams. Surfaced testing the LIT-3244 cherry-pick on patch/1.86.0: the LIT-3244 fix correctly invalidated the team_id cache but the customer's JWT used team_alias_jwt_field, so they kept hitting the stale alias-keyed entry. --- litellm/proxy/auth/auth_checks.py | 20 ++++- .../proxy/auth/test_auth_checks.py | 90 +++++++++++++++++++ 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 13381c7a6c9..16a9f07fa5c 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -1644,19 +1644,33 @@ async def _cache_team_object( user_api_key_cache: UserApiKeyCache, proxy_logging_obj: Optional[ProxyLogging], ): - key = "team_id:{}".format(team_id) - ## CACHE REFRESH TIME! team_table.last_refreshed_at = time.time() await _cache_management_object( - key=key, + key="team_id:{}".format(team_id), value=team_table, user_api_key_cache=user_api_key_cache, proxy_logging_obj=proxy_logging_obj, model_type=LiteLLM_TeamTableCachedObj, ) + # Mirror the write under the alias key too. `get_team_object_by_alias` + # (used by the JWT auth path with `team_alias_jwt_field`) reads from + # `team_alias:`, and `_cache_team_object` is the canonical + # "refresh this team" primitive — every caller (cache-refreshing team + # writes, DB-fetch repopulate, access-group endpoints) needs the + # alias-keyed entry to stay in sync, otherwise the alias path keeps + # serving stale `team.models` / `object_permission` until cache TTL. + if team_table.team_alias: + await _cache_management_object( + key="team_alias:{}".format(team_table.team_alias), + value=team_table, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + model_type=LiteLLM_TeamTableCachedObj, + ) + async def _cache_key_object( hashed_token: str, diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index 26f04a4abcb..1e34a67b7d8 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -3016,3 +3016,93 @@ async def mock_get_current_spend(counter_key, fallback_spend): proxy_logging_obj=proxy_logging_obj, ) assert exc_info.value.max_budget == 0.0 + + +@pytest.mark.asyncio +async def test_cache_team_object_writes_both_team_id_and_team_alias_keys(): + """ + Regression pin for LIT-3244 patch/1.86.0 follow-up. + + `_cache_team_object` is the canonical "refresh this team" primitive. + It must populate BOTH cache keys readers can hit: + - "team_id:" — used by `get_team_object(team_id=...)`, + i.e. API-key auth and JWT-with-team_id_jwt_field + - "team_alias:" — used by `get_team_object_by_alias(team_alias=...)`, + i.e. JWT-with-team_alias_jwt_field + + Without the alias-keyed write, JWT callers using `team_alias_jwt_field` + see a stale team after every team-mutation endpoint (team_model_add, + team_model_delete, update_team, access-group writes), even though + the team_id-keyed cache has been refreshed. + + Symmetric absence test: when team_alias is empty/None, the helper + must NOT write to "team_alias:" (an empty alias would collide across + every alias-less team and serve the wrong row). + """ + from unittest.mock import AsyncMock, MagicMock + + from litellm.proxy._types import LiteLLM_TeamTableCachedObj + from litellm.proxy.auth.auth_checks import _cache_team_object + + base_team_row = { + "team_id": "team-1234", + "team_alias": "H-Capacity", + "models": ["openai/*", "bedrock-claude-sonnet-4"], + } + + # ===== team_alias is set: both keys must be written ===== + team_table = LiteLLM_TeamTableCachedObj(**base_team_row) + cache = MagicMock() + cache.async_set_cache = AsyncMock() + + await _cache_team_object( + team_id="team-1234", + team_table=team_table, + user_api_key_cache=cache, + proxy_logging_obj=MagicMock(), + ) + + written_keys = [] + for call in cache.async_set_cache.await_args_list: + key = call.kwargs.get("key") or call.args[0] + written_keys.append(key) + assert "team_id:team-1234" in written_keys, ( + "_cache_team_object must write the team_id-keyed cache " + f"(saw: {written_keys})" + ) + assert "team_alias:H-Capacity" in written_keys, ( + "_cache_team_object must ALSO write the team_alias-keyed cache " + f"so JWT-with-team_alias_jwt_field readers stay in sync " + f"(saw: {written_keys})" + ) + + # The cached value under both keys should be the same team object — + # otherwise readers via team_id vs. team_alias diverge. + written_values = [] + for call in cache.async_set_cache.await_args_list: + value = call.kwargs.get("value") or call.args[1] + written_values.append(value) + assert all(v is team_table for v in written_values), ( + "All cache writes from _cache_team_object must reference the same " + "LiteLLM_TeamTableCachedObj instance (no divergence between keys)." + ) + + # ===== team_alias is None: alias key must NOT be written ===== + aliasless = LiteLLM_TeamTableCachedObj(**{**base_team_row, "team_alias": None}) + cache2 = MagicMock() + cache2.async_set_cache = AsyncMock() + await _cache_team_object( + team_id="team-no-alias", + team_table=aliasless, + user_api_key_cache=cache2, + proxy_logging_obj=MagicMock(), + ) + written_keys_aliasless = [ + (c.kwargs.get("key") or c.args[0]) + for c in cache2.async_set_cache.await_args_list + ] + assert written_keys_aliasless == ["team_id:team-no-alias"], ( + "When team has no alias, only the team_id-keyed write should fire " + "(empty alias would collide across teams). " + f"Got: {written_keys_aliasless}" + ) From 9721eb7918d2ac1da2894b98cee73b8cc59e8768 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Mon, 25 May 2026 15:01:16 -0700 Subject: [PATCH 4/5] fix(team): delete (not overwrite) team_alias cache on _cache_team_object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior shape of this PR wrote both team_id: AND team_alias: from _cache_team_object. team_alias is NOT unique in the schema (no @unique on LiteLLM_TeamTable.team_alias), and get_team_object_by_alias enforces uniqueness on its own DB-fetch path (len(teams) > 1 raises). Writing the alias-keyed cache from the generic refresh path bypassed that check: a team admin renaming their team to collide with another team's alias could silently overwrite the cached team for JWT-by-alias auth, swapping the resolved team under that alias for the cache window. Switch the alias-keyed operation from a write to a delete (mirroring the dual-cache delete pattern in _delete_cache_key_object). After every team write, the next JWT-by-alias reader cache-misses and falls through to get_team_object_by_alias, which (a) re-fetches the fresh team from DB, closing the LIT-3244 staleness gap that motivated this PR, and (b) enforces alias uniqueness before populating either cache key. team_id: writes are unchanged — team_id is the table PK and is guaranteed unique. Surfaced in veria-ai review on #28739. --- litellm/proxy/auth/auth_checks.py | 34 +++++--- .../proxy/auth/test_auth_checks.py | 87 ++++++++++--------- 2 files changed, 68 insertions(+), 53 deletions(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 16a9f07fa5c..68ae01ed2c2 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -1647,6 +1647,7 @@ async def _cache_team_object( ## CACHE REFRESH TIME! team_table.last_refreshed_at = time.time() + # team_id is the table primary key — guaranteed unique, safe to write. await _cache_management_object( key="team_id:{}".format(team_id), value=team_table, @@ -1655,21 +1656,26 @@ async def _cache_team_object( model_type=LiteLLM_TeamTableCachedObj, ) - # Mirror the write under the alias key too. `get_team_object_by_alias` - # (used by the JWT auth path with `team_alias_jwt_field`) reads from - # `team_alias:`, and `_cache_team_object` is the canonical - # "refresh this team" primitive — every caller (cache-refreshing team - # writes, DB-fetch repopulate, access-group endpoints) needs the - # alias-keyed entry to stay in sync, otherwise the alias path keeps - # serving stale `team.models` / `object_permission` until cache TTL. + # Invalidate the alias-keyed cache so the JWT auth path with + # `team_alias_jwt_field` (which reads via `get_team_object_by_alias`) + # doesn't keep serving the pre-mutation team after every team-write + # endpoint (team_model_add, team_model_delete, update_team, etc.). + # + # Why DELETE and not WRITE: `team_alias` has no UNIQUE constraint in + # schema.prisma. Writing this cache from the generic refresh path + # would let a team admin who renamed their team to collide with + # another team's alias silently overwrite the cached team for + # JWT-by-alias auth (veria-ai review on #28739). Deleting forces the + # next reader through `get_team_object_by_alias`, which DOES enforce + # uniqueness (len(teams) > 1 raises HTTPException) before populating + # the cache from a verified single row. if team_table.team_alias: - await _cache_management_object( - key="team_alias:{}".format(team_table.team_alias), - value=team_table, - user_api_key_cache=user_api_key_cache, - proxy_logging_obj=proxy_logging_obj, - model_type=LiteLLM_TeamTableCachedObj, - ) + alias_key = "team_alias:{}".format(team_table.team_alias) + user_api_key_cache.delete_cache(key=alias_key) + if proxy_logging_obj is not None: + await proxy_logging_obj.internal_usage_cache.dual_cache.async_delete_cache( + key=alias_key + ) async def _cache_key_object( diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index 1e34a67b7d8..d208786b939 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -3019,25 +3019,30 @@ async def mock_get_current_spend(counter_key, fallback_spend): @pytest.mark.asyncio -async def test_cache_team_object_writes_both_team_id_and_team_alias_keys(): +async def test_cache_team_object_writes_team_id_and_invalidates_team_alias(): """ Regression pin for LIT-3244 patch/1.86.0 follow-up. `_cache_team_object` is the canonical "refresh this team" primitive. - It must populate BOTH cache keys readers can hit: + Two cache keys are in play: - "team_id:" — used by `get_team_object(team_id=...)`, i.e. API-key auth and JWT-with-team_id_jwt_field - "team_alias:" — used by `get_team_object_by_alias(team_alias=...)`, i.e. JWT-with-team_alias_jwt_field - Without the alias-keyed write, JWT callers using `team_alias_jwt_field` - see a stale team after every team-mutation endpoint (team_model_add, - team_model_delete, update_team, access-group writes), even though - the team_id-keyed cache has been refreshed. - - Symmetric absence test: when team_alias is empty/None, the helper - must NOT write to "team_alias:" (an empty alias would collide across - every alias-less team and serve the wrong row). + Invariants this test pins: + 1. Writes the team_id-keyed entry with the refreshed object (team_id + is the table PK — guaranteed unique, safe to write). + 2. DELETES (does NOT write) the team_alias-keyed entry. `team_alias` + has no UNIQUE constraint in schema.prisma, so writing it from + this generic refresh path would let a team admin who renames + their team to collide with another team's alias silently + overwrite the cached team for JWT-by-alias auth (veria-ai + review on #28739). Deleting forces the next JWT-by-alias + reader through `get_team_object_by_alias`, which enforces + len(teams)==1 before populating the cache. + 3. When team_alias is None, NO alias-key operation happens (no + delete of an empty-keyed entry, no spurious write). """ from unittest.mock import AsyncMock, MagicMock @@ -3050,59 +3055,63 @@ async def test_cache_team_object_writes_both_team_id_and_team_alias_keys(): "models": ["openai/*", "bedrock-claude-sonnet-4"], } - # ===== team_alias is set: both keys must be written ===== + # ===== team_alias is set ===== team_table = LiteLLM_TeamTableCachedObj(**base_team_row) cache = MagicMock() cache.async_set_cache = AsyncMock() + cache.delete_cache = MagicMock() + logging_obj = MagicMock() + logging_obj.internal_usage_cache.dual_cache.async_delete_cache = AsyncMock() await _cache_team_object( team_id="team-1234", team_table=team_table, user_api_key_cache=cache, - proxy_logging_obj=MagicMock(), + proxy_logging_obj=logging_obj, ) - written_keys = [] - for call in cache.async_set_cache.await_args_list: - key = call.kwargs.get("key") or call.args[0] - written_keys.append(key) - assert "team_id:team-1234" in written_keys, ( - "_cache_team_object must write the team_id-keyed cache " - f"(saw: {written_keys})" + # (1) team_id-keyed write fires with the refreshed object + written_keys = [ + (c.kwargs.get("key") or c.args[0]) + for c in cache.async_set_cache.await_args_list + ] + assert written_keys == ["team_id:team-1234"], ( + "Only the team_id-keyed write should fire; the alias key must be " + "deleted, NOT written. " + f"Got writes: {written_keys}" ) - assert "team_alias:H-Capacity" in written_keys, ( - "_cache_team_object must ALSO write the team_alias-keyed cache " - f"so JWT-with-team_alias_jwt_field readers stay in sync " - f"(saw: {written_keys})" + written_value = ( + cache.async_set_cache.await_args.kwargs.get("value") + or cache.async_set_cache.await_args.args[1] ) + assert written_value is team_table - # The cached value under both keys should be the same team object — - # otherwise readers via team_id vs. team_alias diverge. - written_values = [] - for call in cache.async_set_cache.await_args_list: - value = call.kwargs.get("value") or call.args[1] - written_values.append(value) - assert all(v is team_table for v in written_values), ( - "All cache writes from _cache_team_object must reference the same " - "LiteLLM_TeamTableCachedObj instance (no divergence between keys)." + # (2) team_alias-keyed entry is deleted in BOTH the in-memory cache + # and the Redis dual cache (mirrors _delete_cache_key_object pattern). + cache.delete_cache.assert_called_once_with(key="team_alias:H-Capacity") + logging_obj.internal_usage_cache.dual_cache.async_delete_cache.assert_awaited_once_with( + key="team_alias:H-Capacity" ) - # ===== team_alias is None: alias key must NOT be written ===== + # ===== team_alias is None: no alias-key operation ===== aliasless = LiteLLM_TeamTableCachedObj(**{**base_team_row, "team_alias": None}) cache2 = MagicMock() cache2.async_set_cache = AsyncMock() + cache2.delete_cache = MagicMock() + logging_obj2 = MagicMock() + logging_obj2.internal_usage_cache.dual_cache.async_delete_cache = AsyncMock() + await _cache_team_object( team_id="team-no-alias", team_table=aliasless, user_api_key_cache=cache2, - proxy_logging_obj=MagicMock(), + proxy_logging_obj=logging_obj2, ) + + cache2.delete_cache.assert_not_called() + logging_obj2.internal_usage_cache.dual_cache.async_delete_cache.assert_not_awaited() written_keys_aliasless = [ (c.kwargs.get("key") or c.args[0]) for c in cache2.async_set_cache.await_args_list ] - assert written_keys_aliasless == ["team_id:team-no-alias"], ( - "When team has no alias, only the team_id-keyed write should fire " - "(empty alias would collide across teams). " - f"Got: {written_keys_aliasless}" - ) + assert written_keys_aliasless == ["team_id:team-no-alias"] From 2f50006a726cdb8d0a840be668e49e4bac65b954 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Mon, 25 May 2026 16:19:29 -0700 Subject: [PATCH 5/5] fix(managed-files): anchor model_id regex so it doesn't match llm_output_file_model_id extract_model_id_from_unified_id used `re.search(r"model_id,([^;]+)", ...)` which substring-matches the `model_id,` inside the file-ID encoding's `llm_output_file_model_id,` field. parse_unified_id then fed that deployment UUID back into the auth path as a model candidate via _extract_models_from_managed_resource_id, and every team-BYOK file attach 403'd with: team not allowed to access model. This team can only access models=['openai/*']. Tried to access The team's models list correctly contains the public name (`openai/*`) that target_model_names matches, but the bogus UUID candidate fails the wildcard check first. Anchor the regex to a field boundary (`(?:^|;)model_id,`) so it matches the legitimate top-level `model_id,` field on vector_store unified IDs and skips substring matches inside other fields. File-IDs (which have no top-level `model_id` field) now return None and contribute no spurious UUID candidate. Surfaced reproducing LIT-3244 on patch/1.86.0 with the customer's exact flow: team with openai/* BYOK deployment, JWT-scoped user, POST /v1/vector_stores/{id}/files attaching a file uploaded with target_model_names=openai/gpt-4o. --- .../llms/base_llm/managed_resources/utils.py | 10 +- .../base_llm/test_managed_resources_utils.py | 134 ++++++++++++++++++ 2 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 tests/test_litellm/llms/base_llm/test_managed_resources_utils.py diff --git a/litellm/llms/base_llm/managed_resources/utils.py b/litellm/llms/base_llm/managed_resources/utils.py index 59f5ff0d845..6e30b6cb252 100644 --- a/litellm/llms/base_llm/managed_resources/utils.py +++ b/litellm/llms/base_llm/managed_resources/utils.py @@ -177,8 +177,14 @@ def extract_model_id_from_unified_id( if decoded_id: unified_id = decoded_id - # Extract model ID - match = re.search(r"model_id,([^;]+)", unified_id) + # Extract model ID. Anchor to a field boundary (start of string or + # after `;`) so this regex doesn't substring-match the `model_id,` + # inside file_id encodings' `llm_output_file_model_id,` + # field — that would feed the deployment UUID as a model candidate + # into the team-access check and 403 every team-BYOK file attach + # with `Tried to access ` (LIT-3244 patch/1.86.0 second-order + # finding). + match = re.search(r"(?:^|;)model_id,([^;]+)", unified_id) if match: return match.group(1).strip() diff --git a/tests/test_litellm/llms/base_llm/test_managed_resources_utils.py b/tests/test_litellm/llms/base_llm/test_managed_resources_utils.py new file mode 100644 index 00000000000..3cecb7fa963 --- /dev/null +++ b/tests/test_litellm/llms/base_llm/test_managed_resources_utils.py @@ -0,0 +1,134 @@ +""" +Tests for `litellm.llms.base_llm.managed_resources.utils.extract_model_id_from_unified_id`. + +The regex inside this helper is shared by both the vector-store unified-ID +format (`...;model_id,;...`) and the file-ID format (`...;llm_output_file_model_id,`). +A naive regex (`r"model_id,([^;]+)"`) substring-matches the latter and +returns the deployment UUID, which then gets fed as a model candidate +into the team-access check and 403s every team-BYOK file attach +(LIT-3244 patch/1.86.0 second-order finding). These tests pin the +field-boundary anchor that prevents that. +""" + +import pytest + +from litellm.llms.base_llm.managed_resources.utils import ( + encode_unified_id, + extract_model_id_from_unified_id, +) + +# --------------------------------------------------------------------------- +# Vector-store unified-ID shape — has a top-level `model_id,` field. +# Existing behavior must be preserved: returns the value. +# --------------------------------------------------------------------------- + + +def test_extract_model_id_returns_value_for_vector_store_unified_id(): + unified_id = ( + "litellm_proxy:vector_store" + ";unified_id,abc-123" + ";target_model_names,gpt-4,gemini" + ";resource_id,vs_xyz" + ";model_id,deployment-uuid-456" + ) + assert extract_model_id_from_unified_id(unified_id) == "deployment-uuid-456" + + +def test_extract_model_id_returns_value_when_field_is_first(): + """`model_id` is the very first field after the prefix (anchor must accept start-of-string).""" + unified_id = "litellm_proxy:vector_store;model_id,first-field-value;unified_id,abc" + # First field after the prefix is preceded by `;`, so it matches via the + # `;model_id,` branch. Pin that the anchor isn't accidentally too strict. + assert extract_model_id_from_unified_id(unified_id) == "first-field-value" + + +# --------------------------------------------------------------------------- +# File-ID shape — has `llm_output_file_model_id,` but no top-level +# `model_id,` field. Must return None (the previous regex would have +# substring-matched and returned the deployment UUID). +# --------------------------------------------------------------------------- + + +def test_extract_model_id_returns_none_for_file_id_without_model_id_field(): + """Regression pin for LIT-3244 patch/1.86.0. + + File-IDs constructed via `LITELLM_MANAGED_FILE_COMPLETE_STR` have + `llm_output_file_model_id,` but no top-level + `model_id,` field. The previous regex matched the substring and + returned the UUID, which then 403'd team-BYOK file attaches with + `Tried to access `. + """ + file_id = ( + "litellm_proxy:text/plain" + ";unified_id,file-uuid-123" + ";target_model_names,openai/gpt-4o" + ";llm_output_file_id,file-OpenAIReturnedId" + ";llm_output_file_model_id,813bf25f-e5a7-4658-8253-a6f677be8eb5" + ) + assert extract_model_id_from_unified_id(file_id) is None, ( + "File-ID has no top-level `model_id,` field — the deployment UUID " + "in `llm_output_file_model_id,` must NOT be returned. Returning it " + "feeds the UUID as a model candidate into the team-access check " + "and 403s every team-BYOK file attach (LIT-3244 patch/1.86.0)." + ) + + +def test_extract_model_id_returns_none_for_file_id_with_model_id_value_null(): + """The current file-ID builder writes `llm_output_file_model_id,None` + (the Python `None` stringified) when the upstream model_id isn't known. + Still no top-level `model_id,` field → must return None. + """ + file_id = ( + "litellm_proxy:text/plain" + ";unified_id,uuid" + ";target_model_names,openai/gpt-4o" + ";llm_output_file_id,file-Y" + ";llm_output_file_model_id,None" + ) + assert extract_model_id_from_unified_id(file_id) is None + + +# --------------------------------------------------------------------------- +# Base64-encoded inputs must decode and apply the same anchor. +# --------------------------------------------------------------------------- + + +def test_extract_model_id_decodes_base64_then_anchors(): + file_id_plain = ( + "litellm_proxy:text/plain" + ";unified_id,uuid" + ";target_model_names,openai/gpt-4o" + ";llm_output_file_id,file-Y" + ";llm_output_file_model_id,813bf25f-e5a7-4658-8253-a6f677be8eb5" + ) + encoded = encode_unified_id(file_id_plain) + assert extract_model_id_from_unified_id(encoded) is None + + vector_store_plain = ( + "litellm_proxy:vector_store" + ";unified_id,abc" + ";target_model_names,gpt-4" + ";resource_id,vs_xyz" + ";model_id,real-model-id" + ) + encoded_vs = encode_unified_id(vector_store_plain) + assert extract_model_id_from_unified_id(encoded_vs) == "real-model-id" + + +# --------------------------------------------------------------------------- +# Defensive: malformed / non-string inputs must not raise. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("bad_input", [None, 42, b"bytes-not-str", []]) +def test_extract_model_id_returns_none_for_non_string_input(bad_input): + assert extract_model_id_from_unified_id(bad_input) is None # type: ignore[arg-type] + + +def test_extract_model_id_returns_none_when_field_absent(): + assert ( + extract_model_id_from_unified_id( + "litellm_proxy:other;unified_id,abc;some_field,whatever" + ) + is None + )