diff --git a/tests/e2e/access_control/access_control_client.py b/tests/e2e/access_control/access_control_client.py index d7bc9c280aa..2ea060145b8 100644 --- a/tests/e2e/access_control/access_control_client.py +++ b/tests/e2e/access_control/access_control_client.py @@ -17,6 +17,7 @@ from models import ( MODEL_ACCESS_DENIED_MARKER = "key_model_access_denied" ROUTE_NOT_ALLOWED_MARKER = "not allowed to call this route" +ALL_TEAM_MODELS_SENTINEL = "all-team-models" @dataclass(frozen=True, slots=True) diff --git a/tests/e2e/access_control/test_access_control_e2e.py b/tests/e2e/access_control/test_access_control_e2e.py index ce649fa2400..5ef73564561 100644 --- a/tests/e2e/access_control/test_access_control_e2e.py +++ b/tests/e2e/access_control/test_access_control_e2e.py @@ -19,6 +19,7 @@ import pytest from access_control_client import ( AccessControlClient, + ALL_TEAM_MODELS_SENTINEL, MODEL_ACCESS_DENIED_MARKER, ROUTE_NOT_ALLOWED_MARKER, ) @@ -29,6 +30,7 @@ pytestmark = pytest.mark.e2e ALLOWED_MODEL = "gemini-2.5-flash" DISALLOWED_MODEL = "gpt-5.5" +PROXY_CHAT_MODELS = frozenset({"gpt-5.5", "claude-haiku-4-5", "gemini-2.5-flash"}) def _is_json(body: str) -> bool: @@ -81,3 +83,38 @@ class TestAccessControl: f"{result.status_code}: {result.body[:300]}" ) assert _is_json(result.body), f"400 body must be valid JSON: {result.body[:300]}" + + +class TestTeamlessAllTeamModels: + """A key scoped to ``all-team-models`` with no team assigned inherits the + full proxy model list, exactly as if its models field were left empty. This + is the intended contract (GH #30737): #29746 tightened it to deny such keys + and was reverted in #32032. These cases pin both surfaces a client touches, + listing (GET /v1/models) and calling (/chat/completions), so re-introducing + the team_id guard on either path fails here.""" + + def test_teamless_all_team_models_key_lists_all_proxy_models( + self, client: AccessControlClient, resources: ResourceManager + ) -> None: + key = resources.key(models=[ALL_TEAM_MODELS_SENTINEL]) + listed = set(client.gateway.list_models(key)) + assert PROXY_CHAT_MODELS <= listed, ( + f"teamless {ALL_TEAM_MODELS_SENTINEL!r} key must list every proxy model, " + f"missing {PROXY_CHAT_MODELS - listed}; got {sorted(listed)}" + ) + assert ALL_TEAM_MODELS_SENTINEL not in listed, ( + f"the {ALL_TEAM_MODELS_SENTINEL!r} sentinel must expand to real models, " + f"never surface as a listed model; got {sorted(listed)}" + ) + + def test_teamless_all_team_models_key_can_call_any_model( + self, client: AccessControlClient, resources: ResourceManager + ) -> None: + key = resources.key(models=[ALL_TEAM_MODELS_SENTINEL]) + result = client.chat_status( + key, ALLOWED_MODEL, f"capital of France? {unique_marker()}" + ) + assert result.status_code == 200, ( + f"teamless {ALL_TEAM_MODELS_SENTINEL!r} key must be allowed to call " + f"{ALLOWED_MODEL!r}, got {result.status_code}: {result.body[:300]}" + ) diff --git a/tests/e2e/e2e_gateway.py b/tests/e2e/e2e_gateway.py index ad8b2e833a8..dec82c04844 100644 --- a/tests/e2e/e2e_gateway.py +++ b/tests/e2e/e2e_gateway.py @@ -43,6 +43,7 @@ from models import ( ModelInfoBody, ModelInfoEntry, ModelInfoResponse, + ModelListResponse, ModelMode, ModelNewBody, ModelNewResponse, @@ -126,6 +127,21 @@ class Gateway: ) ).data + def list_models(self, key: str) -> list[str]: + """The public model names GET /v1/models lists as callable for `key`, + the same visibility an OpenAI-compatible client (e.g. OpenWebUI) sees.""" + return [ + entry.id + for entry in unwrap( + self.transport.get( + "/v1/models", + headers=self.transport.bearer(key), + params=NoBody(), + response_type=ModelListResponse, + ) + ).data + ] + def list_files(self, key: str) -> Result[FileListResponse]: return self.transport.get( "/v1/files", diff --git a/tests/e2e/models.py b/tests/e2e/models.py index 39832d1a17f..f96e2e96c3c 100644 --- a/tests/e2e/models.py +++ b/tests/e2e/models.py @@ -389,6 +389,14 @@ class ModelInfoResponse(BaseModel): data: list[ModelInfoEntry] = [] +class ModelListEntry(BaseModel): + id: str + + +class ModelListResponse(BaseModel): + data: list[ModelListEntry] = [] + + class FileEntry(BaseModel): id: str @@ -474,10 +482,6 @@ class ModelNewResponse(BaseModel): model_id: str -class ModelListEntry(BaseModel): - id: str - - class ModelsListResponse(BaseModel): """GET /v1/models on the data plane: the deployments the gateway can actually serve right now. Used to confirm a freshly created model has propagated from