From 987a8fcf48b0a8de787b4405290fb8f4cd3c108b Mon Sep 17 00:00:00 2001 From: mgeorgaklis Date: Wed, 29 Jul 2026 03:31:54 +0000 Subject: [PATCH 01/37] fix(gemini): do not send duplicate thoughtSignature copies to Gemini Gemini returns each thoughtSignature on exactly one part. LiteLLM stores a function-call signature both message-level (thought_signatures) and on the tool call itself, then re-attached it to BOTH the text part and the function-call part when serializing history. gemini-3 and newer models bill every replayed copy as the previous turn's full reasoning token count, so long agentic sessions doubled their context growth and hit the 1,048,576-token limit Only attach a message-level signature to the text part when the same signature is not already carried by a tool-call part: - compare signature values instead of boolean presence so a distinct text-part signature is never dropped - ignore the gemini-3 dummy-signature fallback during detection so replaying gemini-2.5 history to a newer model keeps the real text signature - count signatures carried by server-side tool invocations so they are not re-attached to the text part gemini-2.5 responses (signature on the text part, function call unsigned) are unaffected: the text signature is preserved as before --- .../llms/vertex_ai/gemini/transformation.py | 61 +++- .../test_vertex_ai_gemini_transformation.py | 316 ++++++++++++++++++ 2 files changed, 375 insertions(+), 2 deletions(-) diff --git a/litellm/llms/vertex_ai/gemini/transformation.py b/litellm/llms/vertex_ai/gemini/transformation.py index 0db1118a7b4..f465a54b265 100644 --- a/litellm/llms/vertex_ai/gemini/transformation.py +++ b/litellm/llms/vertex_ai/gemini/transformation.py @@ -20,6 +20,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import ( _get_image_mime_type_from_url, ) from litellm.litellm_core_utils.prompt_templates.factory import ( + _get_thought_signature_from_tool, convert_generic_image_chunk_to_openai_image_obj, convert_to_anthropic_image_obj, convert_to_gemini_tool_call_invoke, @@ -635,6 +636,52 @@ def check_if_part_exists_in_parts(parts: List[PartType], part: PartType, exclude return False +def _collect_tool_call_thought_signatures( + assistant_msg: ChatCompletionAssistantMessage, +) -> frozenset[str]: + """Thought signatures already carried by this message's tool-call parts. + + Gemini returns each thoughtSignature on exactly one part. When the signed + part is a function call, the signature is replayed on that tool-call part + by convert_to_gemini_tool_call_invoke, so attaching the same signature to + the text part as well would send two copies and double-bill the previous + turn's reasoning tokens on gemini-3 and newer models. + + Detection deliberately calls _get_thought_signature_from_tool without the + model argument: with a gemini-3 model that helper synthesizes a dummy + signature for unsigned tool calls, which must not suppress a real + text-part signature (e.g. replaying gemini-2.5 history to a newer model). + """ + signatures: tuple[str, ...] = () + + tool_calls = assistant_msg.get("tool_calls") + if isinstance(tool_calls, list): + for tool in tool_calls: + if isinstance(tool, dict): + signature = _get_thought_signature_from_tool(tool) + if signature: + signatures += (signature,) + + function_call = assistant_msg.get("function_call") + if isinstance(function_call, dict): + signature = _get_thought_signature_from_tool({"function": function_call}) + if signature: + signatures += (signature,) + + provider_specific_fields = assistant_msg.get("provider_specific_fields") + if isinstance(provider_specific_fields, dict): + invocations = provider_specific_fields.get("server_side_tool_invocations") + if isinstance(invocations, list): + for invocation in invocations: + if isinstance(invocation, dict): + for key in ("thought_signature", "response_thought_signature"): + invocation_signature = invocation.get(key) + if isinstance(invocation_signature, str) and invocation_signature: + signatures += (invocation_signature,) + + return frozenset(signatures) + + def _gemini_convert_messages_with_history( messages: List[AllMessageValues], model: Optional[str] = None, @@ -864,8 +911,18 @@ def _gemini_convert_messages_with_history( if provider_specific_fields and isinstance(provider_specific_fields, dict): thought_signatures = provider_specific_fields.get("thought_signatures") - # If we have thought signatures, add them to the part - if thought_signatures and isinstance(thought_signatures, list) and len(thought_signatures) > 0: + # A signature that is already carried by one of this message's + # tool-call parts must not be attached to the text part too: + # Gemini bills every replayed copy as the previous turn's full + # reasoning token count on gemini-3 and newer models + tool_call_signatures = _collect_tool_call_thought_signatures(assistant_msg) + + if ( + thought_signatures + and isinstance(thought_signatures, list) + and len(thought_signatures) > 0 + and thought_signatures[0] not in tool_call_signatures + ): # Use the first signature for the text part (Gemini expects one signature per part) assistant_content.append( PartType( diff --git a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py index d99c190c6e5..8ee8186f6bb 100644 --- a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py +++ b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py @@ -2097,3 +2097,319 @@ def test_multi_turn_function_calling_roles(): assert ( content["role"] == "user" ), f"Content block {i} with function_response has role='{content['role']}', expected 'user'" + + +def test_gemini_thought_signature_preservation_real_response(): + """Test that thought signatures are preserved on the text part if originally there, without dropping or duplicating (real response case).""" + from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( + VertexGeminiConfig, + ) + from litellm.llms.vertex_ai.gemini.transformation import ( + _gemini_convert_messages_with_history, + ) + + real_candidate = { + "content": { + "parts": [ + { + "text": "I will explain and then list files.", + "thoughtSignature": "mock_signature_from_text_part", + }, + { + "functionCall": { + "name": "list_files", + "args": {}, + } + }, + ] + } + } + + parts = real_candidate["content"]["parts"] + + content, reasoning_content = ( + VertexGeminiConfig().get_assistant_content_message(parts=parts) + ) + thought_signatures = ( + VertexGeminiConfig()._extract_thought_signatures_from_parts( + parts=parts + ) + ) + functions, tools, _ = VertexGeminiConfig._transform_parts( + parts=parts, + cumulative_tool_call_idx=0, + is_function_call=False, + ) + + msg: dict = {"role": "assistant"} + if content is not None: + msg["content"] = content + if tools: + msg["tool_calls"] = tools + if functions is not None: + msg["function_call"] = functions + if thought_signatures is not None: + msg["provider_specific_fields"] = { + "thought_signatures": thought_signatures + } + + converted_real = _gemini_convert_messages_with_history( + messages=[msg], + model="gemini-2.5-pro", + ) + + assert len(converted_real) == 1 + assert "parts" in converted_real[0] + parts_out = converted_real[0]["parts"] + assert len(parts_out) == 2 + assert "text" in parts_out[0] + assert ( + parts_out[0]["thoughtSignature"] == "mock_signature_from_text_part" + ) + assert "function_call" in parts_out[1] + assert "thoughtSignature" not in parts_out[1] + + +def test_gemini_thought_signature_deduplication_assumed_response(): + """Test that thought signatures are deduplicated and not attached to the text part if already present in the tool call (assumed response case).""" + from litellm.llms.vertex_ai.gemini.transformation import ( + _gemini_convert_messages_with_history, + ) + + pr_assumed_msg = { + "role": "assistant", + "content": "I will list the directory.", + "provider_specific_fields": { + "thought_signatures": ["mock_signature_63k"] + }, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "list_files", "arguments": "{}"}, + "provider_specific_fields": { + "thought_signature": "mock_signature_63k" + }, + } + ], + } + + converted_pr = _gemini_convert_messages_with_history( + messages=[pr_assumed_msg], + model="gemini-2.5-pro", + ) + + assert len(converted_pr) == 1 + assert "parts" in converted_pr[0] + parts_out = converted_pr[0]["parts"] + assert len(parts_out) == 2 + assert "text" in parts_out[0] + assert "thoughtSignature" not in parts_out[0] + assert "function_call" in parts_out[1] + assert parts_out[1]["thoughtSignature"] == "mock_signature_63k" + + +def test_gemini_thought_signature_pure_text(): + """Test that thought signatures are preserved on the text part for responses with no tool calls.""" + from litellm.llms.vertex_ai.gemini.transformation import ( + _gemini_convert_messages_with_history, + ) + + msg = { + "role": "assistant", + "content": "Hello, I am a model.", + "provider_specific_fields": { + "thought_signatures": ["pure_text_signature"] + }, + } + + converted = _gemini_convert_messages_with_history( + messages=[msg], + model="gemini-2.5-pro", + ) + + assert len(converted) == 1 + assert "parts" in converted[0] + parts_out = converted[0]["parts"] + assert len(parts_out) == 1 + assert "text" in parts_out[0] + assert parts_out[0]["thoughtSignature"] == "pure_text_signature" + + +def test_gemini_thought_signature_pure_tool_call(): + """Test that thought signatures are preserved on the tool call for responses with no intermediate text.""" + from litellm.llms.vertex_ai.gemini.transformation import ( + _gemini_convert_messages_with_history, + ) + + msg = { + "role": "assistant", + "content": None, + "provider_specific_fields": { + "thought_signatures": ["pure_tool_signature"] + }, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "list_files", "arguments": "{}"}, + "provider_specific_fields": { + "thought_signature": "pure_tool_signature" + }, + } + ], + } + + converted = _gemini_convert_messages_with_history( + messages=[msg], + model="gemini-2.5-pro", + ) + + assert len(converted) == 1 + assert "parts" in converted[0] + parts_out = converted[0]["parts"] + assert len(parts_out) == 1 + assert "function_call" in parts_out[0] + assert parts_out[0]["thoughtSignature"] == "pure_tool_signature" + + +def test_gemini_distinct_text_and_tool_signatures_are_both_preserved(): + """A text-part signature that differs from the tool-call signature must stay on the text part.""" + from litellm.llms.vertex_ai.gemini.transformation import ( + _gemini_convert_messages_with_history, + ) + + msg = { + "role": "assistant", + "content": "Some analysis.", + "provider_specific_fields": { + "thought_signatures": ["text_signature", "tool_signature"] + }, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "list_files", "arguments": "{}"}, + "provider_specific_fields": {"thought_signature": "tool_signature"}, + } + ], + } + + parts = _gemini_convert_messages_with_history( + messages=[msg], model="gemini-2.5-pro" + )[0]["parts"] + + assert parts[0]["text"] == "Some analysis." + assert parts[0]["thoughtSignature"] == "text_signature" + assert "function_call" in parts[1] + assert parts[1]["thoughtSignature"] == "tool_signature" + + +def test_gemini_25_text_signature_survives_replay_to_gemini_3(): + """gemini-2.5 history (signed text, unsigned tool call) replayed to gemini-3 keeps the real + text signature; the dummy signature synthesized for the unsigned tool call must not suppress it.""" + from litellm.litellm_core_utils.prompt_templates.factory import ( + _get_dummy_thought_signature, + ) + from litellm.llms.vertex_ai.gemini.transformation import ( + _gemini_convert_messages_with_history, + ) + + msg = { + "role": "assistant", + "content": "I will list the directory.", + "provider_specific_fields": {"thought_signatures": ["real_25_signature"]}, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "list_files", "arguments": "{}"}, + } + ], + } + + parts = _gemini_convert_messages_with_history(messages=[msg], model="gemini-3-pro")[ + 0 + ]["parts"] + + assert parts[0]["text"] == "I will list the directory." + assert parts[0]["thoughtSignature"] == "real_25_signature" + assert "function_call" in parts[1] + assert parts[1]["thoughtSignature"] == _get_dummy_thought_signature() + + +def test_gemini_function_call_signature_round_trip_no_duplicate(): + """End to end: a gemini-3-style response (unsigned text + signed functionCall) parsed and + re-serialized sends the signature exactly once, on the function-call part.""" + from litellm.llms.vertex_ai.gemini.transformation import ( + _gemini_convert_messages_with_history, + ) + from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( + VertexGeminiConfig, + ) + + response_parts = [ + {"text": "I will calculate the result for you."}, + { + "functionCall": {"name": "add_numbers", "args": {"a": 17, "b": 25}}, + "thoughtSignature": "signature_from_function_call", + }, + ] + + config = VertexGeminiConfig() + content, _ = config.get_assistant_content_message(parts=response_parts) + thought_signatures = config._extract_thought_signatures_from_parts( + parts=response_parts + ) + _, tools, _ = VertexGeminiConfig._transform_parts( + parts=response_parts, cumulative_tool_call_idx=0, is_function_call=False + ) + + msg = { + "role": "assistant", + "content": content, + "tool_calls": tools, + "provider_specific_fields": {"thought_signatures": thought_signatures}, + } + + parts = _gemini_convert_messages_with_history(messages=[msg], model="gemini-3-pro")[ + 0 + ]["parts"] + + signatures = [p["thoughtSignature"] for p in parts if "thoughtSignature" in p] + assert signatures == ["signature_from_function_call"] + assert "thoughtSignature" not in parts[0] + assert "function_call" in parts[1] + + +def test_gemini_server_side_tool_signature_not_duplicated_on_text(): + """A signature already re-injected on a server-side toolCall part is not attached to the text part again.""" + from litellm.llms.vertex_ai.gemini.transformation import ( + _gemini_convert_messages_with_history, + ) + + msg = { + "role": "assistant", + "content": "The weather in Buenos Aires is sunny.", + "provider_specific_fields": { + "thought_signatures": ["server_side_signature"], + "server_side_tool_invocations": [ + { + "tool_type": "GOOGLE_SEARCH_WEB", + "id": "abc123", + "args": {"queries": ["weather Buenos Aires"]}, + "response": {"weather": "Sunny"}, + "thought_signature": "server_side_signature", + } + ], + }, + } + + parts = _gemini_convert_messages_with_history( + messages=[msg], model="gemini-2.5-pro" + )[0]["parts"] + + text_part = next(p for p in parts if "text" in p) + assert "thoughtSignature" not in text_part + tool_call_part = next(p for p in parts if "toolCall" in p) + assert tool_call_part["thoughtSignature"] == "server_side_signature" From 35592323e24a3740ed2531a26d71145903ac9f2d Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 30 Jul 2026 16:12:08 -0700 Subject: [PATCH 02/37] fix(tag-management): drop unsupported prisma select kwarg from key lookup /tag/list returned HTTP 500 for every internal user with "LiteLLM_VerificationTokenActions.find_many() got an unexpected keyword argument 'select'". The non-admin branch scopes the tag list to keys owned by the caller, and that lookup passed select={"token": True}; prisma-client-py 0.11.0 has no select kwarg on find_many, so the call raised TypeError and the handler's except block turned it into a 500. Since the Admin UI calls /tag/list on load, Tags was broken for every non-admin user. /tag/daily/activity shares the same helper and was failing the same way The kwarg is dropped rather than replaced; the generated client has no projection API, and a user's key set is small enough that selecting all columns is not worth working around The reason this shipped green is that the existing test asserted the call was made with select={"token": True} against an AsyncMock, which accepts any keyword. The verification-token table double now binds each call against the real find_many signature, so an unsupported kwarg raises the same TypeError production does --- .../tag_management_endpoints.py | 2 - .../test_tag_management_endpoints.py | 109 +++++++++++++++--- 2 files changed, 91 insertions(+), 20 deletions(-) diff --git a/litellm/proxy/management_endpoints/tag_management_endpoints.py b/litellm/proxy/management_endpoints/tag_management_endpoints.py index 47a8670e26f..ac53f254981 100644 --- a/litellm/proxy/management_endpoints/tag_management_endpoints.py +++ b/litellm/proxy/management_endpoints/tag_management_endpoints.py @@ -96,7 +96,6 @@ class _VerificationTokenTableClient(Protocol): async def find_many( self, where: Mapping[str, object] | None = None, - select: Mapping[str, object] | None = None, ) -> "Sequence[PrismaVerificationToken]": ... @@ -157,7 +156,6 @@ async def _get_internal_user_api_keys( key_records = await _table(VerificationTokenRepository(prisma_client)).find_many( where={"user_id": user_id}, - select={"token": True}, ) user_api_keys.update(key_record.token for key_record in key_records if getattr(key_record, "token", None)) diff --git a/tests/test_litellm/proxy/management_endpoints/test_tag_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_tag_management_endpoints.py index 76ba0e3dc67..9927c56b847 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_tag_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_tag_management_endpoints.py @@ -1,11 +1,13 @@ +import inspect import json import os import sys -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional import pytest from fastapi import HTTPException from fastapi.testclient import TestClient +from prisma.actions import LiteLLM_VerificationTokenActions sys.path.insert( 0, os.path.abspath("../../../..") @@ -21,6 +23,28 @@ from litellm.types.tag_management import TagDeleteRequest, TagInfoRequest, TagNe client = TestClient(app) +class FakeVerificationTokenTable: + """Stand-in for ``prisma_client.db.litellm_verificationtoken``. + + ``AsyncMock`` swallows any keyword argument, so a plain mock cannot catch a + call that the generated prisma client would reject at runtime. This double + binds every call against the real ``find_many`` signature, so passing an + unsupported kwarg (e.g. ``select``) raises the same ``TypeError`` the proxy + surfaces as an HTTP 500. + """ + + def __init__(self, records: List[Any]): + self._records = records + self.calls: List[Dict[str, Any]] = [] + + async def find_many(self, **kwargs: Any) -> List[Any]: + inspect.signature(LiteLLM_VerificationTokenActions.find_many).bind( + self, **kwargs + ) + self.calls.append(kwargs) + return self._records + + @pytest.mark.asyncio async def test_create_and_get_tag(): """ @@ -380,6 +404,7 @@ async def test_list_tags_no_dynamic_tags(): app.dependency_overrides.clear() +@pytest.mark.asyncio async def test_internal_user_list_tags_only_returns_tags_used_by_their_keys(): """ Internal users can view tag usage, but the tag list must be scoped to tags @@ -404,9 +429,8 @@ async def test_internal_user_list_tags_only_returns_tags_used_by_their_keys(): owned_key_record = Mock() owned_key_record.token = "owned-key" - mock_db.litellm_verificationtoken.find_many = AsyncMock( - return_value=[owned_key_record] - ) + fake_token_table = FakeVerificationTokenTable([owned_key_record]) + mock_db.litellm_verificationtoken = fake_token_table mock_db.litellm_dailytagspend.group_by = AsyncMock( return_value=[ @@ -446,10 +470,9 @@ async def test_internal_user_list_tags_only_returns_tags_used_by_their_keys(): "stored-owned-tag", "dynamic-owned-tag", ] - mock_db.litellm_verificationtoken.find_many.assert_awaited_once_with( - where={"user_id": "internal-user-123"}, - select={"token": True}, - ) + assert fake_token_table.calls == [ + {"where": {"user_id": "internal-user-123"}} + ] mock_db.litellm_dailytagspend.group_by.assert_awaited_once_with( by=["tag"], where={ @@ -468,6 +491,54 @@ async def test_internal_user_list_tags_only_returns_tags_used_by_their_keys(): app.dependency_overrides.clear() +@pytest.mark.asyncio +async def test_internal_user_list_tags_does_not_500_on_unsupported_prisma_kwarg(): + """ + Regression: /tag/list returned 500 for every internal user because the + non-admin branch looked up the caller's keys with + ``find_many(select={"token": True})``, and the generated prisma client has no + ``select`` kwarg. This reproduces the reported case exactly: a freshly created + internal user with no tag spend yet, which must get an empty 200 rather than + "LiteLLM_VerificationTokenActions.find_many() got an unexpected keyword + argument 'select'". + """ + from unittest.mock import AsyncMock, Mock + + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + + mock_user_auth = UserAPIKeyAuth( + api_key="new-user-key", + user_id="brand-new-internal-user", + user_role=LitellmUserRoles.INTERNAL_USER, + ) + app.dependency_overrides[user_api_key_auth] = lambda: mock_user_auth + + try: + with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma: + mock_db = Mock() + mock_prisma.db = mock_db + + key_record = Mock() + key_record.token = "new-user-key" + fake_token_table = FakeVerificationTokenTable([key_record]) + mock_db.litellm_verificationtoken = fake_token_table + + mock_db.litellm_dailytagspend.group_by = AsyncMock(return_value=[]) + mock_db.litellm_tagtable.find_many = AsyncMock(return_value=[]) + + response = client.get( + "/tag/list", headers={"Authorization": "Bearer new-user-key"} + ) + + assert response.status_code == 200, response.text + assert response.json() == [] + assert fake_token_table.calls == [ + {"where": {"user_id": "brand-new-internal-user"}} + ] + finally: + app.dependency_overrides.clear() + + @pytest.mark.asyncio async def test_list_tags_with_date_range_filters_dynamic_tags(): """ @@ -537,9 +608,8 @@ async def test_internal_user_tag_daily_activity_is_scoped_to_their_keys(): owned_key_record = Mock() owned_key_record.token = "owned-key" - mock_db.litellm_verificationtoken.find_many = AsyncMock( - return_value=[owned_key_record] - ) + fake_token_table = FakeVerificationTokenTable([owned_key_record]) + mock_db.litellm_verificationtoken = fake_token_table mock_get_daily_activity.return_value = "daily-activity-response" result = await get_tag_daily_activity( @@ -549,6 +619,7 @@ async def test_internal_user_tag_daily_activity_is_scoped_to_their_keys(): ) assert result == "daily-activity-response" + assert fake_token_table.calls == [{"where": {"user_id": "internal-user-123"}}] mock_get_daily_activity.assert_awaited_once() assert mock_get_daily_activity.await_args.kwargs["api_key"] == ["owned-key"] @@ -583,9 +654,8 @@ async def test_internal_user_tag_daily_activity_rejects_unowned_api_key_filter() owned_key_record = Mock() owned_key_record.token = "owned-key" - mock_db.litellm_verificationtoken.find_many = AsyncMock( - return_value=[owned_key_record] - ) + fake_token_table = FakeVerificationTokenTable([owned_key_record]) + mock_db.litellm_verificationtoken = fake_token_table result = await get_tag_daily_activity( start_date="2025-01-01", end_date="2025-01-31", @@ -593,6 +663,7 @@ async def test_internal_user_tag_daily_activity_rejects_unowned_api_key_filter() user_api_key_dict=mock_user_auth, ) + assert fake_token_table.calls == [{"where": {"user_id": "internal-user-123"}}] assert result.results == [] assert result.metadata.total_spend == 0 assert result.metadata.total_api_requests == 0 @@ -626,7 +697,8 @@ async def test_internal_user_tag_daily_activity_scopes_to_current_key_without_us ): mock_db = Mock() mock_prisma.db = mock_db - mock_db.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) + fake_token_table = FakeVerificationTokenTable([]) + mock_db.litellm_verificationtoken = fake_token_table mock_get_daily_activity.return_value = "daily-activity-response" result = await get_tag_daily_activity( @@ -636,7 +708,7 @@ async def test_internal_user_tag_daily_activity_scopes_to_current_key_without_us ) assert result == "daily-activity-response" - mock_db.litellm_verificationtoken.find_many.assert_not_awaited() + assert fake_token_table.calls == [] mock_get_daily_activity.assert_awaited_once() assert mock_get_daily_activity.await_args.kwargs["api_key"] == [ "current-owned-key" @@ -669,7 +741,8 @@ async def test_internal_user_tag_daily_activity_without_any_scoped_keys_returns_ ): mock_db = Mock() mock_prisma.db = mock_db - mock_db.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) + fake_token_table = FakeVerificationTokenTable([]) + mock_db.litellm_verificationtoken = fake_token_table result = await get_tag_daily_activity( start_date="2025-01-01", @@ -680,7 +753,7 @@ async def test_internal_user_tag_daily_activity_without_any_scoped_keys_returns_ assert result.results == [] assert result.metadata.total_spend == 0 assert result.metadata.total_api_requests == 0 - mock_db.litellm_verificationtoken.find_many.assert_not_awaited() + assert fake_token_table.calls == [] mock_get_daily_activity.assert_not_awaited() From 1d40f2a707a96be7b4d91587bb7ba0e834cd805d Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 30 Jul 2026 19:22:22 -0700 Subject: [PATCH 03/37] feat(ui): add sorting, filtering and search to the budgets page Move the budgets table onto the paged management list route so sorting, filtering and search happen server-side instead of over whichever rows happened to be in memory. Adds useResourceList, a generic hook that owns page, page_size, sort, q and filters for a server-driven table, folds them into one JSON:API query and returns exactly the props DataTable's server modes want. Budgets is its first consumer. The budget id column now renders in full with a copy button instead of a fixed-width cell, and the table gains Reset and Created columns. --- ui/litellm-dashboard/eslint-suppressions.json | 5 - .../budgets/_components/BudgetTable.test.tsx | 160 +++++-- .../budgets/_components/BudgetTable.tsx | 246 ++++++++++- .../_components/BudgetTableColumns.tsx | 62 ++- .../budgets/_components/budget_panel.test.tsx | 413 ++++++++++-------- .../budgets/_components/budget_panel.tsx | 31 +- .../hooks/budgets/budgetFilters.test.ts | 59 +++ .../hooks/budgets/budgetFilters.ts | 91 ++++ .../(dashboard)/hooks/budgets/useBudgets.ts | 52 ++- .../hooks/common/useResourceList.test.tsx | 161 +++++++ .../hooks/common/useResourceList.ts | 142 ++++++ 11 files changed, 1144 insertions(+), 278 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/budgets/budgetFilters.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/budgets/budgetFilters.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/common/useResourceList.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/common/useResourceList.ts diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 6819b2851f5..229d6c797c2 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -126,11 +126,6 @@ "count": 2 } }, - "src/app/(dashboard)/budgets/_components/budget_panel.test.tsx": { - "unused-imports/no-unused-imports": { - "count": 2 - } - }, "src/app/(dashboard)/budgets/_components/budget_panel.tsx": { "local/filename-pascal-case": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTable.test.tsx index 2b97bcbc072..0c485adf4f2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTable.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTable.test.tsx @@ -1,22 +1,58 @@ -import { screen, within } from "@testing-library/react"; +import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { renderWithProviders } from "@/../tests/test-utils"; +import { renderWithProviders, testQueryClient } from "@/../tests/test-utils"; import BudgetTable from "./BudgetTable"; -import { budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets"; +import type { budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets"; +import type { ResourceListResult } from "@/app/(dashboard)/hooks/common/useResourceList"; +import { ApiError } from "@/lib/http/client"; + +const { copyToClipboardMock } = vi.hoisted(() => ({ copyToClipboardMock: vi.fn() })); + +vi.mock("@/utils/dataUtils", async (importOriginal) => ({ + ...(await importOriginal()), + copyToClipboard: copyToClipboardMock, +})); const makeBudget = (overrides: Partial = {}): budgetItem => ({ budget_id: "budget-1", max_budget: 100, + soft_budget: null, tpm_limit: 1000, rpm_limit: 10, + budget_duration: "30d", + budget_reset_at: null, + created_at: "2024-01-01T00:00:00Z", updated_at: "2024-01-01T00:00:00Z", ...overrides, }); -const defaultProps = { - budgets: [makeBudget()], +const makeList = (overrides: Partial> = {}): ResourceListResult => ({ + rows: [makeBudget()], + rowCount: 1, isLoading: false, + isFetching: false, + error: null, + refetch: vi.fn(), + sorting: [{ id: "created_at", desc: true }], + onSortingChange: vi.fn(), + pagination: { pageIndex: 0, pageSize: 50 }, + onPaginationChange: vi.fn(), + columnFilters: [], + onColumnFiltersChange: vi.fn(), + searchValue: "", + onSearchChange: vi.fn(), + ...overrides, +}); + +const FORBIDDEN_PROBLEM = { + type: "about:blank", + title: "Forbidden", + status: 403, + detail: "Only proxy admins can view budgets", +}; + +const defaultProps = { canModify: true, onEditClick: vi.fn(), onDeleteClick: vi.fn(), @@ -25,72 +61,134 @@ const defaultProps = { describe("BudgetTable", () => { beforeEach(() => { vi.clearAllMocks(); + testQueryClient.clear(); }); it("should display budget information", () => { - renderWithProviders(); + renderWithProviders(); expect(screen.getByText("budget-1")).toBeInTheDocument(); expect(screen.getByText("$100.00")).toBeInTheDocument(); expect(screen.getByText("1000")).toBeInTheDocument(); expect(screen.getByText("10")).toBeInTheDocument(); }); - it("should render the budget id without a fixed character-count clamp", () => { + it("should render the reset column with the friendly duration label", () => { + renderWithProviders(); + expect(screen.getByText("monthly")).toBeInTheDocument(); + }); + + it("should render 'Not set' when a budget has no reset duration", () => { + const list = makeList({ rows: [makeBudget({ budget_duration: null })] }); + renderWithProviders(); + expect(screen.getByText("Not set")).toBeInTheDocument(); + }); + + it("should render the budget id in full, with no truncation", () => { const budgetId = "ecc1869c-6231-4380-a56d-1a0be457477d"; - renderWithProviders(); + const list = makeList({ rows: [makeBudget({ budget_id: budgetId })] }); + renderWithProviders(); const idCell = screen.getByText(budgetId); + expect(idCell.className).not.toContain("truncate"); expect(idCell.className).not.toMatch(/max-w-\[\d+(ch|rem|px)\]/); - expect(idCell.className).toContain("max-w-full"); - expect(idCell.className).toContain("truncate"); + }); + + it("should keep the budget id on a single line", () => { + const budgetId = "ecc1869c-6231-4380-a56d-1a0be457477d"; + const list = makeList({ rows: [makeBudget({ budget_id: budgetId })] }); + renderWithProviders(); + expect(screen.getByText(budgetId).className).toContain("whitespace-nowrap"); + }); + + it("should copy the budget id from the cell's copy button", async () => { + const user = userEvent.setup(); + const budgetId = "ecc1869c-6231-4380-a56d-1a0be457477d"; + const list = makeList({ rows: [makeBudget({ budget_id: budgetId })] }); + renderWithProviders(); + await user.click(screen.getByRole("button", { name: "Copy ID" })); + expect(copyToClipboardMock).toHaveBeenCalledWith(budgetId); + }); + + it("should offer sorting on every backend-sortable column", async () => { + renderWithProviders(); + for (const field of ["budget_id", "max_budget", "tpm_limit", "rpm_limit", "created_at"]) { + expect(screen.getByTestId(`sort-header-${field}`)).toBeInTheDocument(); + } + }); + + it("should not make the reset column sortable", () => { + renderWithProviders(); + expect(screen.queryByTestId("sort-header-budget_duration")).not.toBeInTheDocument(); + expect(screen.getByText("Reset")).toBeInTheDocument(); + }); + + it("should ask the list for a new sort when a sortable header is clicked", async () => { + const user = userEvent.setup(); + const onSortingChange = vi.fn(); + renderWithProviders(); + await user.click(screen.getByTestId("sort-header-max_budget")); + expect(onSortingChange).toHaveBeenCalled(); }); it("should show n/a for missing rate limits and Unlimited for a missing max budget", () => { - renderWithProviders( - , - ); + const list = makeList({ rows: [makeBudget({ max_budget: null, tpm_limit: null, rpm_limit: null })] }); + renderWithProviders(); expect(screen.getAllByText("n/a")).toHaveLength(2); expect(screen.getByText("Unlimited")).toBeInTheDocument(); }); - it("should sort budgets by updated_at descending", () => { - const budgets = [ - makeBudget({ budget_id: "budget-old", updated_at: "2024-01-01T00:00:00Z" }), - makeBudget({ budget_id: "budget-new", updated_at: "2024-06-01T00:00:00Z" }), - ]; - renderWithProviders(); - const rows = screen.getAllByRole("row").slice(1); - expect(within(rows[0]).getByText("budget-new")).toBeInTheDocument(); - expect(within(rows[1]).getByText("budget-old")).toBeInTheDocument(); - }); - it("should call onEditClick from the actions menu", async () => { const user = userEvent.setup(); - renderWithProviders(); + const list = makeList(); + renderWithProviders(); await user.click(screen.getByTestId("budget-actions-budget-1")); await user.click(await screen.findByTestId("budget-action-edit")); - expect(defaultProps.onEditClick).toHaveBeenCalledWith(defaultProps.budgets[0]); + expect(defaultProps.onEditClick).toHaveBeenCalledWith(list.rows[0]); }); it("should call onDeleteClick from the actions menu", async () => { const user = userEvent.setup(); - renderWithProviders(); + const list = makeList(); + renderWithProviders(); await user.click(screen.getByTestId("budget-actions-budget-1")); await user.click(await screen.findByTestId("budget-action-delete")); - expect(defaultProps.onDeleteClick).toHaveBeenCalledWith(defaultProps.budgets[0]); + expect(defaultProps.onDeleteClick).toHaveBeenCalledWith(list.rows[0]); }); it("should not render the actions menu when the user cannot modify budgets", () => { - renderWithProviders(); + renderWithProviders(); expect(screen.queryByTestId("budget-actions-budget-1")).not.toBeInTheDocument(); }); it("should show skeleton rows when loading", () => { - renderWithProviders(); + renderWithProviders(); expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0); }); it("should show the empty state when there are no budgets", () => { - renderWithProviders(); + renderWithProviders(); expect(screen.getByText("No budgets yet")).toBeInTheDocument(); }); + + it("should tell the user their search matched nothing rather than that no budgets exist", () => { + const list = makeList({ rows: [], rowCount: 0, searchValue: "nope" }); + renderWithProviders(); + expect(screen.getByText("No matching budgets")).toBeInTheDocument(); + }); + + it("should render an access-denied state for a 403 instead of an empty table", () => { + const error = new ApiError("Only proxy admins can view budgets", 403, FORBIDDEN_PROBLEM); + const list = makeList({ rows: [], rowCount: 0, error }); + const { container } = renderWithProviders(); + expect(screen.getByText("You do not have access to budgets")).toBeInTheDocument(); + expect(screen.queryByText("No budgets yet")).not.toBeInTheDocument(); + expect(container.querySelector(".lucide-shield-alert")).not.toBeNull(); + }); + + it("should surface the problem detail for a non-403 failure", () => { + const error = new ApiError("budget store unavailable", 500, null); + const list = makeList({ rows: [], rowCount: 0, error }); + renderWithProviders(); + expect(screen.getByText("Could not load budgets")).toBeInTheDocument(); + expect(screen.getByText("budget store unavailable")).toBeInTheDocument(); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTable.tsx index 4bc06425f80..76355c874f8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTable.tsx @@ -1,55 +1,269 @@ "use client"; -import { Inbox } from "lucide-react"; -import React, { useMemo } from "react"; +import { Inbox, ShieldAlert } from "lucide-react"; +import React, { useMemo, useState } from "react"; -import { DataTable } from "@/components/shared/DataTable"; -import { budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets"; +import { + BUDGET_DURATION_FILTER_OPTIONS, + BUDGET_DURATION_UNSET, + type CreatedAtFilterValue, + type MaxBudgetFilterValue, +} from "@/app/(dashboard)/hooks/budgets/budgetFilters"; +import type { budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets"; +import type { ResourceListResult } from "@/app/(dashboard)/hooks/common/useResourceList"; +import { + DataTable, + DataTableFilterDrawer, + DataTableFilterField, + DataTableToolbar, + type FilterDraft, +} from "@/components/shared/DataTable"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { ApiError } from "@/lib/http/client"; import { getBudgetTableColumns } from "./BudgetTableColumns"; interface BudgetTableProps { - budgets: budgetItem[]; - isLoading: boolean; + list: ResourceListResult; canModify: boolean; onEditClick: (budget: budgetItem) => void; onDeleteClick: (budget: budgetItem) => void; } -function EmptyState() { +const PAGE_SIZE_OPTIONS = [25, 50, 100]; + +const FILTER_LABELS: Record = { + budget_duration: "Reset", + max_budget: "Max Budget", + created_at: "Created", +}; + +const durationLabel = (value: string): string => + BUDGET_DURATION_FILTER_OPTIONS.find((option) => option.value === value)?.label ?? value; + +const formatFilterValue = (columnId: string, value: unknown): string => { + if (columnId === "budget_duration") { + return (Array.isArray(value) ? value : []).map((entry) => durationLabel(String(entry))).join(", "); + } + if (columnId === "max_budget") { + const { min, max, unlimitedOnly } = (value ?? {}) as MaxBudgetFilterValue; + return unlimitedOnly === true ? "Unlimited only" : `${min ? `$${min}` : "any"} to ${max ? `$${max}` : "any"}`; + } + if (columnId === "created_at") { + const { from, to } = (value ?? {}) as CreatedAtFilterValue; + return `${from || "any"} to ${to || "any"}`; + } + return String(value); +}; + +/** The drawer keeps any non-empty object as an active filter, so collapse a blank draft to nothing. */ +const normalizeMaxBudget = (draft: MaxBudgetFilterValue): MaxBudgetFilterValue | undefined => { + if (draft.unlimitedOnly === true) { + return { unlimitedOnly: true }; + } + const min = draft.min?.trim() ?? ""; + const max = draft.max?.trim() ?? ""; + if (min === "" && max === "") { + return undefined; + } + return { ...(min === "" ? {} : { min }), ...(max === "" ? {} : { max }) }; +}; + +const normalizeCreatedAt = (draft: CreatedAtFilterValue): CreatedAtFilterValue | undefined => { + const from = draft.from ?? ""; + const to = draft.to ?? ""; + if (from === "" && to === "") { + return undefined; + } + return { ...(from === "" ? {} : { from }), ...(to === "" ? {} : { to }) }; +}; + +function EmptyState({ hasQuery }: { hasQuery: boolean }) { return (
-
No budgets yet
+
{hasQuery ? "No matching budgets" : "No budgets yet"}
- Create a budget to set spend, TPM and RPM limits for customers. + {hasQuery + ? "No budget matches your search or filters." + : "Create a budget to set spend, TPM and RPM limits for customers."}
); } -const BudgetTable: React.FC = ({ budgets, isLoading, canModify, onEditClick, onDeleteClick }) => { - const rows = useMemo( - () => [...budgets].sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()), - [budgets], +function ErrorState({ error }: { error: Error }) { + const forbidden = error instanceof ApiError && error.status === 403; + return ( +
+
+ +
+
+ {forbidden ? "You do not have access to budgets" : "Could not load budgets"} +
+
+ {forbidden ? "Ask a proxy admin to grant you the admin viewer role." : error.message} +
+
); +} + +/** "Not set" and the concrete durations are exclusive; see serializeBudgetFilters for why. */ +function DurationFilter({ selected, onChange }: { selected: string[]; onChange: (selected: string[]) => void }) { + const toggle = (value: string, checked: boolean): void => { + if (!checked) { + onChange(selected.filter((entry) => entry !== value)); + return; + } + const kept = value === BUDGET_DURATION_UNSET ? [] : selected.filter((entry) => entry !== BUDGET_DURATION_UNSET); + onChange([...kept, value]); + }; + + return ( +
+ {BUDGET_DURATION_FILTER_OPTIONS.map((option) => ( + + ))} +
+ ); +} + +function BudgetFilterFields({ get, set }: FilterDraft) { + const maxBudget = (get("max_budget") as MaxBudgetFilterValue | undefined) ?? {}; + const created = (get("created_at") as CreatedAtFilterValue | undefined) ?? {}; + const unlimitedOnly = maxBudget.unlimitedOnly === true; + + return ( + <> + + set("budget_duration", selected)} + /> + + +
+ set("max_budget", normalizeMaxBudget({ ...maxBudget, min: event.target.value }))} + placeholder="Min" + aria-label="Minimum max budget" + data-testid="budget-filter-max-budget-min" + /> + set("max_budget", normalizeMaxBudget({ ...maxBudget, max: event.target.value }))} + placeholder="Max" + aria-label="Maximum max budget" + data-testid="budget-filter-max-budget-max" + /> +
+ +
+ +
+ set("created_at", normalizeCreatedAt({ ...created, from: event.target.value }))} + aria-label="Created from" + data-testid="budget-filter-created-from" + /> + set("created_at", normalizeCreatedAt({ ...created, to: event.target.value }))} + aria-label="Created to" + data-testid="budget-filter-created-to" + /> +
+
+ + ); +} + +const BudgetTable: React.FC = ({ list, canModify, onEditClick, onDeleteClick }) => { + const [filtersOpen, setFiltersOpen] = useState(false); const columns = useMemo( () => getBudgetTableColumns({ canModify, onEditClick, onDeleteClick }), [canModify, onEditClick, onDeleteClick], ); + const hasQuery = list.searchValue.trim() !== "" || list.columnFilters.length > 0; + const emptyMessage = list.error === null ? : ; + return ( budget.budget_id || String(index)} - isLoading={isLoading} + sortingMode="server" + sorting={list.sorting} + onSortingChange={list.onSortingChange} + paginationMode="server" + pagination={list.pagination} + onPaginationChange={list.onPaginationChange} + rowCount={list.rowCount} + pageSizeOptions={PAGE_SIZE_OPTIONS} + filterMode="server" + columnFilters={list.columnFilters} + onColumnFiltersChange={list.onColumnFiltersChange} + isLoading={list.isLoading} loadingMessage="Loading budgets…" - noDataMessage={} + noDataMessage={emptyMessage} size="compact" + toolbar={(table) => ( + <> + setFiltersOpen(true)} + onRefresh={list.refetch} + isRefreshing={list.isFetching} + filterLabels={FILTER_LABELS} + formatFilterValue={formatFilterValue} + /> + + {(draft) => } + + + )} /> ); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTableColumns.tsx index e3fbc9dba08..8cca2caf214 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTableColumns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTableColumns.tsx @@ -1,11 +1,13 @@ "use client"; -import { ColumnDef } from "@tanstack/react-table"; +import { ColumnDef, FilterFn } from "@tanstack/react-table"; import { MoreHorizontal, Pencil, Trash2 } from "lucide-react"; -import { IdCell, MoneyCell } from "@/components/shared/table_cells"; -import { budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets"; +import { DataTableSortHeader } from "@/components/shared/DataTable"; +import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells"; +import type { budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets"; import { buttonVariants } from "@/components/ui/button"; +import { getBudgetDurationLabel } from "@/components/common_components/budget_duration_dropdown"; import { DropdownMenu, DropdownMenuContent, @@ -15,6 +17,15 @@ import { } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/cva.config"; +/** + * Filtering happens on the server, so this never runs as a predicate. It exists to override + * TanStack's auto-remove heuristic, which infers a filter shape from the column's first cell + * and silently discards a filter whose value is not that shape (a range object on a numeric + * column, for instance). + */ +const serverFilter: FilterFn = () => true; +serverFilter.autoRemove = () => false; + function RateLimitCell({ value }: { value: number | null }) { if (value == null) { return n/a; @@ -22,6 +33,13 @@ function RateLimitCell({ value }: { value: number | null }) { return {value}; } +function BudgetDurationCell({ value }: { value: string | null }) { + if (!value) { + return Not set; + } + return {getBudgetDurationLabel(value)}; +} + interface BudgetRowActionsProps { budget: budgetItem; onEditClick: (budget: budgetItem) => void; @@ -72,38 +90,56 @@ export const getBudgetTableColumns = ({ id: "budget_id", accessorKey: "budget_id", meta: { title: "Budget ID" }, - header: "Budget ID", - size: 220, - enableSorting: false, - cell: ({ row }) => , + header: ({ column }) => , + cell: ({ row }) => ( + + ), }, { id: "max_budget", accessorKey: "max_budget", + filterFn: serverFilter, meta: { title: "Max Budget", numeric: true }, - header: "Max Budget", + header: ({ column }) => , size: 120, - enableSorting: false, cell: ({ row }) => , }, { id: "tpm_limit", accessorKey: "tpm_limit", meta: { title: "TPM", numeric: true }, - header: "TPM", + header: ({ column }) => , size: 100, - enableSorting: false, cell: ({ row }) => , }, { id: "rpm_limit", accessorKey: "rpm_limit", meta: { title: "RPM", numeric: true }, - header: "RPM", + header: ({ column }) => , size: 100, - enableSorting: false, cell: ({ row }) => , }, + { + id: "budget_duration", + accessorKey: "budget_duration", + filterFn: serverFilter, + meta: { title: "Reset" }, + // "7d"/"30d" sort lexicographically, not chronologically, so the route does not offer it. + enableSorting: false, + header: ({ column }) => , + size: 110, + cell: ({ row }) => , + }, + { + id: "created_at", + accessorKey: "created_at", + filterFn: serverFilter, + meta: { title: "Created" }, + header: ({ column }) => , + size: 160, + cell: ({ row }) => , + }, ...(canModify ? [ { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.test.tsx index 392616f1935..46f72cd8886 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.test.tsx @@ -1,217 +1,254 @@ -import { fireEvent, render, waitFor, screen } from "@testing-library/react"; -import { act } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ApiError } from "@/lib/http/client"; + import BudgetPanel from "./budget_panel"; -const mockBudgets = [ - { - budget_id: "budget-1", - max_budget: 100, - rpm_limit: 10, - tpm_limit: 1000, - updated_at: "2024-01-01T00:00:00Z", - }, -]; - -vi.mock("@/app/(dashboard)/hooks/budgets/useBudgets", () => ({ - useBudgets: vi.fn().mockReturnValue({ data: [], isLoading: false }), - useDeleteBudget: vi.fn().mockReturnValue({ mutateAsync: vi.fn(), isPending: false }), - useCreateBudget: vi.fn().mockReturnValue({ mutateAsync: vi.fn() }), - useUpdateBudget: vi.fn().mockReturnValue({ mutateAsync: vi.fn() }), +const { getMock, budgetDeleteMock } = vi.hoisted(() => ({ + getMock: vi.fn(), + budgetDeleteMock: vi.fn(), })); -import { - useBudgets, - useDeleteBudget, - useCreateBudget, - useUpdateBudget, -} from "@/app/(dashboard)/hooks/budgets/useBudgets"; +vi.mock("@/components/networking", () => ({ + apiClient: { get: getMock }, + budgetCreateCall: vi.fn(), + budgetUpdateCall: vi.fn(), + budgetDeleteCall: budgetDeleteMock, + getProxyBaseUrl: () => "", +})); -const createQueryClient = () => - new QueryClient({ - defaultOptions: { queries: { retry: false, gcTime: 0 } }, - }); +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => ({ accessToken: "sk-test", userRole: "Admin", userId: "u1" }), +})); -function renderWithProviders(ui: React.ReactElement) { - const qc = createQueryClient(); - return render({ui}); +vi.mock("@/components/molecules/notifications_manager", () => ({ + default: { success: vi.fn(), info: vi.fn(), fromBackend: vi.fn() }, +})); + +interface BudgetSeed { + budget_id: string; + max_budget: number | null; + budget_duration: string | null; } +const budgetRow = (seed: BudgetSeed) => ({ + soft_budget: null, + tpm_limit: 1000, + rpm_limit: 10, + budget_reset_at: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + ...seed, +}); + +const FORBIDDEN_PROBLEM = { + type: "about:blank", + title: "Forbidden", + status: 403, + detail: "Only proxy admins can view budgets", +}; + +const DEFAULT_ROWS = [ + budgetRow({ budget_id: "ecc1869c-6231-4380-a56d-1a0be457477d", max_budget: 100, budget_duration: "30d" }), +]; + +const respondWith = (rows: ReturnType[], totalCount: number) => { + getMock.mockResolvedValue({ + data: rows, + meta: { total_count: totalCount, page: 1, page_size: 50, total_pages: Math.ceil(totalCount / 50) }, + }); +}; + +type QueryRecord = Record; + +const queries = (): QueryRecord[] => getMock.mock.calls.map((call) => (call[1] as { query: QueryRecord }).query); +const lastQuery = (): QueryRecord => queries()[queries().length - 1]; +const paths = (): string[] => getMock.mock.calls.map((call) => String(call[0])); + +const renderPanel = () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + return render( + + + , + ); +}; + +const openFilters = async (user: ReturnType) => { + await user.click(screen.getByTestId("datatable-filters-trigger")); + await screen.findByTestId("filter-drawer-body"); +}; + describe("Budget Panel", () => { - afterEach(() => { + beforeEach(() => { vi.clearAllMocks(); + respondWith(DEFAULT_ROWS, 1); }); - it("should render the budget panel and load budgets", async () => { - vi.mocked(useBudgets).mockReturnValue({ - data: mockBudgets, - isLoading: false, - } as any); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByText("Create a budget to assign to customers.")).toBeInTheDocument(); - expect(screen.getByText("budget-1")).toBeInTheDocument(); - }); + it("loads the first page of budgets, newest first", async () => { + renderPanel(); + await waitFor(() => expect(getMock).toHaveBeenCalled()); + expect(paths()[0]).toBe("/management/v1/budgets"); + expect(queries()[0]).toEqual({ page: 1, page_size: 50, sort: "-created_at" }); + expect(await screen.findByText("ecc1869c-6231-4380-a56d-1a0be457477d")).toBeInTheDocument(); }); - it("should open delete modal from the actions menu", async () => { + it("asks the server to sort when a sortable header is clicked", async () => { const user = userEvent.setup(); - vi.mocked(useBudgets).mockReturnValue({ - data: [ - { - budget_id: "budget-to-delete", - max_budget: 200, - rpm_limit: 20, - tpm_limit: 2000, - updated_at: "2024-01-02T00:00:00Z", - }, - ], - isLoading: false, - } as any); + renderPanel(); + await waitFor(() => expect(getMock).toHaveBeenCalled()); - renderWithProviders(); + await user.click(screen.getByTestId("sort-header-max_budget")); + await waitFor(() => expect(lastQuery().sort).toBe("-max_budget")); - await waitFor(() => { - expect(screen.getByText("budget-to-delete")).toBeInTheDocument(); - }); + await user.click(screen.getByTestId("sort-header-max_budget")); + await waitFor(() => expect(lastQuery().sort).toBe("max_budget")); - await user.click(screen.getByTestId("budget-actions-budget-to-delete")); + await user.click(screen.getByTestId("sort-header-budget_id")); + await waitFor(() => expect(lastQuery().sort).toBe("budget_id")); + }); + + it("searches on budget_id with a debounced q", async () => { + const user = userEvent.setup(); + renderPanel(); + await waitFor(() => expect(getMock).toHaveBeenCalled()); + + await user.type(screen.getByTestId("datatable-search"), "ecc"); + await waitFor(() => expect(lastQuery().q).toBe("ecc")); + expect(queries().some((query) => query.q === "e" || query.q === "ec")).toBe(false); + }); + + it("filters by reset duration and clears it again", async () => { + const user = userEvent.setup(); + renderPanel(); + await waitFor(() => expect(getMock).toHaveBeenCalled()); + + await openFilters(user); + await user.click(screen.getByTestId("budget-filter-duration-7d")); + await user.click(screen.getByTestId("budget-filter-duration-30d")); + await user.click(screen.getByTestId("filter-drawer-apply")); + + await waitFor(() => expect(lastQuery()["filter[budget_duration][in]"]).toBe("7d,30d")); + + await user.click(screen.getByTestId("filter-chip-remove-budget_duration")); + await waitFor(() => expect(lastQuery()).not.toHaveProperty("filter[budget_duration][in]")); + }); + + it("filters by budgets with no reset duration", async () => { + const user = userEvent.setup(); + renderPanel(); + await waitFor(() => expect(getMock).toHaveBeenCalled()); + + await openFilters(user); + await user.click(screen.getByTestId("budget-filter-duration-__unset__")); + await user.click(screen.getByTestId("filter-drawer-apply")); + + await waitFor(() => expect(lastQuery()["filter[budget_duration][is_null]"]).toBe("true")); + expect(lastQuery()).not.toHaveProperty("filter[budget_duration][in]"); + }); + + it("filters by a max budget range and clears it again", async () => { + const user = userEvent.setup(); + renderPanel(); + await waitFor(() => expect(getMock).toHaveBeenCalled()); + + await openFilters(user); + await user.type(screen.getByTestId("budget-filter-max-budget-min"), "10"); + await user.type(screen.getByTestId("budget-filter-max-budget-max"), "500"); + await user.click(screen.getByTestId("filter-drawer-apply")); + + await waitFor(() => expect(lastQuery()["filter[max_budget][gte]"]).toBe("10")); + expect(lastQuery()["filter[max_budget][lte]"]).toBe("500"); + + await user.click(screen.getByTestId("datatable-clear-filters")); + await waitFor(() => expect(lastQuery()).not.toHaveProperty("filter[max_budget][gte]")); + expect(lastQuery()).not.toHaveProperty("filter[max_budget][lte]"); + }); + + it("filters to unlimited budgets only", async () => { + const user = userEvent.setup(); + renderPanel(); + await waitFor(() => expect(getMock).toHaveBeenCalled()); + + await openFilters(user); + await user.type(screen.getByTestId("budget-filter-max-budget-min"), "10"); + await user.click(screen.getByTestId("budget-filter-max-budget-unlimited")); + await user.click(screen.getByTestId("filter-drawer-apply")); + + await waitFor(() => expect(lastQuery()["filter[max_budget][is_null]"]).toBe("true")); + expect(lastQuery()).not.toHaveProperty("filter[max_budget][gte]"); + }); + + it("filters by a created date range covering whole local days", async () => { + const user = userEvent.setup(); + renderPanel(); + await waitFor(() => expect(getMock).toHaveBeenCalled()); + + await openFilters(user); + await user.type(screen.getByTestId("budget-filter-created-from"), "2026-01-05"); + await user.type(screen.getByTestId("budget-filter-created-to"), "2026-01-06"); + await user.click(screen.getByTestId("filter-drawer-apply")); + + await waitFor(() => + expect(lastQuery()["filter[created_at][gte]"]).toBe(new Date("2026-01-05T00:00:00.000").toISOString()), + ); + expect(lastQuery()["filter[created_at][lte]"]).toBe(new Date("2026-01-06T23:59:59.999").toISOString()); + }); + + it("pages through the results and changes page size", async () => { + const user = userEvent.setup(); + respondWith(DEFAULT_ROWS, 400); + renderPanel(); + await waitFor(() => expect(getMock).toHaveBeenCalled()); + + await user.click(screen.getByTestId("pagination-next")); + await waitFor(() => expect(lastQuery().page).toBe(2)); + expect(lastQuery().page_size).toBe(50); + + await user.click(screen.getByTestId("pagination-page-size")); + await user.click(await screen.findByRole("option", { name: "25" })); + await waitFor(() => expect(lastQuery().page_size).toBe(25)); + }); + + it("renders an access-denied state when the route rejects the caller", async () => { + getMock.mockRejectedValue(new ApiError("Only proxy admins can view budgets", 403, FORBIDDEN_PROBLEM)); + renderPanel(); + expect(await screen.findByText("You do not have access to budgets")).toBeInTheDocument(); + expect(screen.queryByText("No budgets yet")).not.toBeInTheDocument(); + }); + + it("deletes a budget from the actions menu", async () => { + const user = userEvent.setup(); + budgetDeleteMock.mockResolvedValue(undefined); + renderPanel(); + await screen.findByText("ecc1869c-6231-4380-a56d-1a0be457477d"); + + await user.click(screen.getByTestId("budget-actions-ecc1869c-6231-4380-a56d-1a0be457477d")); await user.click(await screen.findByTestId("budget-action-delete")); + await screen.findByText("Delete Budget?"); + await user.click(screen.getByRole("button", { name: /^delete$/i })); - await waitFor(() => { - expect(screen.getByText("Delete Budget?")).toBeInTheDocument(); - }); + await waitFor(() => + expect(budgetDeleteMock).toHaveBeenCalledWith("sk-test", "ecc1869c-6231-4380-a56d-1a0be457477d"), + ); }); - it("should successfully delete a budget", async () => { + it("refetches the current page after a delete", async () => { const user = userEvent.setup(); - const deleteMutateAsync = vi.fn().mockResolvedValue(undefined); - vi.mocked(useBudgets).mockReturnValue({ - data: [ - { - budget_id: "budget-to-delete", - max_budget: 200, - rpm_limit: 20, - tpm_limit: 2000, - updated_at: "2024-01-02T00:00:00Z", - }, - ], - isLoading: false, - } as any); - vi.mocked(useDeleteBudget).mockReturnValue({ - mutateAsync: deleteMutateAsync, - isPending: false, - } as any); + budgetDeleteMock.mockResolvedValue(undefined); + renderPanel(); + await screen.findByText("ecc1869c-6231-4380-a56d-1a0be457477d"); + const before = getMock.mock.calls.length; - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByText("budget-to-delete")).toBeInTheDocument(); - }); - - await user.click(screen.getByTestId("budget-actions-budget-to-delete")); + await user.click(screen.getByTestId("budget-actions-ecc1869c-6231-4380-a56d-1a0be457477d")); await user.click(await screen.findByTestId("budget-action-delete")); + await screen.findByText("Delete Budget?"); + await user.click(screen.getByRole("button", { name: /^delete$/i })); - await waitFor(() => { - expect(screen.getByText("Delete Budget?")).toBeInTheDocument(); - }); - - const confirmButton = screen.getByRole("button", { name: /delete/i }); - act(() => { - fireEvent.click(confirmButton); - }); - - await waitFor(() => { - expect(deleteMutateAsync).toHaveBeenCalledWith("budget-to-delete"); - }); - }); - - it("should render empty state without crashing", async () => { - vi.mocked(useBudgets).mockReturnValue({ - data: [], - isLoading: false, - } as any); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByText("Create a budget to assign to customers.")).toBeInTheDocument(); - }); - }); - - it("should handle delete error", async () => { - const user = userEvent.setup(); - const deleteMutateAsync = vi.fn().mockRejectedValue(new Error("Delete failed")); - vi.mocked(useBudgets).mockReturnValue({ - data: [ - { - budget_id: "budget-to-delete", - max_budget: 200, - rpm_limit: 20, - tpm_limit: 2000, - updated_at: "2024-01-02T00:00:00Z", - }, - ], - isLoading: false, - } as any); - vi.mocked(useDeleteBudget).mockReturnValue({ - mutateAsync: deleteMutateAsync, - isPending: false, - } as any); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByText("budget-to-delete")).toBeInTheDocument(); - }); - - await user.click(screen.getByTestId("budget-actions-budget-to-delete")); - await user.click(await screen.findByTestId("budget-action-delete")); - - await waitFor(() => { - expect(screen.getByText("Delete Budget?")).toBeInTheDocument(); - }); - - const confirmButton = screen.getByRole("button", { name: /delete/i }); - act(() => { - fireEvent.click(confirmButton); - }); - - await waitFor(() => { - expect(deleteMutateAsync).toHaveBeenCalledWith("budget-to-delete"); - }); - }); - - it("should open edit modal from the actions menu", async () => { - const user = userEvent.setup(); - vi.mocked(useBudgets).mockReturnValue({ - data: [ - { - budget_id: "budget-to-edit", - max_budget: 300, - rpm_limit: 30, - tpm_limit: 3000, - updated_at: "2024-01-03T00:00:00Z", - }, - ], - isLoading: false, - } as any); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByText("budget-to-edit")).toBeInTheDocument(); - }); - - await user.click(screen.getByTestId("budget-actions-budget-to-edit")); - await user.click(await screen.findByTestId("budget-action-edit")); - - await waitFor(() => { - expect(screen.getByText("Edit Budget")).toBeInTheDocument(); - }); + await waitFor(() => expect(getMock.mock.calls.length).toBeGreaterThan(before)); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx index 2cf2a4c06ec..78c2c0ca74a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx @@ -3,13 +3,13 @@ * */ -import React, { useState } from "react"; +import React, { useCallback, useState } from "react"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; import NotificationsManager from "@/components/molecules/notifications_manager"; -import { useBudgets, useDeleteBudget, budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets"; +import { useBudgetList, useDeleteBudget, budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets"; import BudgetModal from "./budget_modal"; import BudgetTable from "./BudgetTable"; import EditBudgetModal from "./edit_budget_modal"; @@ -31,21 +31,25 @@ const BudgetPanel: React.FC = ({ accessToken }) => { // Admin Viewer follows the read-parity rule: see budgets, no writes. const canModify = isProxyAdminRole(userRole ?? ""); - const { data: budgetList = [], isLoading } = useBudgets(); + const budgetList = useBudgetList(); const deleteBudget = useDeleteBudget(); - const handleEditCall = async (budget: budgetItem) => { - if (accessToken == null) { - return; - } - setSelectedBudget(budget); - setIsEditModalVisible(true); - }; + // Stable identities keep the memoized column defs stable; new ones remount every header and cell. + const handleEditCall = useCallback( + (budget: budgetItem) => { + if (accessToken == null) { + return; + } + setSelectedBudget(budget); + setIsEditModalVisible(true); + }, + [accessToken], + ); - const handleDeleteClick = (budget: budgetItem) => { + const handleDeleteClick = useCallback((budget: budgetItem) => { setSelectedBudget(budget); setIsDeleteModalVisible(true); - }; + }, []); const handleDeleteConfirm = async () => { if (!selectedBudget || accessToken == null) { @@ -99,8 +103,7 @@ const BudgetPanel: React.FC = ({ accessToken }) => { )}

Create a budget to assign to customers.

{ + it("sends nothing when no filter is active", () => { + expect(serializeBudgetFilters([])).toEqual({}); + }); + + it("maps selected durations onto the in operator", () => { + expect(serializeBudgetFilters([{ id: "budget_duration", value: ["7d", "30d"] }])).toEqual({ + "filter[budget_duration][in]": "7d,30d", + }); + }); + + it("maps 'Not set' onto is_null instead of in", () => { + expect(serializeBudgetFilters([{ id: "budget_duration", value: [BUDGET_DURATION_UNSET] }])).toEqual({ + "filter[budget_duration][is_null]": "true", + }); + }); + + it("never sends in alongside is_null for the same field", () => { + const params = serializeBudgetFilters([{ id: "budget_duration", value: ["7d", BUDGET_DURATION_UNSET] }]); + expect(params["filter[budget_duration][in]"]).toBeUndefined(); + expect(params["filter[budget_duration][is_null]"]).toBe("true"); + }); + + it("maps a max budget range onto gte and lte", () => { + expect(serializeBudgetFilters([{ id: "max_budget", value: { min: "10", max: "250.5" } }])).toEqual({ + "filter[max_budget][gte]": "10", + "filter[max_budget][lte]": "250.5", + }); + }); + + it("sends only the bound that was filled in", () => { + expect(serializeBudgetFilters([{ id: "max_budget", value: { min: "10", max: "" } }])).toEqual({ + "filter[max_budget][gte]": "10", + }); + }); + + it("maps 'Unlimited only' onto is_null and drops the range", () => { + const params = serializeBudgetFilters([{ id: "max_budget", value: { min: "10", unlimitedOnly: true } }]); + expect(params).toEqual({ "filter[max_budget][is_null]": "true" }); + }); + + it("widens a created-at day range to cover the whole local days", () => { + const params = serializeBudgetFilters([{ id: "created_at", value: { from: "2026-01-05", to: "2026-01-06" } }]); + expect(params["filter[created_at][gte]"]).toBe(new Date("2026-01-05T00:00:00.000").toISOString()); + expect(params["filter[created_at][lte]"]).toBe(new Date("2026-01-06T23:59:59.999").toISOString()); + }); + + it("ignores an unparseable date rather than sending a broken bound", () => { + expect(serializeBudgetFilters([{ id: "created_at", value: { from: "not-a-date" } }])).toEqual({}); + }); + + it("ignores filter ids the route does not declare", () => { + expect(serializeBudgetFilters([{ id: "spend", value: "5" }])).toEqual({}); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/budgets/budgetFilters.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/budgets/budgetFilters.ts new file mode 100644 index 00000000000..f54eddb3913 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/budgets/budgetFilters.ts @@ -0,0 +1,91 @@ +import type { ColumnFilter, ColumnFiltersState } from "@tanstack/react-table"; + +export const BUDGET_DURATION_UNSET = "__unset__"; + +export const BUDGET_DURATION_FILTER_OPTIONS: readonly { value: string; label: string }[] = [ + { value: "1h", label: "hourly" }, + { value: "24h", label: "daily" }, + { value: "7d", label: "weekly" }, + { value: "30d", label: "monthly" }, + { value: BUDGET_DURATION_UNSET, label: "Not set" }, +]; + +export interface MaxBudgetFilterValue { + min?: string; + max?: string; + unlimitedOnly?: boolean; +} + +export interface CreatedAtFilterValue { + from?: string; + to?: string; +} + +type QueryEntry = readonly [string, string]; + +const entries = (key: string, value: string): QueryEntry[] => (value === "" ? [] : [[key, value]]); + +const asStringArray = (value: unknown): string[] => + Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; + +const asRecord = (value: unknown): Record => + typeof value === "object" && value !== null ? (value as Record) : {}; + +const asTrimmed = (value: unknown): string => (typeof value === "string" ? value.trim() : ""); + +/** The date inputs give a calendar day; the route wants an instant, so widen to the viewer's whole local day. */ +const isoAt = (day: string, time: string): string => { + if (day === "") { + return ""; + } + const parsed = new Date(`${day}T${time}`); + return Number.isNaN(parsed.getTime()) ? "" : parsed.toISOString(); +}; + +/** + * "Not set" is exclusive with the concrete durations. The route's contract does not say how it + * combines `in` with `is_null` on one field, and under AND semantics that pair can only match + * nothing, so we never send both. + */ +const durationParams = (value: unknown): QueryEntry[] => { + const selected = asStringArray(value); + if (selected.includes(BUDGET_DURATION_UNSET)) { + return [["filter[budget_duration][is_null]", "true"]]; + } + return entries("filter[budget_duration][in]", selected.join(",")); +}; + +const maxBudgetParams = (value: unknown): QueryEntry[] => { + const draft = asRecord(value); + if (draft.unlimitedOnly === true) { + return [["filter[max_budget][is_null]", "true"]]; + } + return [ + ...entries("filter[max_budget][gte]", asTrimmed(draft.min)), + ...entries("filter[max_budget][lte]", asTrimmed(draft.max)), + ]; +}; + +const createdAtParams = (value: unknown): QueryEntry[] => { + const draft = asRecord(value); + return [ + ...entries("filter[created_at][gte]", isoAt(asTrimmed(draft.from), "00:00:00.000")), + ...entries("filter[created_at][lte]", isoAt(asTrimmed(draft.to), "23:59:59.999")), + ]; +}; + +const filterParams = (filter: ColumnFilter): QueryEntry[] => { + switch (filter.id) { + case "budget_duration": + return durationParams(filter.value); + case "max_budget": + return maxBudgetParams(filter.value); + case "created_at": + return createdAtParams(filter.value); + default: + return []; + } +}; + +export const serializeBudgetFilters = (filters: ColumnFiltersState): Readonly> => + Object.fromEntries(filters.flatMap(filterParams)); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/budgets/useBudgets.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/budgets/useBudgets.ts index 0d8d94f2369..e5f24e5412d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/budgets/useBudgets.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/budgets/useBudgets.ts @@ -1,28 +1,58 @@ -import { useQuery, useMutation, useQueryClient, UseQueryResult } from "@tanstack/react-query"; -import { createQueryKeys } from "../common/queryKeysFactory"; -import { getBudgetList, budgetCreateCall, budgetUpdateCall, budgetDeleteCall } from "@/components/networking"; +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { SortingState } from "@tanstack/react-table"; +import { useCallback } from "react"; + import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { apiClient, budgetCreateCall, budgetUpdateCall, budgetDeleteCall } from "@/components/networking"; + +import { createQueryKeys } from "../common/queryKeysFactory"; +import { + useResourceList, + type ResourceListPage, + type ResourceListQuery, + type ResourceListResult, +} from "../common/useResourceList"; +import { serializeBudgetFilters } from "./budgetFilters"; export interface budgetItem { budget_id: string; max_budget: number | null; + soft_budget: number | null; rpm_limit: number | null; tpm_limit: number | null; + budget_duration: string | null; + budget_reset_at: string | null; + created_at: string; updated_at: string; } +export const BUDGET_LIST_PATH = "/management/v1/budgets"; + export const budgetKeys = createQueryKeys("budgets"); -export const useBudgets = (): UseQueryResult => { +const DEFAULT_PAGE_SIZE = 50; +const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }]; + +export const useBudgetList = (): ResourceListResult => { const { accessToken } = useAuthorized(); - return useQuery({ - queryKey: budgetKeys.list({}), - queryFn: async () => { - const data = await getBudgetList(accessToken!); - return (data ?? []).filter((item: budgetItem | null): item is budgetItem => item != null); - }, + + const fetchPage = useCallback( + (query: ResourceListQuery, signal: AbortSignal): Promise> => + apiClient.get>(BUDGET_LIST_PATH, { accessToken, query, signal }), + [accessToken], + ); + + const listOptions = { + queryKey: budgetKeys.lists(), + fetchPage, + serializeFilters: serializeBudgetFilters, + defaultSorting: DEFAULT_SORTING, + defaultPageSize: DEFAULT_PAGE_SIZE, enabled: Boolean(accessToken), - }); + }; + return useResourceList(listOptions); }; export const useCreateBudget = () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/common/useResourceList.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/hooks/common/useResourceList.test.tsx new file mode 100644 index 00000000000..3ca67b082ec --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/common/useResourceList.test.tsx @@ -0,0 +1,161 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ColumnFiltersState } from "@tanstack/react-table"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import React, { type PropsWithChildren } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + toSortParam, + useResourceList, + type ResourceListPage, + type ResourceListQuery, + type UseResourceListOptions, +} from "./useResourceList"; + +interface Row { + id: string; +} + +const page = (rows: Row[], totalCount: number): ResourceListPage => ({ + data: rows, + meta: { total_count: totalCount, page: 1, page_size: 50, total_pages: 1 }, +}); + +const noFilters = (): Readonly> => ({}); + +const calls: ResourceListQuery[] = []; + +const renderList = (overrides: Partial> = {}) => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); + const fetchPage = vi.fn((query: ResourceListQuery) => { + calls.push(query); + return Promise.resolve(page([{ id: "a" }], 3)); + }); + const options: UseResourceListOptions = { + queryKey: ["widgets", "list"], + fetchPage, + serializeFilters: noFilters, + defaultSorting: [{ id: "created_at", desc: true }], + defaultPageSize: 50, + enabled: true, + ...overrides, + }; + return renderHook(() => useResourceList(options), { wrapper }); +}; + +const lastCall = (): ResourceListQuery => calls[calls.length - 1]; + +describe("toSortParam", () => { + it("prefixes descending fields with a minus and joins with commas", () => { + expect(toSortParam([{ id: "created_at", desc: true }])).toBe("-created_at"); + expect(toSortParam([{ id: "max_budget", desc: false }])).toBe("max_budget"); + expect( + toSortParam([ + { id: "a", desc: false }, + { id: "b", desc: true }, + ]), + ).toBe("a,-b"); + }); +}); + +describe("useResourceList", () => { + beforeEach(() => { + calls.length = 0; + }); + + it("requests the first page with the default sort", async () => { + const { result } = renderList(); + await waitFor(() => expect(result.current.rowCount).toBe(3)); + expect(lastCall()).toEqual({ page: 1, page_size: 50, sort: "-created_at" }); + }); + + it("exposes the returned rows and total count", async () => { + const { result } = renderList(); + await waitFor(() => expect(result.current.rows).toEqual([{ id: "a" }])); + expect(result.current.rowCount).toBe(3); + }); + + it("does not fetch while disabled", async () => { + const { result } = renderList({ enabled: false }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(calls).toHaveLength(0); + }); + + it("sends the new sort and returns to the first page", async () => { + const { result } = renderList(); + await waitFor(() => expect(calls).toHaveLength(1)); + + act(() => result.current.onPaginationChange({ pageIndex: 2, pageSize: 50 })); + await waitFor(() => expect(lastCall().page).toBe(3)); + + act(() => result.current.onSortingChange([{ id: "max_budget", desc: false }])); + await waitFor(() => expect(lastCall().sort).toBe("max_budget")); + expect(lastCall().page).toBe(1); + }); + + it("omits sort entirely when nothing is sorted", async () => { + const { result } = renderList({ defaultSorting: [] }); + await waitFor(() => expect(calls).toHaveLength(1)); + expect(result.current.sorting).toEqual([]); + expect(lastCall()).not.toHaveProperty("sort"); + }); + + it("debounces the search into a single trimmed q and returns to the first page", async () => { + const { result } = renderList(); + await waitFor(() => expect(calls).toHaveLength(1)); + + act(() => result.current.onPaginationChange({ pageIndex: 1, pageSize: 50 })); + await waitFor(() => expect(lastCall().page).toBe(2)); + + act(() => result.current.onSearchChange("bud")); + act(() => result.current.onSearchChange("budg ")); + + await waitFor(() => expect(lastCall().q).toBe("budg")); + expect(lastCall().page).toBe(1); + expect(calls.some((call) => call.q === "bud")).toBe(false); + }); + + it("stops sending q once the search box is cleared", async () => { + const { result } = renderList(); + act(() => result.current.onSearchChange("budget")); + await waitFor(() => expect(lastCall().q).toBe("budget")); + + act(() => result.current.onSearchChange("")); + await waitFor(() => expect(lastCall()).not.toHaveProperty("q")); + }); + + it("merges serialized filters into the request and returns to the first page", async () => { + const serializeFilters = (filters: ColumnFiltersState): Readonly> => + filters.length === 0 ? {} : { "filter[colour][in]": String(filters[0].value) }; + const { result } = renderList({ serializeFilters }); + await waitFor(() => expect(calls).toHaveLength(1)); + + act(() => result.current.onPaginationChange({ pageIndex: 3, pageSize: 50 })); + await waitFor(() => expect(lastCall().page).toBe(4)); + + act(() => result.current.onColumnFiltersChange([{ id: "colour", value: "red" }])); + await waitFor(() => expect(lastCall()["filter[colour][in]"]).toBe("red")); + expect(lastCall().page).toBe(1); + + act(() => result.current.onColumnFiltersChange([])); + await waitFor(() => expect(lastCall()).not.toHaveProperty("filter[colour][in]")); + }); + + it("sends the requested page size", async () => { + const { result } = renderList(); + await waitFor(() => expect(calls).toHaveLength(1)); + + act(() => result.current.onPaginationChange({ pageIndex: 0, pageSize: 25 })); + await waitFor(() => expect(lastCall().page_size).toBe(25)); + }); + + it("surfaces a failed page as an error instead of empty rows", async () => { + const fetchPage = vi.fn(() => Promise.reject(new Error("boom"))); + const { result } = renderList({ fetchPage }); + await waitFor(() => expect(result.current.error?.message).toBe("boom")); + expect(result.current.rows).toEqual([]); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/common/useResourceList.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/common/useResourceList.ts new file mode 100644 index 00000000000..fb40d108234 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/common/useResourceList.ts @@ -0,0 +1,142 @@ +"use client"; + +import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; +import { useQuery, type UseQueryOptions } from "@tanstack/react-query"; +import type { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table"; +import { useCallback, useMemo, useState } from "react"; + +import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; + +export type ResourceListQuery = Readonly>; + +export interface ResourceListMeta { + total_count: number; + page: number; + page_size: number; + total_pages: number; +} + +export interface ResourceListPage { + data: TRow[]; + meta: ResourceListMeta; +} + +export interface UseResourceListOptions { + /** Prefix every list variant hangs off, so invalidating the resource root refetches whichever page is on screen. */ + queryKey: readonly unknown[]; + fetchPage: (query: ResourceListQuery, signal: AbortSignal) => Promise>; + /** Must be referentially stable; it feeds the query key. */ + serializeFilters: (filters: ColumnFiltersState) => Readonly>; + defaultSorting: SortingState; + defaultPageSize: number; + enabled: boolean; +} + +export interface ResourceListResult { + rows: TRow[]; + rowCount: number; + isLoading: boolean; + isFetching: boolean; + error: Error | null; + refetch: () => void; + + sorting: SortingState; + onSortingChange: OnChangeFn; + pagination: PaginationState; + onPaginationChange: OnChangeFn; + columnFilters: ColumnFiltersState; + onColumnFiltersChange: OnChangeFn; + searchValue: string; + onSearchChange: (value: string) => void; +} + +/** JSON:API sort form: comma separated fields, `-` prefix for descending. */ +export const toSortParam = (sorting: SortingState): string => + sorting.map((entry) => (entry.desc ? `-${entry.id}` : entry.id)).join(","); + +/** + * State container for a table whose sorting, paging, search and filtering all run + * on the server. It owns those four pieces of state, folds them into one JSON:API + * query, and returns the exact props DataTable's server modes want. + * + * Empty parameters are dropped rather than sent blank because the management + * routes reject query params they do not declare. + */ +export function useResourceList(options: UseResourceListOptions): ResourceListResult { + const { queryKey, fetchPage, serializeFilters, defaultSorting, defaultPageSize, enabled } = options; + + const [sorting, setSorting] = useState(defaultSorting); + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: defaultPageSize }); + const [columnFilters, setColumnFilters] = useState([]); + const [searchValue, setSearchValue] = useState(""); + const [debouncedSearch] = useDebouncedValue(searchValue, { wait: DEBOUNCE_WAIT_MS }); + + const query = useMemo(() => { + const sort = toSortParam(sorting); + const search = debouncedSearch.trim(); + return { + page: pagination.pageIndex + 1, + page_size: pagination.pageSize, + ...(sort === "" ? {} : { sort }), + ...(search === "" ? {} : { q: search }), + ...serializeFilters(columnFilters), + }; + }, [sorting, pagination.pageIndex, pagination.pageSize, debouncedSearch, columnFilters, serializeFilters]); + + const queryOptions: UseQueryOptions, Error, ResourceListPage, readonly unknown[]> = { + queryKey: [...queryKey, query], + queryFn: ({ signal }) => fetchPage(query, signal), + enabled, + placeholderData: (previous) => previous, + }; + const { data, isLoading, isFetching, error, refetch: refetchQuery } = useQuery(queryOptions); + + const toFirstPage = useCallback(() => setPagination((previous) => ({ ...previous, pageIndex: 0 })), []); + + const onSortingChange = useCallback>( + (updater) => { + setSorting(updater); + toFirstPage(); + }, + [toFirstPage], + ); + + const onColumnFiltersChange = useCallback>( + (updater) => { + setColumnFilters(updater); + toFirstPage(); + }, + [toFirstPage], + ); + + const onSearchChange = useCallback( + (value: string) => { + setSearchValue(value); + toFirstPage(); + }, + [toFirstPage], + ); + + const refetch = useCallback(() => { + void refetchQuery(); + }, [refetchQuery]); + + const rows = useMemo(() => data?.data ?? [], [data]); + + return { + rows, + rowCount: data?.meta.total_count ?? 0, + isLoading, + isFetching, + error, + refetch, + sorting, + onSortingChange, + pagination, + onPaginationChange: setPagination, + columnFilters, + onColumnFiltersChange, + searchValue, + onSearchChange, + }; +} From a685cc1511387149285dca3ea623fa6db4de7873 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 30 Jul 2026 19:38:24 -0700 Subject: [PATCH 04/37] feat(proxy): add a generic list contract for management/v1 entity lists Paging, sorting, filtering and search for an entity collection, declared once as a ListSpec and served by handle_list. The route injects a ListExecutor that owns its table, so this module never imports Prisma. The caller's scope is derived from the caller alone and ANDed with whatever they filtered on, so a query parameter can only narrow what they may read. This is the shared half of the budgets list; it lands here so the endpoint has something to register against, and drops out when the framework arrives on its own branch. --- .../management_v1/list_framework.py | 308 ++++++++++++++++++ .../management_endpoints/management_v1.py | 33 +- 2 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 litellm/proxy/management_endpoints/management_v1/list_framework.py diff --git a/litellm/proxy/management_endpoints/management_v1/list_framework.py b/litellm/proxy/management_endpoints/management_v1/list_framework.py new file mode 100644 index 00000000000..6e800d295f9 --- /dev/null +++ b/litellm/proxy/management_endpoints/management_v1/list_framework.py @@ -0,0 +1,308 @@ +"""Generic paging/sorting/filtering contract for `/management/v1` entity lists. + +Prisma-free by construction: a route declares a `ListSpec` and injects a +`ListExecutor` that owns the table, so the parsing, scoping and envelope rules +stay in one place and every entity list answers the same way. +""" + +from __future__ import annotations + +import math +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime +from typing import Generic, Literal, Protocol, TypeAlias, TypeVar +from urllib.parse import urlencode + +from fastapi import Request +from pydantic import JsonValue + +from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.management_endpoints.management_v1.common import ( + PROBLEM_TYPE_BASE, + ManagementProblem, +) +from litellm.types.proxy.management_endpoints.management_v1 import ( + ListLinks, + ListMeta, + ListResponse, + ProblemDetail, +) + +FilterOp: TypeAlias = Literal["eq", "in", "gte", "lte", "contains", "is_null"] +FilterType: TypeAlias = Literal["string", "number", "datetime"] + +# Quoted so the recursive alias parses under the repo's 3.10 floor, where neither +# the `type` statement nor a forward reference inside a `|` expression exists. +WhereLeaf: TypeAlias = "str | int | float | bool | datetime | None" +WhereValue: TypeAlias = "WhereLeaf | Sequence[WhereLeaf] | Where | Sequence[Where]" +Where: TypeAlias = "Mapping[str, WhereValue]" +OrderBy: TypeAlias = "Sequence[Mapping[str, Literal['asc', 'desc']]]" + +RowT = TypeVar("RowT") + +PAGINATION_PARAMS = frozenset({"page", "page_size", "sort", "q"}) + + +@dataclass(frozen=True, slots=True) +class FilterSpec: + type: FilterType + ops: frozenset[FilterOp] + + +@dataclass(frozen=True, slots=True) +class SortKey: + field: str + descending: bool + + +@dataclass(frozen=True, slots=True) +class ScopeAll: + """The caller may read every row.""" + + +@dataclass(frozen=True, slots=True) +class ScopeWhere: + """The caller may read only rows matching `where`.""" + + where: Where + + +@dataclass(frozen=True, slots=True) +class ScopeDenied: + """The caller may not read the collection at all.""" + + detail: str + + +Scope: TypeAlias = "ScopeAll | ScopeWhere | ScopeDenied" + + +class ListExecutor(Protocol, Generic[RowT]): + """The table half of a list, injected so the framework never imports Prisma.""" + + async def count(self, where: Where) -> int: ... + + async def find_many(self, where: Where, order: OrderBy, skip: int, take: int) -> Sequence[RowT]: ... + + +@dataclass(frozen=True, slots=True) +class ListSpec(Generic[RowT]): + resource: str + sortable: frozenset[str] + searchable: frozenset[str] + filters: Mapping[str, FilterSpec] + default_sort: tuple[SortKey, ...] + default_page_size: int + max_page_size: int + scope: Callable[[UserAPIKeyAuth], Scope] + serialize: Callable[[RowT], Mapping[str, JsonValue]] + tiebreaker: str + + +@dataclass(frozen=True, slots=True) +class QueryPlan: + where: Where + order: OrderBy + skip: int + take: int + page: int + page_size: int + + +def _problem(slug: str, title: str, detail: str, allowed: Sequence[str] | None = None) -> ManagementProblem: + return ManagementProblem( + ProblemDetail( + type=f"{PROBLEM_TYPE_BASE}{slug}", + title=title, + status=400, + detail=detail, + allowed=list(allowed) if allowed is not None else None, + ) + ) + + +def _allowed_params(filters: Mapping[str, FilterSpec]) -> frozenset[str]: + return PAGINATION_PARAMS | frozenset( + f"filter[{field}][{op}]" for field, filter_spec in filters.items() for op in filter_spec.ops + ) + + +def _reject_unknown_params(request: Request, filters: Mapping[str, FilterSpec]) -> None: + allowed = _allowed_params(filters) + unknown = tuple(sorted(name for name in request.query_params if name not in allowed)) + if not unknown: + return + raise _problem( + "unknown-query-parameter", + "Unknown query parameter", + f"Unrecognized query parameter(s): {', '.join(unknown)}.", + sorted(allowed), + ) + + +def _positive_int(raw: str | None, default: int, name: str) -> int: + if raw is None: + return default + try: + value = int(raw) + except ValueError: + raise _problem("invalid-query-parameter", "Invalid query parameter", f"{name} must be an integer.") + if value < 1: + raise _problem("invalid-query-parameter", "Invalid query parameter", f"{name} must be at least 1.") + return value + + +def _parse_sort(raw: str | None, sortable: frozenset[str], default_sort: tuple[SortKey, ...]) -> tuple[SortKey, ...]: + if raw is None: + return default_sort + keys = tuple( + SortKey(field=token.removeprefix("-"), descending=token.startswith("-")) + for token in (part.strip() for part in raw.split(",")) + if token + ) + unknown = tuple(key.field for key in keys if key.field not in sortable) + if unknown: + raise _problem( + "invalid-sort-field", + "Invalid sort field", + f"Cannot sort on: {', '.join(unknown)}.", + sorted(sortable), + ) + return keys or default_sort + + +def _order_by(keys: Sequence[SortKey], tiebreaker: str) -> OrderBy: + tail = () if any(key.field == tiebreaker for key in keys) else (SortKey(field=tiebreaker, descending=False),) + return tuple({key.field: ("desc" if key.descending else "asc")} for key in (*keys, *tail)) + + +def _coerce(value: str, filter_type: FilterType, param: str) -> WhereLeaf: + if filter_type == "number": + try: + return float(value) + except ValueError: + raise _problem("invalid-filter-value", "Invalid filter value", f"{param} must be a number.") + if filter_type == "datetime": + try: + return datetime.fromisoformat(value) + except ValueError: + raise _problem("invalid-filter-value", "Invalid filter value", f"{param} must be an ISO-8601 timestamp.") + return value + + +def _bool(value: str, param: str) -> bool: + if value.lower() in ("true", "1"): + return True + if value.lower() in ("false", "0"): + return False + raise _problem("invalid-filter-value", "Invalid filter value", f"{param} must be true or false.") + + +def _condition(field: str, op: FilterOp, raw: str, filter_type: FilterType, param: str) -> Where: + if op == "is_null": + return {field: None} if _bool(raw, param) else {field: {"not": None}} + if op == "in": + return {field: {"in": tuple(_coerce(part, filter_type, param) for part in raw.split(",") if part)}} + if op == "contains": + return {field: {"contains": raw, "mode": "insensitive"}} + if op == "eq": + return {field: _coerce(raw, filter_type, param)} + return {field: {op: _coerce(raw, filter_type, param)}} + + +def _filter_conditions(request: Request, filters: Mapping[str, FilterSpec]) -> tuple[Where, ...]: + return tuple( + _condition(field, op, request.query_params[f"filter[{field}][{op}]"], spec.type, f"filter[{field}][{op}]") + for field, spec in filters.items() + for op in sorted(spec.ops) + if f"filter[{field}][{op}]" in request.query_params + ) + + +def _search_condition(raw: str | None, searchable: frozenset[str]) -> tuple[Where, ...]: + if not raw or not searchable: + return () + return ({"OR": tuple({field: {"contains": raw, "mode": "insensitive"}} for field in sorted(searchable))},) + + +def build_query_plan(request: Request, spec: ListSpec[RowT], scope: Scope) -> QueryPlan: + """Turn the query string into the executor's arguments, or raise a 400 problem. + + `scope` is derived from the caller, never from the query string, and is ANDed + with the caller's filters so a filter can only ever narrow what they may read. + """ + _reject_unknown_params(request, spec.filters) + + page = _positive_int(request.query_params.get("page"), 1, "page") + page_size = min( + _positive_int(request.query_params.get("page_size"), spec.default_page_size, "page_size"), + spec.max_page_size, + ) + keys = _parse_sort(request.query_params.get("sort"), spec.sortable, spec.default_sort) + + scope_conditions: tuple[Where, ...] = (scope.where,) if isinstance(scope, ScopeWhere) else () + conditions = ( + scope_conditions + + _filter_conditions(request, spec.filters) + + _search_condition(request.query_params.get("q"), spec.searchable) + ) + + return QueryPlan( + where={"AND": conditions} if conditions else {}, + order=_order_by(keys, spec.tiebreaker), + skip=(page - 1) * page_size, + take=page_size, + page=page, + page_size=page_size, + ) + + +def _page_url(request: Request, page: int) -> str: + others = tuple((key, value) for key, value in request.query_params.multi_items() if key != "page") + return f"{request.url.path}?{urlencode((*others, ('page', page)))}" + + +def _links(request: Request, page: int, last_page: int) -> ListLinks: + return ListLinks( + self_link=_page_url(request, page), + first=_page_url(request, 1), + prev=_page_url(request, page - 1) if page > 1 else None, + next=_page_url(request, page + 1) if page < last_page else None, + last=_page_url(request, last_page), + ) + + +async def handle_list( + request: Request, + spec: ListSpec[RowT], + executor: ListExecutor[RowT], + caller: UserAPIKeyAuth, +) -> ListResponse: + """Serve one page of `spec.resource` under the caller's scope.""" + scope = spec.scope(caller) + if isinstance(scope, ScopeDenied): + raise ManagementProblem( + ProblemDetail( + type=f"{PROBLEM_TYPE_BASE}forbidden", + title="Forbidden", + status=403, + detail=scope.detail, + ) + ) + + plan = build_query_plan(request, spec, scope) + total_count = await executor.count(plan.where) + rows = await executor.find_many(where=plan.where, order=plan.order, skip=plan.skip, take=plan.take) + total_pages = math.ceil(total_count / plan.page_size) + + return ListResponse( + data=tuple(spec.serialize(row) for row in rows), + meta=ListMeta( + page=plan.page, + page_size=plan.page_size, + total_count=total_count, + total_pages=total_pages, + ), + links=_links(request, plan.page, max(total_pages, 1)), + ) diff --git a/litellm/types/proxy/management_endpoints/management_v1.py b/litellm/types/proxy/management_endpoints/management_v1.py index 2aecc54f114..a7427bc7590 100644 --- a/litellm/types/proxy/management_endpoints/management_v1.py +++ b/litellm/types/proxy/management_endpoints/management_v1.py @@ -1,6 +1,8 @@ """Shared response shapes for the `/management/v1` control-plane surface.""" -from pydantic import BaseModel, ConfigDict, Field +from collections.abc import Mapping + +from pydantic import BaseModel, ConfigDict, Field, JsonValue class ProblemDetail(BaseModel): @@ -37,3 +39,32 @@ class FacetListResponse(BaseModel): data: list[str] meta: PageMeta links: PageLinks + + +class ListMeta(BaseModel): + """An entity list can afford the COUNT(*) a facet cannot, so it reports a real total.""" + + page: int + page_size: int + total_count: int + total_pages: int + + +class ListLinks(BaseModel): + """Hypermedia for an entity list. `first`/`last` exist here because `total_pages` is known.""" + + model_config = ConfigDict(populate_by_name=True) + + self_link: str = Field(alias="self") + first: str + prev: str | None = None + next: str | None = None + last: str + + +class ListResponse(BaseModel): + """One page of an entity collection. Rows are flat: no `{type, id, attributes}` wrapper.""" + + data: tuple[Mapping[str, JsonValue], ...] + meta: ListMeta + links: ListLinks From f0866d0446a76ee84bda688b93881f95d3ade9a8 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 30 Jul 2026 19:38:32 -0700 Subject: [PATCH 05/37] feat(proxy): add GET /management/v1/budgets The Budgets page reads /budget/list, which returns the whole table as a bare array with no way to page, sort or filter it. A customer with enough budgets to fill the page has no way to find one. Registers LiteLLM_BudgetTable against the management/v1 list contract: sortable on budget_id, max_budget, tpm_limit, rpm_limit and created_at, default order newest-first with budget_id breaking ties, search on budget_id, and filters for budget_duration, max_budget and created_at. budget_duration is deliberately not sortable; the column holds "7d"/"30d" strings, so a lexicographic ORDER BY puts "30d" ahead of "7d". tpm_limit and rpm_limit are BigInt? in Prisma, so rows validate through a pydantic model on the way out and serialize as JSON numbers. A caller without admin view is refused 403 as a problem document rather than served an empty page. /budget/list is untouched. --- litellm/proxy/_types.py | 1 + .../management_v1/__init__.py | 4 + .../management_v1/budgets.py | 195 ++++++++ tests/e2e/coverage_registry/mgmt.yaml | 2 + .../test_budget_customer_user_org_e2e.py | 166 +++++- .../auth/test_admin_viewer_handler_access.py | 9 + .../proxy/auth/test_route_checks.py | 1 + .../management_v1/test_budgets.py | 471 ++++++++++++++++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 100 ++++ 9 files changed, 946 insertions(+), 3 deletions(-) create mode 100644 litellm/proxy/management_endpoints/management_v1/budgets.py create mode 100644 tests/test_litellm/proxy/management_endpoints/management_v1/test_budgets.py diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 9e98cb46b9a..9f3b32328c5 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -840,6 +840,7 @@ class LiteLLMRoutes(enum.Enum): "/config/list", "/config/field/info", "/budget/list", + "/management/v1/budgets", "/budget/settings", # Invitation viewing (admin viewer cannot create/delete; can read). "/invitation/info", diff --git a/litellm/proxy/management_endpoints/management_v1/__init__.py b/litellm/proxy/management_endpoints/management_v1/__init__.py index 257de66130b..a06c6b2591c 100644 --- a/litellm/proxy/management_endpoints/management_v1/__init__.py +++ b/litellm/proxy/management_endpoints/management_v1/__init__.py @@ -2,11 +2,15 @@ from fastapi import APIRouter +from litellm.proxy.management_endpoints.management_v1.budgets import ( + router as budgets_router, +) from litellm.proxy.management_endpoints.management_v1.spend_logs import ( router as spend_logs_router, ) router = APIRouter() +router.include_router(budgets_router) router.include_router(spend_logs_router) __all__ = ["router"] diff --git a/litellm/proxy/management_endpoints/management_v1/budgets.py b/litellm/proxy/management_endpoints/management_v1/budgets.py new file mode 100644 index 00000000000..79c3876c7ab --- /dev/null +++ b/litellm/proxy/management_endpoints/management_v1/budgets.py @@ -0,0 +1,195 @@ +"""`GET /management/v1/budgets`.""" + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime +from typing import Annotated + +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter + +from litellm._logging import verbose_proxy_logger +from litellm.proxy._types import ( + CommonProxyErrors, + UserAPIKeyAuth, + user_api_key_has_admin_view, +) +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.management_endpoints.management_v1.common import ( + MANAGEMENT_V1_PREFIX, + PROBLEM_TYPE_BASE, + ManagementProblem, +) +from litellm.proxy.management_endpoints.management_v1.list_framework import ( + FilterSpec, + ListSpec, + OrderBy, + Scope, + ScopeAll, + ScopeDenied, + SortKey, + Where, + handle_list, +) +from litellm.proxy.utils import PrismaClient +from litellm.types.proxy.management_endpoints.management_v1 import ( + ListResponse, + ProblemDetail, +) + +router = APIRouter(prefix=MANAGEMENT_V1_PREFIX) + + +class BudgetRow(BaseModel): + """The `LiteLLM_BudgetTable` columns this list serves. + + Validating the untyped Prisma row through here is what makes `tpm_limit` / + `rpm_limit` ints: they are `BigInt?` in the schema, which the query engine can + hand back as a decimal string. + """ + + model_config = ConfigDict(from_attributes=True) + + budget_id: str + max_budget: float | None = None + soft_budget: float | None = None + tpm_limit: int | None = None + rpm_limit: int | None = None + budget_duration: str | None = None + budget_reset_at: datetime | None = None + created_at: datetime + updated_at: datetime + + +_BUDGET_ROWS = TypeAdapter(tuple[BudgetRow, ...]) + + +@dataclass(frozen=True, slots=True) +class PrismaBudgetListExecutor: + """The `ListExecutor` half of the budgets list: everything Prisma-shaped lives here.""" + + prisma_client: PrismaClient + + async def count(self, where: Where) -> int: + return int(await self.prisma_client.db.litellm_budgettable.count(where=dict(where))) + + async def find_many(self, where: Where, order: OrderBy, skip: int, take: int) -> Sequence[BudgetRow]: + rows = await self.prisma_client.db.litellm_budgettable.find_many( + where=dict(where), order=list(order), skip=skip, take=take + ) + return _BUDGET_ROWS.validate_python(rows) + + +def _iso(value: datetime | None) -> str | None: + return value.isoformat() if value is not None else None + + +def _serialize(row: BudgetRow) -> Mapping[str, JsonValue]: + return { + "budget_id": row.budget_id, + "max_budget": row.max_budget, + "soft_budget": row.soft_budget, + "tpm_limit": row.tpm_limit, + "rpm_limit": row.rpm_limit, + "budget_duration": row.budget_duration, + "budget_reset_at": _iso(row.budget_reset_at), + "created_at": _iso(row.created_at), + "updated_at": _iso(row.updated_at), + } + + +def _scope(caller: UserAPIKeyAuth) -> Scope: + if user_api_key_has_admin_view(caller): + return ScopeAll() + return ScopeDenied( + detail="Only proxy admins can list budgets, your role={}".format(caller.user_role), + ) + + +# budget_duration is deliberately absent from `sortable`: the column holds strings +# like "7d" and "30d", so a lexicographic ORDER BY puts "30d" ahead of "7d". +BUDGETS_LIST_SPEC: ListSpec[BudgetRow] = ListSpec( + resource="budgets", + sortable=frozenset({"budget_id", "max_budget", "tpm_limit", "rpm_limit", "created_at"}), + searchable=frozenset({"budget_id"}), + filters={ + "budget_duration": FilterSpec(type="string", ops=frozenset({"in", "is_null"})), + "max_budget": FilterSpec(type="number", ops=frozenset({"gte", "lte", "is_null"})), + "created_at": FilterSpec(type="datetime", ops=frozenset({"gte", "lte"})), + }, + default_sort=(SortKey(field="created_at", descending=True), SortKey(field="budget_id", descending=False)), + default_page_size=50, + max_page_size=100, + scope=_scope, + serialize=_serialize, + tiebreaker="budget_id", +) + + +@router.get( + "/budgets", + tags=["budget management"], + dependencies=[Depends(user_api_key_auth)], + response_model=ListResponse, +) +async def list_budgets( + request: Request, + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], +) -> ListResponse: + """ + The budgets defined on this proxy, paged, sortable and filterable, for the + Budgets page. + + Readable by a proxy admin or an admin viewer; anyone else is refused 403. The + older `/budget/list` answers with the whole table as a bare array and has no + way to page, sort or filter it. + + `sort` takes a comma-separated list of `budget_id`, `max_budget`, `tpm_limit`, + `rpm_limit` or `created_at`, each optionally prefixed with `-` for descending, + and defaults to `-created_at,budget_id`. `q` is a case-insensitive substring + match on `budget_id`. `page_size` defaults to 50 and is capped at 100. + Filters are `filter[budget_duration][in|is_null]`, + `filter[max_budget][gte|lte|is_null]` and `filter[created_at][gte|lte]`. + + Example curl: + ``` + curl --location --globoff 'http://0.0.0.0:4000/management/v1/budgets?sort=-max_budget&filter[budget_duration][in]=7d,30d&page_size=25' \ + --header 'Authorization: Bearer sk-1234' + ``` + """ + try: + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + raise ManagementProblem( + ProblemDetail( + type=f"{PROBLEM_TYPE_BASE}database-not-connected", + title="Database not connected", + status=503, + detail=CommonProxyErrors.db_not_connected_error.value, + ) + ) + + return await handle_list( + request=request, + spec=BUDGETS_LIST_SPEC, + executor=PrismaBudgetListExecutor(prisma_client=prisma_client), + caller=user_api_key_dict, + ) + + except ManagementProblem: + raise + except Exception as e: # noqa: BLE001 # a driver error answers as a problem document, not the OpenAI error shape + verbose_proxy_logger.exception( + "litellm.proxy.management_endpoints.management_v1.budgets.list_budgets(): Exception occured - {}".format( + str(e) + ) + ) + raise ManagementProblem( + ProblemDetail( + type=f"{PROBLEM_TYPE_BASE}internal-server-error", + title="Internal server error", + status=500, + detail="Failed to list budgets.", + ) + ) diff --git a/tests/e2e/coverage_registry/mgmt.yaml b/tests/e2e/coverage_registry/mgmt.yaml index 68c5ef6b31d..2a0fc5c9f29 100644 --- a/tests/e2e/coverage_registry/mgmt.yaml +++ b/tests/e2e/coverage_registry/mgmt.yaml @@ -57,6 +57,8 @@ - {id: mgmt.budget.update.persists, module: mgmt, tier: P1, surface: api, assertions: [persists], source: "budget_management_endpoints.py:155", rationale: "Limit changes apply"} - {id: mgmt.budget.delete.persists, module: mgmt, tier: P1, surface: api, assertions: [persists], source: "budget_management_endpoints.py:280", rationale: "Clears limits"} - {id: mgmt.budget.list.happy_path, module: mgmt, tier: P1, surface: api, assertions: [happy_path], source: "budget_management_endpoints.py:215", rationale: "Budget enumeration"} +- {id: mgmt.budget.list_v1.happy_path, module: mgmt, tier: P1, surface: api, assertions: [happy_path], source: "management_v1/budgets.py:129", rationale: "Budget enumeration the Budgets page can page, sort and filter"} +- {id: mgmt.budget.list_v1.admin_only, module: mgmt, tier: P1, surface: api, assertions: [admin_only], source: "management_v1/budgets.py:129", rationale: "A caller without admin view is refused, not served an empty page"} - {id: mgmt.callback.list.happy_path, module: mgmt, tier: P2, surface: api, assertions: [happy_path], source: "callback_management_endpoints.py", rationale: "Callback config (smoke)"} - {id: mgmt.cache_settings.update.happy_path, module: mgmt, tier: P2, surface: api, assertions: [happy_path], source: "cache_settings_endpoints.py", rationale: "Cache config (smoke). Deliberately uncovered: the previous test read the live settings and wrote them back, which proves nothing (identical values in, so a no-op POST still passes) while being able to break the deployment. /cache/settings persists what it receives and that row outranks YAML cache_params, re-applied on a timer, so a write that omits ssl or redis_startup_nodes turns a TLS cluster into a plaintext standalone node and every later Redis call hangs. That took out 60 of 72 tests on 2026-07-25. GET cannot round-trip it either: it resolves the stored row overlaid with REDIS_* env and never reads YAML, so on a fresh deploy it cannot see YAML ssl to echo back. A safe test needs an isolated proxy, or LIT-4816 fixed so a partial write cannot downgrade transport. Do not re-add a read-then-write-back test against a shared proxy."} - {id: mgmt.cost_tracking.estimate.happy_path, module: mgmt, tier: P2, surface: api, assertions: [happy_path], source: "cost_tracking_settings.py", rationale: "Cost estimate (smoke)"} diff --git a/tests/e2e/management/test_budget_customer_user_org_e2e.py b/tests/e2e/management/test_budget_customer_user_org_e2e.py index 54cc18b228b..12372bb7cc1 100644 --- a/tests/e2e/management/test_budget_customer_user_org_e2e.py +++ b/tests/e2e/management/test_budget_customer_user_org_e2e.py @@ -19,10 +19,10 @@ import time from collections.abc import Callable import pytest -from pydantic import BaseModel, RootModel +from pydantic import BaseModel, Field, RootModel from e2e_config import unique_marker -from e2e_http import NoBody, unwrap +from e2e_http import NoBody, Success, UnauthorizedError, UnknownApiError, unwrap from lifecycle import ResourceManager from management_client import ManagementClient from models import KeyGenerateBody, OrgInfoParams, OrgNewBody, UserNewBody @@ -44,9 +44,11 @@ def _poll[T](client: ManagementClient, attempt: Callable[[], T | None], failure: class BudgetNewBody(BaseModel): - max_budget: float + max_budget: float | None = None soft_budget: float | None = None budget_duration: str | None = None + budget_id: str | None = None + tpm_limit: int | None = None class BudgetNewResponse(BaseModel): @@ -204,6 +206,164 @@ class TestBudgetManagement: ) +# ---------- /management/v1/budgets ---------- + +_BUDGETS_V1 = "/management/v1/budgets" + + +class BudgetPageParams(BaseModel): + """Query for GET /management/v1/budgets. The filter fields serialize to the + bracketed keys the route reads them under, so nothing here is a raw dict.""" + + q: str | None = None + sort: str | None = None + page: int | None = None + page_size: int | None = None + duration_in: str | None = Field(default=None, serialization_alias="filter[budget_duration][in]") + max_budget_is_null: bool | None = Field(default=None, serialization_alias="filter[max_budget][is_null]") + not_a_parameter: str | None = Field(default=None, serialization_alias="filter[budget_id][eq]") + + +class BudgetPageMeta(BaseModel): + page: int + page_size: int + total_count: int + total_pages: int + + +class BudgetPageLinks(BaseModel): + first: str + prev: str | None = None + next: str | None = None + last: str + + +class BudgetPageRow(BaseModel): + budget_id: str + max_budget: float | None = None + tpm_limit: int | None = None + budget_duration: str | None = None + + +class BudgetPageResponse(BaseModel): + data: list[BudgetPageRow] + meta: BudgetPageMeta + links: BudgetPageLinks + + +def _list_budgets(client: ManagementClient, params: BudgetPageParams) -> BudgetPageResponse: + return unwrap( + client.proxy.transport.get( + _BUDGETS_V1, + headers=client.proxy.transport.master, + params=params, + response_type=BudgetPageResponse, + ) + ) + + +def _list_budget_ids(client: ManagementClient, params: BudgetPageParams) -> tuple[str, ...]: + return tuple(row.budget_id for row in _list_budgets(client, params).data) + + +def _list_status(client: ManagementClient, params: BudgetPageParams, key: str | None = None) -> int: + headers = client.proxy.transport.master if key is None else client.proxy.transport.bearer(key) + outcome = client.proxy.transport.get( + _BUDGETS_V1, headers=headers, params=params, response_type=BudgetPageResponse + ) + match outcome: + case Success(status_code=status_code): + return status_code + case UnauthorizedError(): + return 401 + case UnknownApiError(status_code=status_code): + return status_code + case _: + raise AssertionError(outcome) + + +class TestBudgetListV1: + """The paged, sorted, filtered budget list the Budgets page reads. + + Every test tags its own budgets with a marker in the budget_id and searches on + it, so budgets left behind by other suites cannot move the assertions. + """ + + @pytest.mark.covers("mgmt.budget.list_v1.happy_path") + def test_sorts_pages_and_filters_the_budgets_it_created( + self, client: ManagementClient, resources: ResourceManager + ) -> None: + marker = unique_marker() + small, medium, large = (f"{marker}-small", f"{marker}-medium", f"{marker}-large") + for budget_id, max_budget, duration in ( + (small, 1.0, "7d"), + (medium, 2.0, "30d"), + (large, 3.0, "30d"), + ): + _create_budget( + client, + resources, + BudgetNewBody( + budget_id=budget_id, max_budget=max_budget, budget_duration=duration, tpm_limit=60000 + ), + ) + + mine = BudgetPageParams(q=marker, sort="-max_budget") + _ = _poll( + client, + lambda: mine if len(_list_budget_ids(client, mine)) == 3 else None, + f"{_BUDGETS_V1} never listed all three budgets tagged {marker}", + ) + + assert _list_budget_ids(client, mine) == (large, medium, small) + + page_two = _list_budgets(client, BudgetPageParams(q=marker, sort="-max_budget", page=2, page_size=1)) + assert [row.budget_id for row in page_two.data] == [medium] + assert page_two.meta.total_count == 3 + assert page_two.meta.total_pages == 3 + assert page_two.meta.page_size == 1 + assert page_two.links.prev is not None and page_two.links.next is not None + + assert set(_list_budget_ids(client, BudgetPageParams(q=marker, duration_in="30d"))) == {medium, large} + + limits = _list_budgets(client, BudgetPageParams(q=marker, sort="budget_id")).data + assert [row.tpm_limit for row in limits] == [60000, 60000, 60000] + + @pytest.mark.covers("mgmt.budget.list_v1.happy_path") + def test_is_null_finds_the_budget_left_uncapped( + self, client: ManagementClient, resources: ResourceManager + ) -> None: + marker = unique_marker() + uncapped = _create_budget(client, resources, BudgetNewBody(budget_id=f"{marker}-uncapped")) + _ = _create_budget(client, resources, BudgetNewBody(budget_id=f"{marker}-capped", max_budget=4.0)) + + params = BudgetPageParams(q=marker, max_budget_is_null=True) + found = _poll( + client, + lambda: params if _list_budget_ids(client, params) == (uncapped,) else None, + f"{_BUDGETS_V1} never isolated the uncapped budget {uncapped}", + ) + + assert [row.max_budget for row in _list_budgets(client, found).data] == [None] + + @pytest.mark.covers("mgmt.budget.list_v1.happy_path") + def test_refuses_a_sort_field_and_a_parameter_it_does_not_support(self, client: ManagementClient) -> None: + assert _list_status(client, BudgetPageParams(sort="budget_duration")) == 400 + assert _list_status(client, BudgetPageParams(not_a_parameter="b-1")) == 400 + + @pytest.mark.covers("mgmt.budget.list_v1.admin_only") + def test_is_refused_for_a_non_admin_key(self, client: ManagementClient, resources: ResourceManager) -> None: + key = client.proxy.generate_key(KeyGenerateBody()) + resources.defer(lambda: client.proxy.delete_key(key)) + + status = _list_status(client, BudgetPageParams(), key=key) + + assert status in (401, 403), ( + f"a non-admin key listing budgets must be refused 401/403, got {status}. Serving 200 with an " + f"empty page would read as 'this proxy has no budgets'" + ) + + # ---------- customer / end-user ---------- diff --git a/tests/test_litellm/proxy/auth/test_admin_viewer_handler_access.py b/tests/test_litellm/proxy/auth/test_admin_viewer_handler_access.py index b0d2595e48c..9f4a801eb83 100644 --- a/tests/test_litellm/proxy/auth/test_admin_viewer_handler_access.py +++ b/tests/test_litellm/proxy/auth/test_admin_viewer_handler_access.py @@ -51,6 +51,7 @@ def admin_viewer_client(monkeypatch): mock_budget_table = MagicMock() mock_budget_table.find_many = AsyncMock(return_value=[]) mock_budget_table.find_first = AsyncMock(return_value=None) + mock_budget_table.count = AsyncMock(return_value=0) mock_invitation_table = MagicMock() mock_invitation_table.find_unique = AsyncMock(return_value=None) @@ -106,6 +107,14 @@ def test_budget_list_allows_admin_viewer(admin_viewer_client): assert resp.status_code == 200, resp.text +def test_management_v1_budgets_allows_admin_viewer(admin_viewer_client): + """`/management/v1/budgets` is the paged/sortable budget list; same read tier as + `/budget/list`, and it answers 403 rather than an empty page when it refuses.""" + resp = admin_viewer_client.get("/management/v1/budgets") + _assert_not_role_blocked(resp) + assert resp.status_code == 200, resp.text + + def test_budget_settings_allows_admin_viewer(admin_viewer_client): """`/budget/settings` describes a budget's fields; read-only.""" resp = admin_viewer_client.get("/budget/settings", params={"budget_id": "b1"}) diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index a6d4dc63697..06764139eda 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -1960,6 +1960,7 @@ ADMIN_VIEWER_SETTINGS_ROUTES = [ "/config/field/info", # Budgets page "/budget/list", + "/management/v1/budgets", "/budget/settings", # Invitation viewing (admin viewer cannot create/delete; can read) "/invitation/info", diff --git a/tests/test_litellm/proxy/management_endpoints/management_v1/test_budgets.py b/tests/test_litellm/proxy/management_endpoints/management_v1/test_budgets.py new file mode 100644 index 00000000000..500c072bc7d --- /dev/null +++ b/tests/test_litellm/proxy/management_endpoints/management_v1/test_budgets.py @@ -0,0 +1,471 @@ +from datetime import datetime, timezone +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.testclient import TestClient + +from litellm.proxy._types import LiteLLMRoutes, LitellmUserRoles +from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth, user_api_key_auth +from litellm.proxy.management_endpoints.management_v1 import router +from litellm.proxy.management_endpoints.management_v1.budgets import BUDGETS_LIST_SPEC +from litellm.proxy.management_endpoints.management_v1.common import ( + MANAGEMENT_V1_PREFIX, + PROBLEM_TYPE_BASE, + ManagementProblem, + problem_response, +) +from litellm.proxy.management_endpoints.management_v1.list_framework import ( + ScopeWhere, + build_query_plan, +) +from litellm.types.proxy.management_endpoints.management_v1 import ProblemDetail + +app = FastAPI() + + +@app.exception_handler(ManagementProblem) +async def management_problem_exception_handler(request: Request, exc: ManagementProblem): + return problem_response(exc.problem) + + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request: Request, exc: RequestValidationError): + return problem_response( + ProblemDetail( + type=f"{PROBLEM_TYPE_BASE}invalid-query-parameter", + title="Invalid query parameter", + status=400, + detail="The request query parameters are invalid.", + ) + ) + + +app.include_router(router) +client = TestClient(app) + +BUDGETS_PATH = f"{MANAGEMENT_V1_PREFIX}/budgets" +SORTABLE = ["budget_id", "created_at", "max_budget", "rpm_limit", "tpm_limit"] + + +def _row(budget_id: str, **overrides: Any) -> dict[str, Any]: + return { + "budget_id": budget_id, + "max_budget": 10.0, + "soft_budget": None, + "tpm_limit": None, + "rpm_limit": None, + "budget_duration": "30d", + "budget_reset_at": None, + "created_at": datetime(2026, 7, 20, 12, 0, tzinfo=timezone.utc), + "updated_at": datetime(2026, 7, 21, 12, 0, tzinfo=timezone.utc), + **overrides, + } + + +@pytest.fixture +def budget_table(monkeypatch): + table = MagicMock() + table.count = AsyncMock(return_value=0) + table.find_many = AsyncMock(return_value=[]) + prisma_client = MagicMock() + prisma_client.db.litellm_budgettable = table + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", prisma_client) + return table + + +@pytest.fixture +def as_proxy_admin(): + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( + user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN + ) + yield + app.dependency_overrides.clear() + + +def _serve(budget_table, rows: list[dict[str, Any]], total: int | None = None) -> None: + budget_table.find_many = AsyncMock(return_value=rows) + budget_table.count = AsyncMock(return_value=len(rows) if total is None else total) + + +def _as_role(role: LitellmUserRoles): + original = app.dependency_overrides.copy() + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(user_id="u", user_role=role) + return original + + +def _get(query: str = ""): + suffix = f"?{query}" if query else "" + return client.get(f"{BUDGETS_PATH}{suffix}", headers={"Authorization": "Bearer k"}) + + +def test_returns_flat_rows_in_the_control_plane_envelope(budget_table, as_proxy_admin): + """`{data, meta, links}` with flat rows; no JSON:API `{type, id, attributes}` wrapper.""" + _serve(budget_table, [_row("b-1")]) + + response = _get() + + assert response.status_code == 200 + body = response.json() + assert set(body) == {"data", "meta", "links"} + assert body["data"][0]["budget_id"] == "b-1" + assert "attributes" not in body["data"][0] + + +def test_serves_the_columns_the_budgets_page_renders(budget_table, as_proxy_admin): + _serve(budget_table, [_row("b-1", soft_budget=5.0, budget_reset_at=datetime(2026, 8, 1, tzinfo=timezone.utc))]) + + row = _get().json()["data"][0] + + assert set(row) == { + "budget_id", + "max_budget", + "soft_budget", + "tpm_limit", + "rpm_limit", + "budget_duration", + "budget_reset_at", + "created_at", + "updated_at", + } + assert row["soft_budget"] == 5.0 + assert row["budget_reset_at"].startswith("2026-08-01T00:00:00") + + +def test_defaults_to_newest_first_with_budget_id_breaking_ties(budget_table, as_proxy_admin): + """Two budgets created in the same transaction share a created_at; without the + tiebreaker their relative order is undefined and pages can repeat or drop rows.""" + _serve(budget_table, []) + + _get() + + assert budget_table.find_many.call_args.kwargs["order"] == [ + {"created_at": "desc"}, + {"budget_id": "asc"}, + ] + + +def test_appends_the_tiebreaker_to_an_explicit_sort(budget_table, as_proxy_admin): + _serve(budget_table, []) + + _get("sort=-max_budget") + + assert budget_table.find_many.call_args.kwargs["order"] == [ + {"max_budget": "desc"}, + {"budget_id": "asc"}, + ] + + +def test_does_not_duplicate_the_tiebreaker_when_it_is_sorted_on(budget_table, as_proxy_admin): + _serve(budget_table, []) + + _get("sort=-budget_id") + + assert budget_table.find_many.call_args.kwargs["order"] == [{"budget_id": "desc"}] + + +def test_refuses_to_sort_on_budget_duration(budget_table, as_proxy_admin): + """The column holds "7d"/"30d", so a lexicographic ORDER BY would put "30d" + before "7d" and silently mis-order the page.""" + _serve(budget_table, []) + + response = _get("sort=budget_duration") + + assert response.status_code == 400 + assert response.headers["content-type"].startswith("application/problem+json") + body = response.json() + assert "budget_duration" in body["detail"] + assert body["allowed"] == SORTABLE + budget_table.find_many.assert_not_called() + + +def test_the_advertised_sort_fields_are_the_ones_that_work(budget_table, as_proxy_admin): + """Guards the rejection above against drifting from what the spec actually accepts.""" + _serve(budget_table, []) + + for field in SORTABLE: + assert _get(f"sort={field}").status_code == 200, field + assert sorted(BUDGETS_LIST_SPEC.sortable) == SORTABLE + + +def test_rejects_an_unknown_query_parameter(budget_table, as_proxy_admin): + """A silently ignored filter over-returns budgets, which is worse than a rejected request.""" + _serve(budget_table, []) + + response = _get("filtre[max_budget][gte]=5") + + assert response.status_code == 400 + assert response.headers["content-type"].startswith("application/problem+json") + body = response.json() + assert "filtre[max_budget][gte]" in body["detail"] + assert "filter[max_budget][gte]" in body["allowed"] + budget_table.count.assert_not_called() + budget_table.find_many.assert_not_called() + + +def test_rejects_an_operator_the_filter_does_not_declare(budget_table, as_proxy_admin): + """`max_budget` takes ranges, not `in`; accepting an undeclared operator is how a + filter starts meaning something the query planner never checked.""" + _serve(budget_table, []) + + assert _get("filter[max_budget][in]=5,10").status_code == 400 + assert _get("filter[created_at][is_null]=true").status_code == 400 + + +def test_omitted_page_size_serves_fifty(budget_table, as_proxy_admin): + _serve(budget_table, []) + + body = _get().json() + + assert body["meta"]["page_size"] == 50 + assert budget_table.find_many.call_args.kwargs["take"] == 50 + + +def test_clamps_an_oversized_page_size_to_a_hundred(budget_table, as_proxy_admin): + """Unclamped, one request can ask the proxy to serialize the whole budget table.""" + _serve(budget_table, []) + + body = _get("page_size=500").json() + + assert body["meta"]["page_size"] == 100 + assert budget_table.find_many.call_args.kwargs["take"] == 100 + + +def test_offsets_by_page(budget_table, as_proxy_admin): + _serve(budget_table, []) + + _get("page=3&page_size=25") + + assert budget_table.find_many.call_args.kwargs["skip"] == 50 + assert budget_table.find_many.call_args.kwargs["take"] == 25 + + +@pytest.mark.parametrize( + "role", + [ + LitellmUserRoles.INTERNAL_USER, + LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, + LitellmUserRoles.TEAM, + ], +) +def test_refuses_a_caller_without_admin_view(budget_table, role): + """Budgets are proxy-wide, so a caller who cannot read all of them must be told + so. Answering 200 with an empty list would read as "there are no budgets".""" + _serve(budget_table, [_row("b-1")]) + original = _as_role(role) + try: + response = _get() + finally: + app.dependency_overrides = original + + assert response.status_code == 403 + assert response.headers["content-type"].startswith("application/problem+json") + assert response.json()["status"] == 403 + budget_table.count.assert_not_called() + budget_table.find_many.assert_not_called() + + +@pytest.mark.parametrize("role", [LitellmUserRoles.PROXY_ADMIN, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY]) +def test_admins_and_admin_viewers_may_read_every_budget(budget_table, role): + _serve(budget_table, [_row("b-1")]) + original = _as_role(role) + try: + response = _get() + finally: + app.dependency_overrides = original + + assert response.status_code == 200 + assert [row["budget_id"] for row in response.json()["data"]] == ["b-1"] + + +def test_a_denied_caller_stays_denied_whatever_they_filter_on(budget_table): + """The scope decision reads the caller, never the query string.""" + _serve(budget_table, [_row("b-1")]) + original = _as_role(LitellmUserRoles.INTERNAL_USER) + try: + response = _get("filter[max_budget][gte]=0&q=b-") + finally: + app.dependency_overrides = original + + assert response.status_code == 403 + + +def test_a_filter_narrows_the_scope_predicate_instead_of_replacing_it(budget_table, as_proxy_admin): + """A filter is ANDed in. Assigning it over the scope clause is what would let a + caller widen their own read.""" + _serve(budget_table, []) + + _get("filter[max_budget][gte]=5") + + where = budget_table.find_many.call_args.kwargs["where"] + assert {"max_budget": {"gte": 5.0}} in where["AND"] + + +def test_a_scoped_caller_keeps_their_scope_clause_alongside_their_filter(): + """Same spec, driven through the planner with a row-scoped caller: the scope + clause has to survive next to whatever the caller filtered on.""" + request = Request( + { + "type": "http", + "method": "GET", + "path": BUDGETS_PATH, + "headers": [], + "query_string": b"filter[max_budget][gte]=5", + } + ) + + plan = build_query_plan(request, BUDGETS_LIST_SPEC, ScopeWhere(where={"budget_id": {"in": ("b-1",)}})) + + assert {"budget_id": {"in": ("b-1",)}} in plan.where["AND"] + assert {"max_budget": {"gte": 5.0}} in plan.where["AND"] + + +def test_q_matches_budget_id_case_insensitively(budget_table, as_proxy_admin): + """budget_id is the only text identity on the row; matching anything else would + return budgets whose ids do not contain what the user typed.""" + _serve(budget_table, []) + + _get("q=Prod") + + where = budget_table.find_many.call_args.kwargs["where"] + assert {"OR": ({"budget_id": {"contains": "Prod", "mode": "insensitive"}},)} in where["AND"] + + +def test_q_does_not_search_any_other_column(budget_table, as_proxy_admin): + _serve(budget_table, []) + + _get("q=30d") + + searched = budget_table.find_many.call_args.kwargs["where"]["AND"][0]["OR"] + assert [next(iter(clause)) for clause in searched] == ["budget_id"] + assert BUDGETS_LIST_SPEC.searchable == frozenset({"budget_id"}) + + +def test_is_null_selects_the_unlimited_budgets(budget_table, as_proxy_admin): + """"Unlimited" is max_budget IS NULL; `max_budget = 0` would be a hard zero cap.""" + _serve(budget_table, [_row("b-unlimited", max_budget=None)]) + + body = _get("filter[max_budget][is_null]=true").json() + + assert {"max_budget": None} in budget_table.find_many.call_args.kwargs["where"]["AND"] + assert body["data"][0]["max_budget"] is None + + +def test_is_null_false_selects_the_capped_budgets(budget_table, as_proxy_admin): + _serve(budget_table, []) + + _get("filter[max_budget][is_null]=false") + + assert {"max_budget": {"not": None}} in budget_table.find_many.call_args.kwargs["where"]["AND"] + + +def test_in_filter_splits_the_requested_durations(budget_table, as_proxy_admin): + _serve(budget_table, []) + + _get("filter[budget_duration][in]=7d,30d") + + assert {"budget_duration": {"in": ("7d", "30d")}} in budget_table.find_many.call_args.kwargs["where"]["AND"] + + +def test_created_at_range_is_read_as_a_timestamp(budget_table, as_proxy_admin): + _serve(budget_table, []) + + _get("filter[created_at][gte]=2026-07-01T00:00:00%2B00:00") + + assert { + "created_at": {"gte": datetime(2026, 7, 1, tzinfo=timezone.utc)} + } in budget_table.find_many.call_args.kwargs["where"]["AND"] + + +def test_rejects_a_filter_value_that_is_not_of_the_declared_type(budget_table, as_proxy_admin): + _serve(budget_table, []) + + assert _get("filter[max_budget][gte]=lots").status_code == 400 + assert _get("filter[created_at][gte]=yesterday").status_code == 400 + + +def test_reports_the_total_and_links_every_page_on_a_middle_page(budget_table, as_proxy_admin): + """The Budgets page renders a page count, so the total has to be the match total, + not the length of the page it just received.""" + _serve(budget_table, [_row("b-3"), _row("b-4")], total=7) + + body = _get("page=2&page_size=2").json() + + assert body["meta"] == {"page": 2, "page_size": 2, "total_count": 7, "total_pages": 4} + links = body["links"] + assert "page=1" in links["first"] and "page_size=2" in links["first"] + assert "page=1" in links["prev"] + assert "page=3" in links["next"] + assert "page=4" in links["last"] + assert "page=2" in links["self"] + + +def test_the_last_page_has_no_next(budget_table, as_proxy_admin): + _serve(budget_table, [_row("b-5")], total=5) + + links = _get("page=3&page_size=2").json()["links"] + + assert links["next"] is None + assert "page=2" in links["prev"] + + +def test_the_first_page_has_no_prev(budget_table, as_proxy_admin): + _serve(budget_table, [_row("b-1")], total=5) + + links = _get("page_size=2").json()["links"] + + assert links["prev"] is None + assert "page=2" in links["next"] + + +def test_an_empty_table_still_links_a_first_and_last_page(budget_table, as_proxy_admin): + _serve(budget_table, [], total=0) + + body = _get().json() + + assert body["meta"]["total_count"] == 0 + assert body["meta"]["total_pages"] == 0 + assert "page=1" in body["links"]["first"] and "page=1" in body["links"]["last"] + + +def test_counts_over_the_same_predicate_it_pages(budget_table, as_proxy_admin): + """A total counted without the caller's filter would page through rows the + filter excluded.""" + _serve(budget_table, [], total=0) + + _get("filter[budget_duration][in]=30d") + + assert budget_table.count.call_args.kwargs["where"] == budget_table.find_many.call_args.kwargs["where"] + + +def test_bigint_limits_serialize_as_json_numbers(budget_table, as_proxy_admin): + """tpm_limit/rpm_limit are BigInt? in Prisma; the query engine can hand them back + as decimal strings, and a quoted "60000" breaks arithmetic in the dashboard.""" + _serve(budget_table, [_row("b-1", tpm_limit="60000", rpm_limit=1200)]) + + row = _get().json()["data"][0] + + assert row["tpm_limit"] == 60000 + assert row["rpm_limit"] == 1200 + assert isinstance(row["tpm_limit"], int) and not isinstance(row["tpm_limit"], bool) + assert '"tpm_limit": "60000"' not in _get().text + + +def test_reports_a_missing_database_as_a_problem_document(monkeypatch, as_proxy_admin): + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) + + response = _get() + + assert response.status_code == 503 + assert response.headers["content-type"].startswith("application/problem+json") + + +def test_is_reachable_by_the_roles_that_can_open_the_budgets_page(): + """Route-level auth gate, which the dependency_overrides above bypass. The handler's + admin-view check is dead code if RouteChecks rejects the role first.""" + assert BUDGETS_PATH in LiteLLMRoutes.admin_viewer_routes.value + assert ("/budget/list" in LiteLLMRoutes.admin_viewer_routes.value) == ( + BUDGETS_PATH in LiteLLMRoutes.admin_viewer_routes.value + ) diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 380b6545da8..752b762e773 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -7169,6 +7169,43 @@ export interface paths { patch?: never; trace?: never; }; + "/management/v1/budgets": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Budgets + * @description The budgets defined on this proxy, paged, sortable and filterable, for the + * Budgets page. + * + * Readable by a proxy admin or an admin viewer; anyone else is refused 403. The + * older `/budget/list` answers with the whole table as a bare array and has no + * way to page, sort or filter it. + * + * `sort` takes a comma-separated list of `budget_id`, `max_budget`, `tpm_limit`, + * `rpm_limit` or `created_at`, each optionally prefixed with `-` for descending, + * and defaults to `-created_at,budget_id`. `q` is a case-insensitive substring + * match on `budget_id`. `page_size` defaults to 50 and is capped at 100. + * Filters are `filter[budget_duration][in|is_null]`, + * `filter[max_budget][gte|lte|is_null]` and `filter[created_at][gte|lte]`. + * + * Example curl: + * ``` + * curl --location --globoff 'http://0.0.0.0:4000/management/v1/budgets?sort=-max_budget&filter[budget_duration][in]=7d,30d&page_size=25' --header 'Authorization: Bearer sk-1234' + * ``` + */ + get: operations["list_budgets_management_v1_budgets_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/management/v1/spend_logs/end_users": { parameters: { query?: never; @@ -24771,6 +24808,7 @@ export interface components { /** Updated By */ updated_by?: string | null; }; + JsonValue: unknown; /** KeyHealthResponse */ KeyHealthResponse: { /** @@ -24922,6 +24960,36 @@ export interface components { /** Guardrails */ guardrails: components["schemas"]["GuardrailInfoResponse"][]; }; + /** + * ListLinks + * @description Hypermedia for an entity list. `first`/`last` exist here because `total_pages` is known. + */ + ListLinks: { + /** First */ + first: string; + /** Last */ + last: string; + /** Next */ + next?: string | null; + /** Prev */ + prev?: string | null; + /** Self */ + self: string; + }; + /** + * ListMeta + * @description An entity list can afford the COUNT(*) a facet cannot, so it reports a real total. + */ + ListMeta: { + /** Page */ + page: number; + /** Page Size */ + page_size: number; + /** Total Count */ + total_count: number; + /** Total Pages */ + total_pages: number; + }; /** * ListPluginsResponse * @description Response from listing plugins. @@ -24937,6 +25005,18 @@ export interface components { /** Prompts */ prompts: components["schemas"]["PromptSpec"][]; }; + /** + * ListResponse + * @description One page of an entity collection. Rows are flat: no `{type, id, attributes}` wrapper. + */ + ListResponse: { + /** Data */ + data: { + [key: string]: components["schemas"]["JsonValue"]; + }[]; + links: components["schemas"]["ListLinks"]; + meta: components["schemas"]["ListMeta"]; + }; /** * ListRunsResponse * @description Response from listing runs @@ -43416,6 +43496,26 @@ export interface operations { }; }; }; + list_budgets_management_v1_budgets_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListResponse"]; + }; + }; + }; + }; list_spend_log_end_users_management_v1_spend_logs_end_users_get: { parameters: { query: { From 86da406f998d42980b283106de674f4b52193c9e Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:07:51 -0700 Subject: [PATCH 06/37] fix(type-discipline): exempt values frozen in place by tuple/frozenset/MappingProxyType from LIT002 --- scripts/check_type_discipline.py | 34 +++++++++++++++++-- .../test_check_type_discipline.py | 16 +++++++++ type-discipline-budget.json | 2 +- 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/scripts/check_type_discipline.py b/scripts/check_type_discipline.py index 809dc141eb8..88679f28190 100644 --- a/scripts/check_type_discipline.py +++ b/scripts/check_type_discipline.py @@ -20,7 +20,10 @@ LIT002 Mutable-collection *construction*: a list/dict/set literal or comprehens generator (`tuple(f(x) for x in xs)`), a tuple literal, or a frozen dataclass / NamedTuple / ReadOnly TypedDict. Generator expressions and `tuple`/`frozenset` calls are not construction and pass. Annotation-internal lists (`Callable[[int], - str]`) are exempt. Suppress with `# mutable-ok: `. + str]`) are exempt, as is a value passed directly to a freezing wrapper + (`tuple(...)`, `frozenset(...)`, `MappingProxyType(...)`): it is frozen before + it can escape, though anything mutable nested inside it still counts. + Suppress with `# mutable-ok: `. LIT003 noqa suppression without rule codes or without a reason. Required shape: `# noqa: TID251 # ` LIT004 pyright/mypy ignore without bracketed codes or without a reason. @@ -90,6 +93,7 @@ MUTABLE_CONSTRUCTORS = frozenset(( # are common methods (e.g. pydantic's `model.dict()`), not collection construction. A # qualified `collections.deque(...)` still counts. QUALIFIED_CONSTRUCTORS = MUTABLE_CONSTRUCTORS - frozenset(("dict", "list", "set")) +FREEZING_WRAPPERS = frozenset(("tuple", "frozenset", "MappingProxyType")) UNSAFE_GUARDS = frozenset(("TypeGuard", "TypeIs")) MIN_REASON_LEN = 3 @@ -382,6 +386,31 @@ def _annotation_node_ids(tree: ast.AST) -> frozenset[int]: ) +def _callable_name(func: ast.expr) -> str | None: + if isinstance(func, ast.Name): + return func.id + if isinstance(func, ast.Attribute): + return func.attr + return None + + +def _frozen_argument_ids(tree: ast.AST) -> frozenset[int]: + """ids() of every expression passed directly to a freezing wrapper. + + `MappingProxyType({...})`, `frozenset({...})`, and `tuple([...])` freeze their + argument before it can escape, so the literal inside is a one-shot build, not a + mutable value anyone can grow later. Only the argument itself is exempt; a + mutable collection nested inside it still trips LIT002. + """ + return frozenset( + id(node.args[0]) + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and len(node.args) == 1 + and _callable_name(node.func) in FREEZING_WRAPPERS + ) + + def _construction_kind(node: ast.expr) -> str | None: """Human label if `node` builds a mutable collection, else None.""" if isinstance(node, ast.List): @@ -407,8 +436,9 @@ def _construction_kind(node: ast.expr) -> str | None: def iter_construction_violations(path: Path, tree: ast.AST, comments: Comments) -> Iterator[Violation]: in_annotation = _annotation_node_ids(tree) + frozen_arguments = _frozen_argument_ids(tree) for node in ast.walk(tree): - if not isinstance(node, ast.expr) or id(node) in in_annotation: + if not isinstance(node, ast.expr) or id(node) in in_annotation or id(node) in frozen_arguments: continue kind = _construction_kind(node) if kind is None or node.lineno in comments.mutable_ok_lines: diff --git a/tests/test_litellm/test_check_type_discipline.py b/tests/test_litellm/test_check_type_discipline.py index 13edf6d1a95..f624eb926d1 100644 --- a/tests/test_litellm/test_check_type_discipline.py +++ b/tests/test_litellm/test_check_type_discipline.py @@ -152,6 +152,22 @@ def test_qualified_collections_constructors_still_count(tmp_path): assert "LIT002" in _codes(tmp_path, "import collections\nm = collections.defaultdict(list)\n") +def test_value_frozen_by_wrapper_is_exempt(tmp_path): + assert "LIT002" not in _codes(tmp_path, "from types import MappingProxyType\nm = MappingProxyType({'a': 1})\n") + assert "LIT002" not in _codes(tmp_path, "import types\nm = types.MappingProxyType({'a': 1})\n") + assert "LIT002" not in _codes(tmp_path, "from types import MappingProxyType\nm = MappingProxyType(dict(a=1))\n") + assert "LIT002" not in _codes(tmp_path, "f = frozenset({1, 2})\n") + assert "LIT002" not in _codes(tmp_path, "t = tuple([1, 2])\n") + + +def test_mutable_nested_inside_frozen_wrapper_still_counts(tmp_path): + assert "LIT002" in _codes(tmp_path, "from types import MappingProxyType\nm = MappingProxyType({'a': []})\n") + + +def test_unfrozen_literal_still_counts(tmp_path): + assert "LIT002" in _codes(tmp_path, "from types import MappingProxyType\nd = {'a': 1}\nm = MappingProxyType(d)\n") + + def test_mutable_ok_with_reason_suppresses_both_rules(tmp_path): codes = _codes(tmp_path, "x: dict[str, int] = {} # mutable-ok: in-place buffer mutated hot path\n") assert "LIT001" not in codes diff --git a/type-discipline-budget.json b/type-discipline-budget.json index c9a1b59cc06..2d5e4dd3a50 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -3,7 +3,7 @@ "limit": 23253 }, "LIT002": { - "limit": 27427 + "limit": 27280 }, "LIT003": { "limit": 292 From 089a4fa228db8bcf28f78db56b6a37e61f061bb6 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:26:05 -0700 Subject: [PATCH 07/37] fix(lint): restrict freezing-wrapper match to bare names and types.MappingProxyType --- scripts/check_type_discipline.py | 21 +++++++++++-------- .../test_check_type_discipline.py | 6 ++++++ 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/scripts/check_type_discipline.py b/scripts/check_type_discipline.py index 88679f28190..43a4cb66484 100644 --- a/scripts/check_type_discipline.py +++ b/scripts/check_type_discipline.py @@ -386,12 +386,15 @@ def _annotation_node_ids(tree: ast.AST) -> frozenset[int]: ) -def _callable_name(func: ast.expr) -> str | None: +def _is_freezing_wrapper(func: ast.expr) -> bool: if isinstance(func, ast.Name): - return func.id - if isinstance(func, ast.Attribute): - return func.attr - return None + return func.id in FREEZING_WRAPPERS + return ( + isinstance(func, ast.Attribute) + and func.attr == "MappingProxyType" + and isinstance(func.value, ast.Name) + and func.value.id == "types" + ) def _frozen_argument_ids(tree: ast.AST) -> frozenset[int]: @@ -400,14 +403,14 @@ def _frozen_argument_ids(tree: ast.AST) -> frozenset[int]: `MappingProxyType({...})`, `frozenset({...})`, and `tuple([...])` freeze their argument before it can escape, so the literal inside is a one-shot build, not a mutable value anyone can grow later. Only the argument itself is exempt; a - mutable collection nested inside it still trips LIT002. + mutable collection nested inside it still trips LIT002. Only bare names (plus + `types.MappingProxyType`) qualify, so an unrelated method that happens to share + a wrapper's name cannot exempt its argument. """ return frozenset( id(node.args[0]) for node in ast.walk(tree) - if isinstance(node, ast.Call) - and len(node.args) == 1 - and _callable_name(node.func) in FREEZING_WRAPPERS + if isinstance(node, ast.Call) and len(node.args) == 1 and _is_freezing_wrapper(node.func) ) diff --git a/tests/test_litellm/test_check_type_discipline.py b/tests/test_litellm/test_check_type_discipline.py index f624eb926d1..53d672fc4a8 100644 --- a/tests/test_litellm/test_check_type_discipline.py +++ b/tests/test_litellm/test_check_type_discipline.py @@ -160,6 +160,12 @@ def test_value_frozen_by_wrapper_is_exempt(tmp_path): assert "LIT002" not in _codes(tmp_path, "t = tuple([1, 2])\n") +def test_same_named_method_does_not_exempt_its_argument(tmp_path): + assert "LIT002" in _codes(tmp_path, "t = obj.tuple([1, 2])\n") + assert "LIT002" in _codes(tmp_path, "f = obj.frozenset({1, 2})\n") + assert "LIT002" in _codes(tmp_path, "m = obj.MappingProxyType({'a': 1})\n") + + def test_mutable_nested_inside_frozen_wrapper_still_counts(tmp_path): assert "LIT002" in _codes(tmp_path, "from types import MappingProxyType\nm = MappingProxyType({'a': []})\n") From ffd6ac52c5ddd1321b07761cd1f3d204ddb3cdc8 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 31 Jul 2026 00:08:20 -0700 Subject: [PATCH 08/37] fix(deps): raise aiohttp floor to 3.14.2 to clear pooled-connection timeouts aiohttp 3.14.0 and 3.14.1 re-arm the sock_read timer on a keep-alive connection after it has already been returned to the idle pool. The stray timer stamps a SocketTimeoutError on the pooled connection without closing it, so the pool keeps handing it out and the next request to pick it up fails instantly on an error left behind by an earlier, unrelated request. Because a single pool is shared across providers, the failures appear simultaneously across Vertex AI, Bedrock, Anthropic and OpenAI-compatible deployments as sub-millisecond "Connection timed out" errors. uv.lock resolved aiohttp 3.14.1 and the published images install via `uv sync --frozen`, so every image built from that lock shipped the regression. The wheel's own metadata declared `aiohttp>=3.10,<4.0`, which also left pip consumers free to resolve into the same broken window, so both the runtime floor and the uv constraint move to >=3.14.2. Upstream fixed this in aio-libs/aiohttp#12954, released in aiohttp 3.14.2; the lock now resolves 3.14.3. Raising the floor rather than capping below 3.14 keeps the advisories that the existing 3.14.1 floor cleared, so no osv-scanner ignores are needed. litellm requires Python >=3.10 and aiohttp 3.14.2 requires >=3.10, so no supported interpreter loses support. Both new tests fail on the previous pins and pass on these. --- pyproject.toml | 4 +- .../test_basic_python_version.py | 71 +++++ uv.lock | 246 +++++++++--------- 3 files changed, 196 insertions(+), 125 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 93fb32da464..678e7384a05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ dependencies = [ "tokenizers>=0.21.0,<1.0", "click>=8.0.0,<9.0", "jinja2>=3.1.6,<4.0", - "aiohttp>=3.10,<4.0", + "aiohttp>=3.14.2,<4.0", "pydantic>=2.10.0,<3.0.0", "jsonschema>=4.0.0,<5.0", ] @@ -277,7 +277,7 @@ exclude = [ [tool.uv] constraint-dependencies = [ "tornado>=6.5.6", - "aiohttp>=3.14.1,<4.0", + "aiohttp>=3.14.2,<4.0", "packaging>=24.0", "soupsieve>=2.8.4", "httplib2>=0.32.0", diff --git a/tests/local_testing/test_basic_python_version.py b/tests/local_testing/test_basic_python_version.py index e31c3953714..1f260f86eeb 100644 --- a/tests/local_testing/test_basic_python_version.py +++ b/tests/local_testing/test_basic_python_version.py @@ -142,6 +142,77 @@ def test_cli_extra_is_a_thin_client_install(): assert not leaked, f"`cli` extra leaks proxy-server deps onto laptops: {leaked}" +AIOHTTP_POOL_POISONING_RANGE = ">=3.14.0,<3.14.2" +AIOHTTP_POOL_POISONING_RELEASES = ("3.14.0", "3.14.1") + + +def _load_toml(path): + try: + import tomllib as tomli + except ImportError: + try: + import tomli + except ImportError: + pytest.skip("tomli/tomllib not available - skipping dependency check") + + with open(path, "rb") as f: + return tomli.load(f) + + +def _declared_aiohttp_specifier(): + from packaging.requirements import Requirement + + pyproject = _load_toml(os.path.join(PROJECT_ROOT, "pyproject.toml")) + for requirement in pyproject["project"]["dependencies"]: + parsed = Requirement(requirement) + if parsed.name.lower() == "aiohttp": + return parsed.specifier + pytest.fail("aiohttp is no longer a declared runtime dependency of litellm") + + +def _locked_aiohttp_version(): + lock = _load_toml(os.path.join(PROJECT_ROOT, "uv.lock")) + for package in lock["package"]: + if package["name"].lower() == "aiohttp": + return package["version"] + pytest.fail("aiohttp is missing from uv.lock") + + +def test_declared_aiohttp_floor_excludes_pool_poisoning_releases(): + """aiohttp 3.14.0/3.14.1 re-arm the sock_read timer on a keep-alive connection + after it is back in the idle pool, so the next request to reuse it fails + instantly with a bogus timeout (aio-libs/aiohttp#12953, fixed in 3.14.2). + + The wheel's own metadata is what pip resolves against, so the floor declared + here - not just the lockfile - has to exclude that range. + """ + specifier = _declared_aiohttp_specifier() + + admitted = [v for v in AIOHTTP_POOL_POISONING_RELEASES if specifier.contains(v)] + assert not admitted, ( + f"litellm declares aiohttp{specifier}, which still admits {admitted}. " + "Those releases poison pooled keep-alive connections and cause " + "cross-provider sub-millisecond 'Connection timed out' failures; " + "keep the floor at >=3.14.2." + ) + + +def test_locked_aiohttp_version_is_not_pool_poisoning(): + """uv.lock is what the published Docker images install (uv sync --frozen), so a + lock that drifts back onto 3.14.0/3.14.1 ships the regression regardless of + what pyproject.toml declares. + """ + from packaging.specifiers import SpecifierSet + + locked = _locked_aiohttp_version() + + assert not SpecifierSet(AIOHTTP_POOL_POISONING_RANGE).contains(locked), ( + f"uv.lock resolves aiohttp {locked}, which is inside the pool-poisoning " + f"range {AIOHTTP_POOL_POISONING_RANGE} (aio-libs/aiohttp#12953). " + "Re-run `uv lock` against an aiohttp>=3.14.2 floor." + ) + + import os import subprocess import time diff --git a/uv.lock b/uv.lock index d30f2df0a0e..fa7652c67ec 100644 --- a/uv.lock +++ b/uv.lock @@ -10,7 +10,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-07-27T18:40:42.08538Z" +exclude-newer = "2026-07-28T06:59:32.050819Z" exclude-newer-span = "P3D" [manifest] @@ -20,7 +20,7 @@ members = [ "litellm-proxy-extras", ] constraints = [ - { name = "aiohttp", specifier = ">=3.14.1,<4.0" }, + { name = "aiohttp", specifier = ">=3.14.2,<4.0" }, { name = "httplib2", specifier = ">=0.32.0" }, { name = "packaging", specifier = ">=24.0" }, { name = "setuptools", specifier = ">=83.0.0" }, @@ -82,7 +82,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.14.1" +version = "3.14.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -95,126 +95,126 @@ dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/67/58ded4b3f2e10f94972d8928050c85330e249a31dd45a0e5f3c0e9c3fa05/aiohttp-3.14.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8f6bb621e5863cfe8fe5ff5468002d200ec31f30f1280b259dc505b02595099e", size = 766140, upload-time = "2026-06-07T21:05:37.471Z" }, - { url = "https://files.pythonhosted.org/packages/18/68/4ae5b4e08943f316594bb68da89957d3baf5760588fa09509594bd777e4b/aiohttp-3.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4f7215cb3933784f79ed20e5f050e15984f390424339b22375d5a53c933a0491", size = 519430, upload-time = "2026-06-07T21:05:40.751Z" }, - { url = "https://files.pythonhosted.org/packages/cb/c1/316c8f3549dbe5245f92bfd523ec6f32dd4d98cafe21df3f6a19b1184c75/aiohttp-3.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d9d4e294455b23a68c9b8f042d0e8e377a265bcb15332753695f6e5b6819e0ce", size = 514406, upload-time = "2026-06-07T21:05:42.111Z" }, - { url = "https://files.pythonhosted.org/packages/5a/ee/fb0ac28684e8d753b83c8a4eebc19a5846912aa0a4daaabb6a9936363840/aiohttp-3.14.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b238af795833d5731d049d82bc84b768ae6f8f97f0495963b3ed9935c5901cc3", size = 1703649, upload-time = "2026-06-07T21:05:43.427Z" }, - { url = "https://files.pythonhosted.org/packages/3b/57/aa2beab673331f111885db8a7b69dfe3ab0e53e446a0ace18ca694b4dc58/aiohttp-3.14.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4e5e0ae56914ecdbf446493addefc0159053dd53962cef37d7839f37f73d505", size = 1675126, upload-time = "2026-06-07T21:05:44.897Z" }, - { url = "https://files.pythonhosted.org/packages/47/ea/dad128abe365e79be03b16ed464198ac73e0d257e8260c6f7d6f31cbef26/aiohttp-3.14.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:092e4ce3619a7c6dee52a6bdabda973d9b34b66781f840ce93c7e0cec30cf521", size = 1771558, upload-time = "2026-06-07T21:05:46.405Z" }, - { url = "https://files.pythonhosted.org/packages/63/f3/b5b4e10327cb85d34d24232c6b71b64602f190b3ccb238a043ac6b187dac/aiohttp-3.14.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb33777ea21e8b7ecde0e6fc84f598be0a1192eab1a63bc746d75aa75d38e7bd", size = 1856631, upload-time = "2026-06-07T21:05:47.844Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9d/93294c3045775c708ac8310eb3d3622a11d2951345ad590d532d62a1faa4/aiohttp-3.14.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23119f8fd4f5d16902ed459b63b100bcd269628075162bddac56cc7b5273b3fb", size = 1714139, upload-time = "2026-06-07T21:05:49.982Z" }, - { url = "https://files.pythonhosted.org/packages/29/c4/93067c85a0373492ce8e577435203c5947c454af074ac48ed4f3a1b9dd4a/aiohttp-3.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:57fc6745a4b7d0f5a9eb4f40a69718be6c0bc1b8368cc9fe89e90118719f4f42", size = 1588321, upload-time = "2026-06-07T21:05:51.431Z" }, - { url = "https://files.pythonhosted.org/packages/c4/39/9ff91aaf02af8b7b8222a987466da539f154c3e01732c22b5f5a20a8ee66/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6fd35beba67c4183b09375c5fff9accb47524191a244a99f95fd4472f5402c2b", size = 1670375, upload-time = "2026-06-07T21:05:53.109Z" }, - { url = "https://files.pythonhosted.org/packages/aa/e4/77452a3676b8d99ac1375f77691d6bf65ea6e9f4b201b82ef77c916dc767/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:672b9d65f42eb877f5c3f234a4547e4e1a226ca8c2eed879bb34670a0ce51192", size = 1690933, upload-time = "2026-06-07T21:05:54.902Z" }, - { url = "https://files.pythonhosted.org/packages/7d/84/b0059a7c7fc05ea23f3bc1596ba91c12f79588b9450564a24cac37536d0a/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:24ba13339fed9251d9b1a1bec8c7ab84c0d1675d79d33501e11f94f8b9a84e05", size = 1740798, upload-time = "2026-06-07T21:05:56.458Z" }, - { url = "https://files.pythonhosted.org/packages/8f/3a/e2a513ecbfc362591caa51a7f7e011b3bfc8938b388ae44cd95560d36999/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:94da27378da0610e341c4d30de29a191672683cc82b8f9556e8f7c7212a020fe", size = 1576412, upload-time = "2026-06-07T21:05:57.953Z" }, - { url = "https://files.pythonhosted.org/packages/a1/10/08f1654f538f93d36dcac66310a06eefce4641cdafca83f9f0a5317be254/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:52cdac9432d8b4a719f35094a818d95adcae0f0b4fe9b9b921909e0c87de9e7d", size = 1750199, upload-time = "2026-06-07T21:05:59.488Z" }, - { url = "https://files.pythonhosted.org/packages/99/e4/d91b70c57d8b8e9611e4a2e52238ca3698d3dc1c2efe25b7a9bf594ac584/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:672ac254412a24d0d0cf00a9e6c238877e4be5e5fa2d188832c1244f45f31966", size = 1699356, upload-time = "2026-06-07T21:06:01.131Z" }, - { url = "https://files.pythonhosted.org/packages/3d/f1/15340176f35ff61b95dbe34020bcf43f9e624a2d7bbac934715ff97d2033/aiohttp-3.14.1-cp310-cp310-win32.whl", hash = "sha256:2fe3607e71acc6ebb0ec8e492a247bf7a291226192dc0084236dfc12478916f6", size = 458939, upload-time = "2026-06-07T21:06:02.86Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c2/a2f1ec5b37f903109e43ae2862268cfe4a67a60c1b2cf43169fcdff5995f/aiohttp-3.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:30099eda75a53c32efb0920e9c33c195314d2cc1c680fbfd30894932ac5f27df", size = 482583, upload-time = "2026-06-07T21:06:04.666Z" }, - { url = "https://files.pythonhosted.org/packages/d0/7a/7b56f6732ef79530afaa72aa335d41b67c8d79b946995f0b11ad72985435/aiohttp-3.14.1-cp310-cp310-win_arm64.whl", hash = "sha256:5a837f49d901f9e368651b676912bff1104ed8c1a83b280bcd7b29adccef5c9c", size = 453470, upload-time = "2026-06-07T21:06:06.322Z" }, - { url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225, upload-time = "2026-06-07T21:06:07.957Z" }, - { url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743, upload-time = "2026-06-07T21:06:09.749Z" }, - { url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139, upload-time = "2026-06-07T21:06:11.26Z" }, - { url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088, upload-time = "2026-06-07T21:06:12.887Z" }, - { url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835, upload-time = "2026-06-07T21:06:14.564Z" }, - { url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801, upload-time = "2026-06-07T21:06:16.228Z" }, - { url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992, upload-time = "2026-06-07T21:06:17.947Z" }, - { url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989, upload-time = "2026-06-07T21:06:19.677Z" }, - { url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129, upload-time = "2026-06-07T21:06:22.558Z" }, - { url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576, upload-time = "2026-06-07T21:06:24.8Z" }, - { url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668, upload-time = "2026-06-07T21:06:26.528Z" }, - { url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019, upload-time = "2026-06-07T21:06:28.297Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638, upload-time = "2026-06-07T21:06:30.242Z" }, - { url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660, upload-time = "2026-06-07T21:06:31.943Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698, upload-time = "2026-06-07T21:06:33.72Z" }, - { url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386, upload-time = "2026-06-07T21:06:35.279Z" }, - { url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406, upload-time = "2026-06-07T21:06:36.824Z" }, - { url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987, upload-time = "2026-06-07T21:06:38.371Z" }, - { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" }, - { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" }, - { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" }, - { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" }, - { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" }, - { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" }, - { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" }, - { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" }, - { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" }, - { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" }, - { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" }, - { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" }, - { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" }, - { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, - { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, - { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, - { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, - { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, - { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, - { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, - { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, - { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, - { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, - { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, - { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, - { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, - { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, - { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, - { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, - { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, - { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, - { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, - { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, - { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, - { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, - { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, - { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, - { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, - { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, - { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, - { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, - { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, - { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, - { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, - { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, - { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, - { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, - { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, - { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, - { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, - { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, - { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, - { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, - { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, - { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, - { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, - { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, - { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, - { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, - { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, - { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, - { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, - { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, - { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, - { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, - { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, - { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, - { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4d/4a99fb425c5e0cad715eea7bd190aff46f38b959a0a2dadb993705d34b26/aiohttp-3.14.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:eb0495d778817619273c108784292be161a924b9f5ae5cbbc70a2caa6838250b", size = 765848, upload-time = "2026-07-23T01:52:08.217Z" }, + { url = "https://files.pythonhosted.org/packages/74/e8/43b85dc55b8e950dc644babe762add781319ea881b57b33d2cce12017d12/aiohttp-3.14.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c3c200cf9757edd785051dc699c7ecbec22110dbfcb3fefc7a9f9695eda8ea7a", size = 517476, upload-time = "2026-07-23T01:52:10.846Z" }, + { url = "https://files.pythonhosted.org/packages/7f/9e/73b582c4dbbc3c12ef4473822475effaabf1f934b56f14f5b03fe5d3a2af/aiohttp-3.14.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd51ebf9d3a00c074df4ede271023f4d2dba289bcc740b88191872716014e3c5", size = 515334, upload-time = "2026-07-23T01:52:12.636Z" }, + { url = "https://files.pythonhosted.org/packages/79/03/e98c3c9e05a5bdf97defe5ff9169baba4f0ec9a901f2d60e0f060c2f051e/aiohttp-3.14.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:134ac5ddcf61c6fad984b9a5727d83492ada43d63471db20fb73042c13fca62f", size = 1708830, upload-time = "2026-07-23T01:52:14.538Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2c/26e60b694844dfd2176c57f913a22d0cd6a16f9ff202cbda7580d0328b98/aiohttp-3.14.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:70c987b27534f9ae1a723f47ae921571d616da21d3208282bf4c52af5164ac43", size = 1674012, upload-time = "2026-07-23T01:52:16.486Z" }, + { url = "https://files.pythonhosted.org/packages/38/65/672df92e3172cd876aacfa97a952ac560877eb169384b2991ac5b273de4c/aiohttp-3.14.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1b59533861b70a2185c8f4f350f791f39d64358ef6944ce71c5240c9ec0982c9", size = 1767015, upload-time = "2026-07-23T01:52:18.28Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c5/228dec7bfec1c373cc2217cdeb47d6456dcd7a13a4c55144930a75ae3851/aiohttp-3.14.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c5281acc88b92396f88c7e1e2748f8466689df22b80170e4f51efa712fb47a8", size = 1858700, upload-time = "2026-07-23T01:52:20.08Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ff/cb36724e8c8d17f90ada567a9ff3efe1d6e9b549fba697a242aece180f21/aiohttp-3.14.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48d67b87db6279c044760787eb01f6413032c2e6f3ba1cafaa492b1c8e578479", size = 1714075, upload-time = "2026-07-23T01:52:22.071Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3a/296a4135c6366376263aeef54b15caca1f07676c2ae0c525d7832f2f808a/aiohttp-3.14.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f53bcd52f585e1ac3e590d61434eb61f9a88c38df041b4ea126d97144344a77b", size = 1588234, upload-time = "2026-07-23T01:52:23.757Z" }, + { url = "https://files.pythonhosted.org/packages/7d/81/9d5d853ef892dc066d1eb6db0e87a47348b920c1c879aa554612fdbd9d79/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0fdea2281997af69da84c77ffa6f5938a0285f21fb3887c249d67419ca865b3d", size = 1677300, upload-time = "2026-07-23T01:52:25.861Z" }, + { url = "https://files.pythonhosted.org/packages/68/96/021d386ae32d9b26d4b88df2e794546232ff56bb6be952bf6be227c0bbc7/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:cda5fd5c95ad7a125a2e8464acc78b98b94c475a3780d6aa0aa157c93f470f4d", size = 1691501, upload-time = "2026-07-23T01:52:28Z" }, + { url = "https://files.pythonhosted.org/packages/29/9f/af66adce26a14af135c003cbd0f44ccaa68cebd30ff8ac99ca47fb4958f7/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:6debfa7312ff9d4c124dc71d72e9a0a4b9e0879e48ba6fcb42bef5c3300289e2", size = 1735113, upload-time = "2026-07-23T01:52:29.995Z" }, + { url = "https://files.pythonhosted.org/packages/2f/90/28c390d4c9851effe52ac25b5a2e1d92246acd00728b4fc7975dafb67484/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f4e05329faa0ea1a404b37de4f034fd2c2defcca06a68dc6745e4e56c88e8a48", size = 1577486, upload-time = "2026-07-23T01:52:31.937Z" }, + { url = "https://files.pythonhosted.org/packages/db/c2/00e23a1bf2abb70dd353f6987db7e7f2491d0261f7363997738c71c98f95/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a3a8296e7ab5c295f53f1041487cb088e1480775aafbf7fe545d93b770a0f96f", size = 1751353, upload-time = "2026-07-23T01:52:33.688Z" }, + { url = "https://files.pythonhosted.org/packages/6e/7d/d51a706a8cbfa57f0611127daf61ab3ae02ab8420b0407412079227d1c65/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5373dc80ad1aa2fb9ad95c83f24eef418bbda3a61375f128e5b0192e4f3f9b32", size = 1698681, upload-time = "2026-07-23T01:52:38.167Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b0/90bd5cd9fdd9787cb4211d284d1fb8401339a933cb0227a15b71e789232f/aiohttp-3.14.3-cp310-cp310-win32.whl", hash = "sha256:a3e22975f905b89a55a488c2a08f2fdb2186175349e917d48985cc468a3d4c6e", size = 456733, upload-time = "2026-07-23T01:52:41.823Z" }, + { url = "https://files.pythonhosted.org/packages/d8/15/fe5b8f6a71ae112bc677163d0b0701bda5dc15005249582258ede0eb88c7/aiohttp-3.14.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdd0e2834dce1a26c1bbe26464861e16bbe217042cbff619247c11594472518c", size = 480460, upload-time = "2026-07-23T01:52:43.905Z" }, + { url = "https://files.pythonhosted.org/packages/54/00/45e98b6645cd7f00a4b78b749ebd309094b0eaeb2d2e96157eadbc0d0050/aiohttp-3.14.3-cp310-cp310-win_arm64.whl", hash = "sha256:eac645b09bcfdf73df7536331f0678c1086ea250981118ddb5199e17ccef72bb", size = 453479, upload-time = "2026-07-23T01:52:46.075Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5c/b3e4ff8ad43a8afef9602c5e90285936da1beaea8b029016b793891f03c3/aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3", size = 764250, upload-time = "2026-07-23T01:52:48.525Z" }, + { url = "https://files.pythonhosted.org/packages/0e/da/f1b384465e51449d844056b75070461da03a9a23e6c1747003695bf4172a/aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a", size = 516281, upload-time = "2026-07-23T01:52:51.047Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/01264f820ee2e3712a827892b1cd6ff80f3300c1fcbffbb45714a915d47a/aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8", size = 514742, upload-time = "2026-07-23T01:52:53.779Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8d/a71c6f2db52ac1ed142b133f7feddaa6b70539c3f4de24d7e226c95b794c/aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239", size = 1780613, upload-time = "2026-07-23T01:52:56.948Z" }, + { url = "https://files.pythonhosted.org/packages/a5/11/3dd9b3fb3a170f6ec9011b5291d876a6fab4086714c9e158600edf01b4fd/aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f", size = 1737688, upload-time = "2026-07-23T01:52:59.294Z" }, + { url = "https://files.pythonhosted.org/packages/6d/3e/834c26918be7d88068822b40e0db30fca50b5f4fe79104aa16a93f1d74e6/aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06", size = 1845742, upload-time = "2026-07-23T01:53:01.641Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c9/49ab8572df7d66bc13d11e31f781292badb04180dd87ba98733066c6aed7/aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929", size = 1928412, upload-time = "2026-07-23T01:53:04.018Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/2b8f0c0ce09c87a1daf80fd483431b56b1435d3f62789bc86f572e1245de/aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db", size = 1786220, upload-time = "2026-07-23T01:53:06.481Z" }, + { url = "https://files.pythonhosted.org/packages/85/00/9c45f81de11710460edfa1dc81317b6e882703b160926c879a9d20da9fcc/aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce", size = 1637231, upload-time = "2026-07-23T01:53:10.258Z" }, + { url = "https://files.pythonhosted.org/packages/19/ce/967d628e910756f3539c6107cb7844a1b69440dcb3029a5ee7871b09ab63/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c", size = 1753161, upload-time = "2026-07-23T01:53:13.817Z" }, + { url = "https://files.pythonhosted.org/packages/11/b2/0c3d4114f0aee4f580f5b3b4eb71b24d7a23b834ea506a4dfebe76513f35/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15", size = 1756356, upload-time = "2026-07-23T01:53:16.211Z" }, + { url = "https://files.pythonhosted.org/packages/63/5d/99e7d91c82f1399d1ae2a854e080bd1493fbc31e5e959dbc4ec33dac3bec/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c", size = 1819846, upload-time = "2026-07-23T01:53:18.289Z" }, + { url = "https://files.pythonhosted.org/packages/ad/05/d5e1cb6480eeffd3f901d40a2c5e2d1e7effdc797837da3b490272699f13/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae", size = 1628531, upload-time = "2026-07-23T01:53:23.86Z" }, + { url = "https://files.pythonhosted.org/packages/c9/90/b934682bcaefae18a9e04f3dff5b68522ba810906358ae5029b68110ea3b/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910", size = 1832712, upload-time = "2026-07-23T01:53:27.551Z" }, + { url = "https://files.pythonhosted.org/packages/21/df/6061679faaf81fac746e7307c7adb71e858071a5d34c27583afefc64f543/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7", size = 1775014, upload-time = "2026-07-23T01:53:30.223Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1d/f854878bbc69b88faefe924b619a34a6f59ec05fd387c77690667eaa75eb/aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa", size = 456006, upload-time = "2026-07-23T01:53:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/73/0c/2af9d1674baccd1dbd47282a93d660a22e57ef6167c856deb24b4214fbab/aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d", size = 481069, upload-time = "2026-07-23T01:53:39.673Z" }, + { url = "https://files.pythonhosted.org/packages/8e/76/88401ff3fc95e85c5fc38d588f36f55e61ecb64343b2bc8d69326f453cc0/aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39", size = 453021, upload-time = "2026-07-23T01:53:43.749Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" }, + { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" }, + { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" }, + { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" }, + { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" }, + { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" }, + { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" }, + { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" }, + { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" }, + { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" }, + { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" }, + { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, + { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, + { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, + { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, ] [[package]] @@ -4327,7 +4327,7 @@ proxy-dev = [ [package.metadata] requires-dist = [ { name = "a2a-sdk", marker = "extra == 'extra-proxy'", specifier = ">=1.1.0,<2.0" }, - { name = "aiohttp", specifier = ">=3.10,<4.0" }, + { name = "aiohttp", specifier = ">=3.14.2,<4.0" }, { name = "anthropic", extras = ["vertex"], marker = "extra == 'proxy-runtime'", specifier = ">=0.84.0,<1.0" }, { name = "apscheduler", marker = "extra == 'proxy'", specifier = ">=3.11.2,<4.0" }, { name = "audioread", marker = "extra == 'stt-nvidia-riva'", specifier = ">=3.0.1" }, From 67969303fceb053184039c6792131a77f09df4f5 Mon Sep 17 00:00:00 2001 From: mgeorgaklis Date: Fri, 31 Jul 2026 14:48:40 +0000 Subject: [PATCH 09/37] refactor(gemini): simplify thought signature collection --- .../llms/vertex_ai/gemini/transformation.py | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/litellm/llms/vertex_ai/gemini/transformation.py b/litellm/llms/vertex_ai/gemini/transformation.py index f465a54b265..1f568496041 100644 --- a/litellm/llms/vertex_ai/gemini/transformation.py +++ b/litellm/llms/vertex_ai/gemini/transformation.py @@ -657,10 +657,11 @@ def _collect_tool_call_thought_signatures( tool_calls = assistant_msg.get("tool_calls") if isinstance(tool_calls, list): for tool in tool_calls: - if isinstance(tool, dict): - signature = _get_thought_signature_from_tool(tool) - if signature: - signatures += (signature,) + if not isinstance(tool, dict): + continue + signature = _get_thought_signature_from_tool(tool) + if signature: + signatures += (signature,) function_call = assistant_msg.get("function_call") if isinstance(function_call, dict): @@ -669,15 +670,20 @@ def _collect_tool_call_thought_signatures( signatures += (signature,) provider_specific_fields = assistant_msg.get("provider_specific_fields") - if isinstance(provider_specific_fields, dict): - invocations = provider_specific_fields.get("server_side_tool_invocations") - if isinstance(invocations, list): - for invocation in invocations: - if isinstance(invocation, dict): - for key in ("thought_signature", "response_thought_signature"): - invocation_signature = invocation.get(key) - if isinstance(invocation_signature, str) and invocation_signature: - signatures += (invocation_signature,) + if not isinstance(provider_specific_fields, dict): + return frozenset(signatures) + + invocations = provider_specific_fields.get("server_side_tool_invocations") + if not isinstance(invocations, list): + return frozenset(signatures) + + for invocation in invocations: + if not isinstance(invocation, dict): + continue + for key in ("thought_signature", "response_thought_signature"): + invocation_signature = invocation.get(key) + if isinstance(invocation_signature, str) and invocation_signature: + signatures += (invocation_signature,) return frozenset(signatures) From 473f43dfbf08797c2239c645147bd722030373be Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Fri, 31 Jul 2026 08:49:22 -0700 Subject: [PATCH 10/37] fix(mcp): deny MCP access when a named entitlement cannot be read (#35160) An MCP permission level answers which servers and tools it permits, and a level that answers nothing places no restriction. Key auth was reading a lookup FAULT as that same answer, so the end user, agent and org ceilings quietly disappeared for as long as one lasted, while the keyless gateway-admitted path failed closed on the very same fault. Those levels now separate the two fault classes the user level already did. A principal row that NAMES an object_permission_id whose contents cannot be read is a known entitlement with unknown contents, so it denies. A lookup that fails before we can tell whether the principal is entitled at all still places no ceiling, that being the state which existed before the level did; denying there would refuse MCP to the majority of callers, who have no such entitlement configured. The keyless path is unchanged. Resolves LIT-4960 --- .../mcp_server/auth/user_api_key_auth_mcp.py | 337 ++++++++++++------ .../auth/test_user_api_key_auth_mcp.py | 174 +++++++++ 2 files changed, 404 insertions(+), 107 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py index dc796837277..81983cc62fd 100644 --- a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py +++ b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py @@ -67,6 +67,18 @@ def _as_list(values: Sequence[str] | None) -> list[str] | None: # mutable-ok: r return None if values is None else list(values) +class UnloadableEntitlementError(Exception): + """A principal's row NAMES an ``object_permission_id`` whose contents could not be read. + + Raised only where there is POSITIVE evidence an entitlement exists, so every caller must DENY + rather than fall back to "this level places no restriction": a ceiling we know exists but cannot + read would otherwise silently widen the caller for as long as the fault lasts. + + Deliberately distinct from a lookup that fails before the principal's entitlement is known at + all. Not knowing whether someone is entitled is the state that existed before the level did, so + it places no ceiling; denying there would refuse MCP to every caller during a cold-cache fault.""" + + def _parse_mcp_server_names_from_path(path: str, mcp_servers_header: Optional[List[str]] = None) -> Optional[List[str]]: """Resolve the single MCP server name a cold-start passthrough bypass may target. Delegates parsing to @@ -292,6 +304,22 @@ class MCPRequestHandler: 3. Header extraction and validation Utilizes the main `user_api_key_auth` function to validate authentication + + Entitlement-fault contract (``get_allowed_mcp_servers`` / ``get_allowed_tools_for_server``) + ------------------------------------------------------------------------------------------ + Every level (key, team, end user, agent, org) answers "which servers/tools does this level + permit", and a level that answers nothing places no restriction. A lookup FAULT is not that + answer, and the two callers resolve it differently on purpose: + + - A keyless gateway-admitted subject fails CLOSED on any fault at any level. Each of its grant + sources is resolved independently and unioned, so a fault that returned "no restriction" would + win the union as allow-all, and its per-source org ceiling is the ONLY org bound it has. + - Key auth fails closed only where there is POSITIVE evidence an entitlement exists: a principal + row that NAMES an ``object_permission_id`` we cannot load is a known entitlement with unknown + contents (``UnloadableEntitlementError`` -> deny). A fault so early we cannot tell whether the + principal is entitled at all leaves no ceiling, because that is the state that existed before + the level did; denying there would refuse MCP to every caller, most of whom have no entitlement + configured, for the duration of a cold-cache or DB fault. """ LITELLM_API_KEY_HEADER_NAME_PRIMARY = SpecialHeaders.custom_litellm_api_key.value @@ -1348,6 +1376,9 @@ class MCPRequestHandler: has an explicit MCP server list, the combined key/team/end_user/agent result is capped to that list. If the org has no list, no extra restriction is applied. + A level that cannot answer is NOT a level that permits everything; see the class docstring + for how each caller shape resolves an entitlement fault. + Returns: List[str]: List of allowed MCP servers by server id """ @@ -1478,7 +1509,12 @@ class MCPRequestHandler: return list(set(allowed_mcp_servers)) except Exception as e: - verbose_logger.warning(f"Failed to get allowed MCP servers: {str(e)}") + if isinstance(e, UnloadableEntitlementError): + # A ceiling we KNOW exists and cannot read. Denying is the only answer that does not + # widen this caller past what an operator configured, for both caller shapes. + verbose_logger.warning(f"Denying MCP access, entitlement unreadable: {str(e)}") + else: + verbose_logger.warning(f"Failed to get allowed MCP servers: {str(e)}") return [] @staticmethod @@ -1491,11 +1527,15 @@ class MCPRequestHandler: """Cap the resolved server list by this caller's org ceiling: an explicit org list intersects lower-level restrictions (else becomes the ceiling); no org or an empty list leaves it unchanged. - ``keyless_source`` governs both divergences for a keyless admitted source. An UNRESOLVABLE ceiling - fails CLOSED for it (its only org bound is this ceiling, so dropping it on a fault would escalate a - cross-org user) while a key stays fail-open. And an org list may only ever INTERSECT a source (the - admitted model unions grants, so a ceiling must not become one), whereas for a key it may - substitute, that being the key ceiling model.""" + ``keyless_source`` governs both divergences for a keyless admitted source. An INDETERMINATE ceiling + (we cannot tell whether the org restricts at all) fails CLOSED for it (its only org bound is this + ceiling, so dropping it on a fault would escalate a cross-org user) while a key stays fail-open. And + an org list may only ever INTERSECT a source (the admitted model unions grants, so a ceiling must not + become one), whereas for a key it may substitute, that being the key ceiling model. + + The fail-open arm is reached only for an INDETERMINATE fault: a ceiling the org NAMES but that + cannot be read raises out of ``_get_allowed_mcp_servers_for_org`` and never arrives here as + ``None``, so key auth cannot silently shed a ceiling an operator did configure.""" if not (user_api_key_auth and user_api_key_auth.org_id): return allowed_mcp_servers allowed_mcp_servers_for_org = await MCPRequestHandler._get_allowed_mcp_servers_for_org(user_api_key_auth) @@ -1900,12 +1940,19 @@ class MCPRequestHandler: ) except Exception as e: - verbose_logger.warning(f"Failed to get allowed tools for server: {str(e)}") + # An entitlement known to exist but unreadable denies for BOTH caller shapes, so [] rather + # than the None (allow-all) key auth gets for an indeterminate fault. + unreadable_entitlement = isinstance(e, UnloadableEntitlementError) + if unreadable_entitlement: + verbose_logger.warning(f"Denying MCP tools, entitlement unreadable: {str(e)}") + else: + verbose_logger.warning(f"Failed to get allowed tools for server: {str(e)}") # Fail CLOSED for a keyless admitted subject: ANY error must deny the server's tools ([]), # not collapse to allow-all (None); key/JWT auth keeps its prior allow-all-on-error. Both # keyless_source AND the marker are needed: each source resolves through an UNMARKED auth, so # without keyless_source a fault under a source returns None and wins the union as allow-all. - return [] if (keyless_source or _is_mcp_admitted_user_subject(user_api_key_auth)) else None + deny_all = unreadable_entitlement or keyless_source or _is_mcp_admitted_user_subject(user_api_key_auth) + return [] if deny_all else None @staticmethod async def _apply_agent_and_org_tool_ceilings( @@ -1944,7 +1991,9 @@ class MCPRequestHandler: try: org_obj_perm = await MCPRequestHandler._get_org_object_permission(user_api_key_auth) except Exception as e: # noqa: BLE001 # unresolvable org ceiling, decided per caller shape - if keyless_source: + # A ceiling the org NAMES but that cannot be read denies at every caller shape; only an + # INDETERMINATE fault (we cannot tell whether a ceiling exists) keeps key auth open. + if keyless_source or isinstance(e, UnloadableEntitlementError): raise verbose_logger.warning( f"MCP org tool ceiling unresolvable for org_id={user_api_key_auth.org_id!r}; " @@ -2275,18 +2324,54 @@ class MCPRequestHandler: verbose_logger.warning(f"Failed to get allowed MCP servers for team: {str(e)}") return [] + @staticmethod + async def _load_named_object_permission( + principal: str, + object_permission_id: str, + prisma_client: "PrismaClient", + user_api_key_auth: UserAPIKeyAuth, + ) -> LiteLLM_ObjectPermissionTable: + """Load the object permission a principal's row NAMES, or raise ``UnloadableEntitlementError``. + + The single place that fault is minted, so end user, agent and org cannot drift on what counts + as "known entitlement, unknown contents". ``get_object_permission`` answers None for both an + absent row and a failed read, and neither is evidence the principal is unrestricted: the link + proves an entitlement was configured, so both must deny.""" + from litellm.proxy.auth.auth_checks import get_object_permission + from litellm.proxy.proxy_server import proxy_logging_obj, user_api_key_cache + + unloadable = UnloadableEntitlementError( + f"{principal} names object_permission_id {object_permission_id!r} which could not be loaded" + ) + try: + object_permission = await get_object_permission( + object_permission_id=object_permission_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=user_api_key_auth.parent_otel_span, + proxy_logging_obj=proxy_logging_obj, + ) + except Exception as e: # noqa: BLE001 # a named entitlement we cannot read denies, whatever the read failed with + raise unloadable from e + if object_permission is None: + raise unloadable + return object_permission + @staticmethod async def _get_org_object_permission( user_api_key_auth: Optional[UserAPIKeyAuth] = None, - ): + ) -> LiteLLM_ObjectPermissionTable | None: """ Get org object_permission via the established ``get_org_object`` / ``get_object_permission`` helpers so MCP requests share the same ``user_api_key_cache`` entries as the rest of the proxy. + + ``None`` means the org places NO ceiling: no ``org_id``, no DB, or an org row naming no + permission. A row that NAMES one it cannot load raises ``UnloadableEntitlementError``; + every other lookup failure propagates as itself, leaving the ceiling merely unresolved. """ from litellm.proxy.auth.auth_checks import ( OrganizationNotFoundError, - get_object_permission, get_org_object, ) from litellm.proxy.proxy_server import ( @@ -2322,31 +2407,29 @@ class MCPRequestHandler: if org_obj is None or not org_obj.object_permission_id: return None - # The org NAMES a permission; failing to read it is INDETERMINATE and must not collapse into the - # None that means "no ceiling". Raise and let each caller pick fail-open or fail-closed. - object_permission = await get_object_permission( + # The org NAMES a permission; failing to read it is a KNOWN ceiling with unknown contents and + # must not collapse into the None that means "no ceiling". Raising denies at every caller shape. + return await MCPRequestHandler._load_named_object_permission( + principal=f"org {user_api_key_auth.org_id!r}", object_permission_id=org_obj.object_permission_id, prisma_client=prisma_client, - user_api_key_cache=user_api_key_cache, - parent_otel_span=user_api_key_auth.parent_otel_span, - proxy_logging_obj=proxy_logging_obj, + user_api_key_auth=user_api_key_auth, ) - if object_permission is None: - raise ValueError( - f"org {user_api_key_auth.org_id!r} names object_permission_id " - f"{org_obj.object_permission_id!r} which could not be loaded" - ) - return object_permission @staticmethod async def _get_allowed_mcp_servers_for_org( user_api_key_auth: Optional[UserAPIKeyAuth] = None, - ) -> List[str]: + ) -> list[str] | None: """ Get allowed MCP servers for an organization. Returns the MCP servers from the org's object_permission. - An empty result means the org places no restriction (allow-all from this level). + An empty result means the org places no restriction (allow-all from this level), ``None`` + that the ceiling could not be resolved, which the caller decides per shape. + + A ceiling the org NAMES but we cannot read is neither: it raises out of here so both caller + shapes deny, because dropping a ceiling known to exist is exactly the silent widening the + level is there to prevent. """ try: object_permissions = await MCPRequestHandler._get_org_object_permission(user_api_key_auth) @@ -2374,34 +2457,28 @@ class MCPRequestHandler: except Exception as e: # None = ceiling UNRESOLVED, distinct from [] = org places no restriction. Collapsing them # let a DB fault silently drop a ceiling; the caller picks fail-open/closed from this signal. + # A NAMED-but-unreadable ceiling is a stronger fact than "unresolved" and denies everywhere. + if isinstance(e, UnloadableEntitlementError): + raise verbose_logger.warning(f"Failed to get allowed MCP servers for org: {str(e)}") return None @staticmethod - async def _get_allowed_mcp_servers_for_end_user( - user_api_key_auth: Optional[UserAPIKeyAuth] = None, - ) -> List[str]: - """ - Get allowed MCP servers for an end user. + async def _get_end_user_object_permission( + user_api_key_auth: UserAPIKeyAuth, + prisma_client: "PrismaClient", + ) -> LiteLLM_ObjectPermissionTable | None: + """The end user's own object_permission, or ``None`` when this level places no restriction. - Returns the MCP servers from the end_user's object_permission. - """ + ``None`` covers an end user row that is absent or names no permission, and an end user we + could not resolve at all (``get_end_user_object`` answers None for an absent row AND for a + failed read, so this level genuinely cannot tell those apart). A row that DOES name a + permission we cannot load raises ``UnloadableEntitlementError``: the link is positive + evidence of an entitlement, so its contents may not be assumed empty.""" from litellm.proxy.auth.auth_checks import get_end_user_object - from litellm.proxy.proxy_server import ( - prisma_client, - proxy_logging_obj, - user_api_key_cache, - ) - - if not user_api_key_auth or not user_api_key_auth.end_user_id: - return [] - - if prisma_client is None: - verbose_logger.debug("prisma_client is None") - return [] + from litellm.proxy.proxy_server import proxy_logging_obj, user_api_key_cache try: - # Use optimized get_end_user_object function with caching end_user_obj = await get_end_user_object( end_user_id=user_api_key_auth.end_user_id, prisma_client=prisma_client, @@ -2410,29 +2487,65 @@ class MCPRequestHandler: proxy_logging_obj=proxy_logging_obj, route="/mcp", ) + except Exception as e: # noqa: BLE001 # entitlement unknown, not known-absent: no ceiling, as before this level + verbose_logger.warning(f"Failed to resolve end_user for MCP permissions: {str(e)}") + return None - if end_user_obj is None or end_user_obj.object_permission is None: - return [] + if end_user_obj is None: + return None + if end_user_obj.object_permission is not None: + return end_user_obj.object_permission + if not end_user_obj.object_permission_id: + return None + # The row NAMES a permission the relation did not carry. One shared (cached) lookup decides + # whether it is readable; an unreadable one denies rather than reading as "no restriction". + return await MCPRequestHandler._load_named_object_permission( + principal=f"end user {user_api_key_auth.end_user_id!r}", + object_permission_id=end_user_obj.object_permission_id, + prisma_client=prisma_client, + user_api_key_auth=user_api_key_auth, + ) + @staticmethod + async def _get_allowed_mcp_servers_for_end_user( + user_api_key_auth: Optional[UserAPIKeyAuth] = None, + ) -> List[str]: + """ + Get allowed MCP servers for an end user. + + Returns the MCP servers from the end_user's object_permission; an empty result means this + level places no restriction. An entitlement the end user row NAMES but that cannot be read + raises ``UnloadableEntitlementError`` out of here so the resolver denies. + """ + from litellm.proxy.proxy_server import prisma_client + + if not user_api_key_auth or not user_api_key_auth.end_user_id: + return [] + + if prisma_client is None: + verbose_logger.debug("prisma_client is None") + return [] + + object_permission = await MCPRequestHandler._get_end_user_object_permission(user_api_key_auth, prisma_client) + if object_permission is None: + return [] + + try: # Permission entries may be server_ids OR names/aliases — expand to ids. from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( global_mcp_server_manager, ) - direct_mcp_servers = global_mcp_server_manager.expand_permission_list( - end_user_obj.object_permission.mcp_servers or [] - ) + direct_mcp_servers = global_mcp_server_manager.expand_permission_list(object_permission.mcp_servers or []) # Get MCP servers from access groups access_group_servers = await MCPRequestHandler._get_mcp_servers_from_access_groups( - end_user_obj.object_permission.mcp_access_groups or [] + object_permission.mcp_access_groups or [] ) # servers referenced in tool permissions should also be accessible tool_perm_servers = list( - global_mcp_server_manager.expand_tool_permissions( - end_user_obj.object_permission.mcp_tool_permissions - ).keys() + global_mcp_server_manager.expand_tool_permissions(object_permission.mcp_tool_permissions).keys() ) # Combine all lists @@ -2643,22 +2756,51 @@ class MCPRequestHandler: # don't re-query the DB on every MCP request for that agent. _AGENT_NO_PERMISSION_SENTINEL = "__agent_no_mcp_permission__" + @staticmethod + async def _agent_object_permission_id(agent_id: str, prisma_client: "PrismaClient") -> str | None: + """The permission row this agent's row links to, or ``None`` when it links none. + + Caches the link (with a sentinel for "links none") so an agent without an entitlement costs + no DB read per MCP request. A read that fails also answers ``None``: not knowing whether the + agent is entitled is the state that existed before this level, so it places no ceiling. Only + a link we DID resolve can make the caller deny.""" + from litellm.proxy.proxy_server import user_api_key_cache + + cache_key = f"agent_object_permission_id:{agent_id}" + try: + cached: object = await user_api_key_cache.async_get_cache(key=cache_key) + if cached == MCPRequestHandler._AGENT_NO_PERMISSION_SENTINEL: + return None + if isinstance(cached, str) and cached: + return cached + agent_row = await AgentsRepository(prisma_client).table.find_unique(where={"agent_id": agent_id}) + linked: object = getattr(agent_row, "object_permission_id", None) if agent_row is not None else None + object_permission_id = linked if isinstance(linked, str) and linked else None + await user_api_key_cache.async_set_cache( + key=cache_key, + value=object_permission_id or MCPRequestHandler._AGENT_NO_PERMISSION_SENTINEL, + ttl=get_management_object_ttl(user_api_key_cache), + ) + return object_permission_id + except Exception as e: # noqa: BLE001 # entitlement unknown, not known-absent: no ceiling, as before this level + verbose_logger.warning(f"Failed to resolve object_permission_id for agent {agent_id!r}: {str(e)}") + return None + @staticmethod async def _get_agent_object_permission( user_api_key_auth: Optional[UserAPIKeyAuth] = None, - ): + ) -> LiteLLM_ObjectPermissionTable | None: """ Get agent object_permission via the established ``get_object_permission`` helper. Caches the ``agent_id -> object_permission_id`` mapping so we avoid re-reading the agent row on every request, and reuses the shared ``object_permission_id`` cache populated by the org / team / key paths. + + ``None`` means the agent places NO restriction: no ``agent_id``, no DB, or an agent linking + no permission. An agent that LINKS one we cannot load raises ``UnloadableEntitlementError``, + since a known entitlement with unknown contents must deny rather than read as unrestricted. """ - from litellm.proxy.auth.auth_checks import get_object_permission - from litellm.proxy.proxy_server import ( - prisma_client, - proxy_logging_obj, - user_api_key_cache, - ) + from litellm.proxy.proxy_server import prisma_client if not user_api_key_auth or not user_api_key_auth.agent_id: return None @@ -2668,40 +2810,17 @@ class MCPRequestHandler: return None agent_id = user_api_key_auth.agent_id - cache_key = f"agent_object_permission_id:{agent_id}" - - try: - object_permission_id: Optional[str] = await user_api_key_cache.async_get_cache(key=cache_key) - - if object_permission_id == MCPRequestHandler._AGENT_NO_PERMISSION_SENTINEL: - return None - - if object_permission_id is None: - agent_row = await AgentsRepository(prisma_client).table.find_unique( - where={"agent_id": agent_id}, - ) - object_permission_id = ( - getattr(agent_row, "object_permission_id", None) if agent_row is not None else None - ) - await user_api_key_cache.async_set_cache( - key=cache_key, - value=object_permission_id or MCPRequestHandler._AGENT_NO_PERMISSION_SENTINEL, - ttl=get_management_object_ttl(user_api_key_cache), - ) - if not object_permission_id: - return None - - return await get_object_permission( - object_permission_id=object_permission_id, - prisma_client=prisma_client, - user_api_key_cache=user_api_key_cache, - parent_otel_span=user_api_key_auth.parent_otel_span, - proxy_logging_obj=proxy_logging_obj, - ) - except Exception as e: - verbose_logger.warning(f"Failed to get agent object permission: {str(e)}") + object_permission_id = await MCPRequestHandler._agent_object_permission_id(agent_id, prisma_client) + if object_permission_id is None: return None + return await MCPRequestHandler._load_named_object_permission( + principal=f"agent {agent_id!r}", + object_permission_id=object_permission_id, + prisma_client=prisma_client, + user_api_key_auth=user_api_key_auth, + ) + @staticmethod async def _get_allowed_mcp_servers_for_agent( user_api_key_auth: Optional[UserAPIKeyAuth] = None, @@ -2711,7 +2830,9 @@ class MCPRequestHandler: Get allowed MCP servers for an agent (from the agent's object_permission). Returns the MCP servers from the agent's object_permission. - If agent has no object_permission, returns [] (no extra restriction). + If agent has no object_permission, returns [] (no extra restriction). An entitlement the + agent LINKS but that cannot be read raises ``UnloadableEntitlementError`` out of here so the + resolver denies. Args: user_api_key_auth: User auth with agent_id @@ -2721,13 +2842,13 @@ class MCPRequestHandler: if not user_api_key_auth or not user_api_key_auth.agent_id: return [] - try: - obj_perm = agent_object_permission - if obj_perm is None: - obj_perm = await MCPRequestHandler._get_agent_object_permission(user_api_key_auth) - if obj_perm is None: - return [] + obj_perm = agent_object_permission + if obj_perm is None: + obj_perm = await MCPRequestHandler._get_agent_object_permission(user_api_key_auth) + if obj_perm is None: + return [] + try: direct_mcp_servers = getattr(obj_perm, "mcp_servers", None) or [] if isinstance(direct_mcp_servers, str): direct_mcp_servers = [] @@ -2757,7 +2878,9 @@ class MCPRequestHandler: ) -> Optional[List[str]]: """ Get allowed tool names for a server from the agent's object_permission. - Returns None if agent has no tool restrictions for this server. + Returns None if agent has no tool restrictions for this server. An entitlement the agent + LINKS but that cannot be read raises ``UnloadableEntitlementError`` out of here, which the + tool resolver turns into deny-all for the server rather than an unrestricted tool list. Args: server_id: Server ID to check permissions for @@ -2768,13 +2891,13 @@ class MCPRequestHandler: if not user_api_key_auth or not user_api_key_auth.agent_id: return None - try: - obj_perm = agent_object_permission - if obj_perm is None: - obj_perm = await MCPRequestHandler._get_agent_object_permission(user_api_key_auth) - if obj_perm is None: - return None + obj_perm = agent_object_permission + if obj_perm is None: + obj_perm = await MCPRequestHandler._get_agent_object_permission(user_api_key_auth) + if obj_perm is None: + return None + try: mcp_tool_permissions = getattr(obj_perm, "mcp_tool_permissions", None) if not mcp_tool_permissions or not isinstance(mcp_tool_permissions, dict): return None diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py index 89b8f018e5c..0b95a497882 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py @@ -8191,3 +8191,177 @@ class TestGetUserObjectPermission: async def test_no_user_id_places_no_ceiling(self): assert await MCPRequestHandler._get_user_object_permission(UserAPIKeyAuth(api_key="sk-test")) is None assert await MCPRequestHandler._get_user_object_permission(None) is None + + +def _key_auth_reaching(server, *, tools=None, **fields): + """A key-authenticated caller whose OWN key grant reaches ``server`` (and optionally its ``tools``). + + The key grant is the thing an upper-level entitlement fault must not silently hand back: every + test below asserts against what this key reaches when the level under test cannot be resolved. + """ + return UserAPIKeyAuth( + api_key="sk-hash", + user_id="u1", + object_permission=LiteLLM_ObjectPermissionTable( + object_permission_id="op-key", + mcp_servers=[server], + mcp_tool_permissions={server: tools} if tools else None, + ), + **fields, + ) + + +def _agent_prisma(object_permission_id=None, side_effect=None): + prisma_client = MagicMock() + prisma_client.db.litellm_agentstable.find_unique = AsyncMock( + return_value=MagicMock(object_permission_id=object_permission_id), + side_effect=side_effect, + ) + return prisma_client + + +@contextlib.contextmanager +def _entitlement_fault_globals(prisma_client=None): + from litellm.caching.dual_cache import DualCache + + with ( + patch("litellm.proxy.proxy_server.prisma_client", prisma_client or MagicMock()), + patch("litellm.proxy.proxy_server.user_api_key_cache", DualCache()), + patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()), + ): + yield + + +@pytest.mark.asyncio +class TestEntitlementFaultSemantics: + """Each entitlement level distinguishes two fault classes for a KEY-authenticated caller. + + A principal row that NAMES an object_permission we cannot load is a known entitlement with + unknown contents, so the level denies rather than handing back the wider key scope. A lookup + that fails before we can tell whether the principal is entitled at all leaves no ceiling, which + is the state that existed before the level did; denying there would refuse MCP to the majority + of callers, who have no such entitlement configured, for the duration of a cold-cache fault. + """ + + async def test_end_user_named_but_unloadable_permission_denies(self): + end_user = MagicMock(object_permission=None, object_permission_id="op-eu") + auth = _key_auth_reaching("srv1", end_user_id="eu-1") + with _entitlement_fault_globals(): + with ( + patch("litellm.proxy.auth.auth_checks.get_end_user_object", AsyncMock(return_value=end_user)), + patch("litellm.proxy.auth.auth_checks.get_object_permission", AsyncMock(return_value=None)), + ): + allowed = await MCPRequestHandler.get_allowed_mcp_servers(auth) + assert allowed == [], "an end-user entitlement we know exists but cannot read must deny" + + async def test_end_user_without_an_entitlement_places_no_ceiling(self): + """The three shapes that are NOT evidence of an entitlement: an end user row linking no + permission, no end user row at all, and a lookup that blew up before answering either.""" + auth = _key_auth_reaching("srv1", end_user_id="eu-1") + linked_none = MagicMock(object_permission=None, object_permission_id=None) + for lookup, shape in ( + (AsyncMock(return_value=linked_none), "row links no permission"), + (AsyncMock(return_value=None), "no end user row"), + (AsyncMock(side_effect=RuntimeError("connection reset by peer")), "lookup failed"), + ): + with _entitlement_fault_globals(): + with patch("litellm.proxy.auth.auth_checks.get_end_user_object", lookup): + allowed = await MCPRequestHandler.get_allowed_mcp_servers(auth) + assert set(allowed) == {"srv1"}, f"{shape}: no evidence of an entitlement, so no ceiling" + + async def test_agent_named_but_unloadable_permission_denies(self): + auth = _key_auth_reaching("srv1", agent_id="agent-unloadable") + with _entitlement_fault_globals(_agent_prisma(object_permission_id="op-agent")): + with patch("litellm.proxy.auth.auth_checks.get_object_permission", AsyncMock(return_value=None)): + allowed = await MCPRequestHandler.get_allowed_mcp_servers(auth) + assert allowed == [], "an agent entitlement we know exists but cannot read must deny" + + async def test_agent_without_an_entitlement_places_no_ceiling(self): + """An agent row linking no permission, and an agent row we could not read at all.""" + for prisma_client, agent_id, shape in ( + (_agent_prisma(object_permission_id=None), "agent-unlinked", "agent links no permission"), + (_agent_prisma(side_effect=RuntimeError("connection reset by peer")), "agent-unread", "row read failed"), + ): + auth = _key_auth_reaching("srv1", agent_id=agent_id) + with _entitlement_fault_globals(prisma_client): + allowed = await MCPRequestHandler.get_allowed_mcp_servers(auth) + assert set(allowed) == {"srv1"}, f"{shape}: no evidence of an entitlement, so no ceiling" + + async def test_agent_named_but_unloadable_permission_denies_tools(self): + """The tools axis denies with [] rather than the None (allow-all) key auth gets for an + indeterminate fault, so an unreadable agent entitlement cannot widen the key's tool scope.""" + auth = _key_auth_reaching("srv1", tools=["tool_a"], agent_id="agent-tools-unloadable") + with _entitlement_fault_globals(_agent_prisma(object_permission_id="op-agent")): + with patch("litellm.proxy.auth.auth_checks.get_object_permission", AsyncMock(return_value=None)): + tools = await MCPRequestHandler.get_allowed_tools_for_server("srv1", auth) + assert tools == [], "an agent entitlement we know exists but cannot read must deny its tools" + + async def test_org_named_but_unloadable_ceiling_denies(self): + auth = _key_auth_reaching("srv1", org_id="org-a") + org = MagicMock(object_permission_id="op-org") + with _entitlement_fault_globals(): + with ( + patch("litellm.proxy.auth.auth_checks.get_org_object", AsyncMock(return_value=org)), + patch("litellm.proxy.auth.auth_checks.get_object_permission", AsyncMock(return_value=None)), + ): + allowed = await MCPRequestHandler.get_allowed_mcp_servers(auth) + assert allowed == [], "an org ceiling we know exists but cannot read must deny, key auth included" + + async def test_org_named_but_unloadable_ceiling_denies_tools(self): + auth = _key_auth_reaching("srv1", tools=["tool_a"], org_id="org-a") + org = MagicMock(object_permission_id="op-org") + with _entitlement_fault_globals(): + with ( + patch("litellm.proxy.auth.auth_checks.get_org_object", AsyncMock(return_value=org)), + patch("litellm.proxy.auth.auth_checks.get_object_permission", AsyncMock(return_value=None)), + ): + tools = await MCPRequestHandler.get_allowed_tools_for_server("srv1", auth) + assert tools == [], "an org tool ceiling we know exists but cannot read must deny its tools" + + async def test_org_without_a_resolvable_entitlement_places_no_ceiling(self): + """A deleted org and an org lookup that failed are both cases where we cannot point at a + ceiling; key auth keeps its long-standing fail-open behavior for them.""" + from litellm.proxy.auth.auth_checks import OrganizationNotFoundError + + auth = _key_auth_reaching("srv1", org_id="org-a") + for lookup, shape in ( + (AsyncMock(return_value=MagicMock(object_permission_id=None)), "org names no permission"), + (AsyncMock(side_effect=OrganizationNotFoundError("Organization doesn't exist in db.")), "org deleted"), + (AsyncMock(side_effect=RuntimeError("connection reset by peer")), "org lookup failed"), + ): + with _entitlement_fault_globals(): + with patch("litellm.proxy.auth.auth_checks.get_org_object", lookup): + allowed = await MCPRequestHandler.get_allowed_mcp_servers(auth) + assert set(allowed) == {"srv1"}, f"{shape}: no ceiling we can point at, so key auth stays open" + + async def test_keyless_org_ceiling_denies_on_either_fault_class(self): + """The keyless gateway-admitted path is untouched: it already denied on ANY org-ceiling + fault, and still denies on both classes, because a per-source org ceiling is the only org + bound a keyless subject has and an unbounded source would win the union.""" + auth = _make_admitted_subject("sso-user", org_id="org-a", own_servers=["srv1"]) + org = MagicMock(object_permission_id="op-org") + with _entitlement_fault_globals(): + with patch("litellm.proxy.auth.auth_checks.get_org_object", AsyncMock(return_value=org)): + with patch("litellm.proxy.auth.auth_checks.get_object_permission", AsyncMock(return_value=None)): + named_unloadable = await MCPRequestHandler.get_allowed_mcp_servers(auth) + with patch( + "litellm.proxy.auth.auth_checks.get_org_object", + AsyncMock(side_effect=RuntimeError("connection reset by peer")), + ): + indeterminate = await MCPRequestHandler.get_allowed_mcp_servers(auth) + assert named_unloadable == [] and indeterminate == [] + + async def test_keyless_source_never_consults_the_end_user_or_agent_levels(self): + """A keyless subject's grant sources carry neither end_user_id nor agent_id, so neither + level runs for it and neither new deny can reach its union. Pinned because a source that + DID consult them would fail closed on a fault and silently drop a team's grants.""" + auth = _make_admitted_subject("sso-user", own_servers=["srv1"]) + auth.end_user_id = "eu-1" + auth.agent_id = "agent-unloadable" + with _entitlement_fault_globals(_agent_prisma(object_permission_id="op-agent")): + with ( + patch("litellm.proxy.auth.auth_checks.get_end_user_object", AsyncMock(side_effect=AssertionError)), + patch("litellm.proxy.auth.auth_checks.get_object_permission", AsyncMock(return_value=None)), + ): + allowed = await MCPRequestHandler.get_allowed_mcp_servers(auth) + assert set(allowed) == {"srv1"} From 416e39815474b35acdfa02d18f619d84f8101582 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Fri, 31 Jul 2026 09:26:30 -0700 Subject: [PATCH 11/37] feat(proxy): add generic list handler for /management/v1 (#35308) * feat(proxy): add generic list handler for /management/v1 Adds the ListSpec/QueryPlan machinery the control-plane list endpoints are meant to share, so a resource declares what it exposes instead of hand-rolling its own paging, sorting and filter parsing. build_query_plan is pure: it turns query parameters into a QueryPlan or an RFC 9457 problem without any I/O, which is what lets the plan be asserted as a value. The database half is a ListExecutor protocol injected by the caller, so this module has no Prisma dependency at all. Four things the framework guarantees rather than leaving to each resource: the spec's unique tiebreaker is always the final sort key, so pages cannot repeat rows when the leading column is all nulls; ordering is NULLS LAST in both directions, since Postgres otherwise floats empty values to the top the moment the sort direction flips; the scope predicate is a separate conjunct ahead of every caller filter, so a filter on a scoped column cannot widen it; and a denied scope is a 403 problem rather than a 200 with an empty list. No route and no consumer yet; budgets registers against it next. The facet endpoint's has_more shapes are untouched, and a test pins them so page mode cannot quietly absorb them. * fix(proxy): accept the bare filter[field] form in the list framework Section 5 of the design doc spells equality without an operator bracket (`?filter[status]=active`, and `/management/v1/keys?filter[team_id]=` in the sub-resource paragraph); only the other operators carry a second bracket. The parser only understood `filter[field][op]`, so the canonical spelling came back as an unknown query parameter. `filter[field]` now resolves to the field's `eq` operator, which means it still goes through the declared operator set rather than around it: a field that does not offer `eq` rejects the shorthand. The allowed-parameter list advertises the bare spelling for `eq` and the bracketed one for everything else. Drops two guards from the key parser that could not fire. Operator validation already rejects every malformed operator, and `field in spec.filters` already rejects every field nobody declared, so a well-formedness check on top of them was unreachable; the tests cover the malformed keys directly instead. * fix(proxy): validate list specs at construction and reject repeated params Two gaps a review flagged on the list framework. The page-size cap was only enforced against a supplied page_size, so a spec whose default_page_size exceeded its max_page_size served more rows than the resource allows on exactly the request that omits the parameter. A default of zero was worse: it reached the total_pages division and made the resource 500 on every request. ListSpec now validates 1 <= default_page_size <= max_page_size when it is built, so a misconfigured resource fails as it is registered rather than per request. default_sort is checked against sortable for the same reason; caller-supplied sort was already validated, but the default never passed through that path and a typo there reached the ORDER BY clause untouched. Raising is right here despite the usual model-failures-as-values rule: there is no request in flight and no caller to answer. Repeated query parameters silently collapsed to their last value, so ?page=1&page=999 paged from 999 and a repeated sort key quietly won, which is the same silently-altered-semantics failure the surface already rejects unknown parameters to avoid. They are now a 400. The check lives in handle_list rather than build_query_plan because a Mapping[str, str] cannot represent a repeat at all; the boundary that can see one is the boundary that rejects it. A denied scope still outranks it, matching every other rejection here. Also corrects the order_by_sql docstring, which claimed every field reaching it had been validated against sortable. That held for caller-supplied sort only. * refactor(proxy): model list predicates as frozen values instead of dicts The LIT002 budget rejected the framework: building a where-fragment meant a dict literal per operator, and a dict keyed by a column name chosen at runtime cannot be frozen into a TypedDict or a dataclass field, so there was no spelling of the old shape the rule would accept. Replacing the fragments with a tagged union removes the construction entirely. A plan's where is now a tuple of frozen Compare / Within / IsNull / AnyOf, matched exhaustively, and the field name is a value rather than a key. That also retires the Mapping[str, object] the plan used to carry, which said nothing about what was inside it and left the fragment shape as a convention two sides had to keep agreeing on. Scope predicates take the same type, so a resource declares its row filter in the same vocabulary rather than hand-rolling a backend dict. where_sql renders a plan for a raw-SQL executor, binding every caller-supplied value to a numbered placeholder and writing only spec-declared column names into the statement. It is the counterpart to order_by_sql, which already existed for the same reason: nulls ordering forces the executor onto raw SQL, so the escaping and placeholder arithmetic belong in one reviewed place rather than in each consumer. Also folds the two remaining mutable builds out of the module (set comprehensions and Counter to frozenset/tuple, the serialized page to a tuple pydantic coerces), and lifts the LIKE escaper into common.py so the facet endpoint and the framework share one copy instead of two that can drift. No behavioural change to the facet endpoint; its tests, including the one pinning the escaping, pass untouched. --- .../management_v1/common.py | 38 +- .../management_v1/list_framework.py | 522 +++++++++++ .../management_v1/spend_logs.py | 7 +- .../management_endpoints/management_v1.py | 34 + .../management_v1/test_list_framework.py | 871 ++++++++++++++++++ 5 files changed, 1458 insertions(+), 14 deletions(-) create mode 100644 litellm/proxy/management_endpoints/management_v1/list_framework.py create mode 100644 tests/test_litellm/proxy/management_endpoints/management_v1/test_list_framework.py diff --git a/litellm/proxy/management_endpoints/management_v1/common.py b/litellm/proxy/management_endpoints/management_v1/common.py index c0e7f49f2e9..daa2c60ac5e 100644 --- a/litellm/proxy/management_endpoints/management_v1/common.py +++ b/litellm/proxy/management_endpoints/management_v1/common.py @@ -7,6 +7,7 @@ from fastapi.dependencies.utils import get_flat_dependant from fastapi.responses import JSONResponse from litellm.types.proxy.management_endpoints.management_v1 import ( + ListLinks, PageLinks, ProblemDetail, ) @@ -43,6 +44,21 @@ def _declared_query_params(request: Request) -> frozenset[str]: return frozenset(field.alias for field in get_flat_dependant(dependant, skip_repeats=True).query_params) +def escape_like(value: str) -> str: + """Escape LIKE/ILIKE metacharacters. Ids routinely contain `_`, which is a wildcard unescaped.""" + return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + +def unknown_query_param_problem(unknown: tuple[str, ...], allowed: tuple[str, ...]) -> ProblemDetail: + return ProblemDetail( + type=f"{PROBLEM_TYPE_BASE}unknown-query-parameter", + title="Unknown query parameter", + status=400, + detail=f"Unrecognized query parameter(s): {', '.join(unknown)}.", + allowed=sorted(allowed), + ) + + async def reject_unknown_query_params(request: Request) -> None: """Reject any query param the route did not declare. @@ -53,15 +69,7 @@ async def reject_unknown_query_params(request: Request) -> None: unknown: tuple[str, ...] = tuple(sorted(name for name in request.query_params if name not in declared)) if not unknown: return - raise ManagementProblem( - ProblemDetail( - type=f"{PROBLEM_TYPE_BASE}unknown-query-parameter", - title="Unknown query parameter", - status=400, - detail=f"Unrecognized query parameter(s): {', '.join(unknown)}.", - allowed=sorted(declared), - ) - ) + raise ManagementProblem(unknown_query_param_problem(unknown=unknown, allowed=tuple(sorted(declared)))) def _page_url(request: Request, page: int) -> str: @@ -75,3 +83,15 @@ def build_page_links(request: Request, page: int, has_more: bool) -> PageLinks: prev=_page_url(request, page - 1) if page > 1 else None, next=_page_url(request, page + 1) if has_more else None, ) + + +def build_list_links(request: Request, page: int, total_pages: int) -> ListLinks: + """Page-mode links. `last` clamps to page 1 on an empty result set so every link still resolves.""" + last = max(total_pages, 1) + return ListLinks( + self_link=_page_url(request, page), + first=_page_url(request, 1), + prev=_page_url(request, page - 1) if page > 1 else None, + next=_page_url(request, page + 1) if page < last else None, + last=_page_url(request, last), + ) diff --git a/litellm/proxy/management_endpoints/management_v1/list_framework.py b/litellm/proxy/management_endpoints/management_v1/list_framework.py new file mode 100644 index 00000000000..3e4b9131d1e --- /dev/null +++ b/litellm/proxy/management_endpoints/management_v1/list_framework.py @@ -0,0 +1,522 @@ +"""Generic list handling for `/management/v1` collection routes. + +A resource declares a `ListSpec`; `build_query_plan` turns query parameters into a +`QueryPlan` or an RFC 9457 problem without touching a database, and `handle_list` +runs that plan through an injected `ListExecutor`. Keeping the planning pure is what +lets a caller assert the plan as a value instead of asserting against a live Prisma +client, and it keeps this module free of any database dependency. + +A plan's `where` is a tuple of frozen `Predicate`s rather than a backend-shaped +mapping, so the framework never has to know which query builder executes it and a +planned predicate cannot be rewritten afterwards. `where_sql` renders one for a +raw-SQL executor with every caller-supplied value bound to a placeholder. +""" + +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime, timezone +from math import ceil +from typing import Generic, Literal, Protocol, TypeVar + +from fastapi import Request +from pydantic import TypeAdapter, ValidationError +from typing_extensions import assert_never + +from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.management_endpoints.management_v1.common import ( + PROBLEM_TYPE_BASE, + ManagementProblem, + build_list_links, + escape_like, + unknown_query_param_problem, +) +from litellm.types.proxy.management_endpoints.management_v1 import ( + ListMeta, + ListResponse, + ProblemDetail, +) + +ComparisonOp = Literal["eq", "gte", "lte", "gt", "lt", "contains", "not"] +# `is_null` is not in the design doc's operator set. It is here because there is no +# other way to ask for "max_budget IS NULL", and a table that renders nulls as +# "Unlimited" has to be able to filter on them. +FilterOp = ComparisonOp | Literal["in", "is_null"] + +FilterType = type[str] | type[int] | type[float] | type[datetime] +FilterValue = str | int | float | datetime + +PAGE_PARAM = "page" +PAGE_SIZE_PARAM = "page_size" +SORT_PARAM = "sort" +SEARCH_PARAM = "q" + +TRow = TypeVar("TRow") +TRow_co = TypeVar("TRow_co", covariant=True) +TOut = TypeVar("TOut") + +_FILTER_OP_ADAPTER: TypeAdapter[FilterOp] = TypeAdapter(FilterOp) + + +@dataclass(frozen=True, slots=True) +class Compare: + """`field value`.""" + + field: str + op: ComparisonOp + value: FilterValue + + +@dataclass(frozen=True, slots=True) +class Within: + """`field IN (values)`.""" + + field: str + values: tuple[FilterValue, ...] + + +@dataclass(frozen=True, slots=True) +class IsNull: + """`field IS NULL`, or `IS NOT NULL` when negated.""" + + field: str + negated: bool + + +@dataclass(frozen=True, slots=True) +class AnyOf: + """Disjunction of its clauses. `?q=` is the only producer today.""" + + clauses: tuple["Predicate", ...] + + +Predicate = Compare | Within | IsNull | AnyOf + + +@dataclass(frozen=True, slots=True) +class FilterSpec: + type: FilterType + ops: frozenset[FilterOp] + + +@dataclass(frozen=True, slots=True) +class SortKey: + field: str + descending: bool + + +@dataclass(frozen=True, slots=True) +class ScopeAll: + """The caller may read every row of the resource.""" + + +@dataclass(frozen=True, slots=True) +class ScopeWhere: + """The caller may read the rows matching every predicate in `where`.""" + + where: tuple[Predicate, ...] + + +@dataclass(frozen=True, slots=True) +class ScopeDenied: + """The caller may read no rows at all, and should be told so rather than shown an empty page.""" + + reason: str + + +Scope = ScopeAll | ScopeWhere | ScopeDenied + + +@dataclass(frozen=True, slots=True) +class ListSpec(Generic[TRow, TOut]): + resource: str + sortable: frozenset[str] + searchable: frozenset[str] + filters: Mapping[str, FilterSpec] + default_sort: tuple[SortKey, ...] + default_page_size: int + max_page_size: int + scope: Callable[[UserAPIKeyAuth], Scope] + serialize: Callable[[TRow], TOut] + tiebreaker: str + + def __post_init__(self) -> None: + """A malformed spec is a programming error at import time, so this raises rather than + returning a problem: there is no request in flight and no caller to answer.""" + if not 1 <= self.default_page_size <= self.max_page_size: + raise ValueError( + f"{self.resource}: default_page_size must be between 1 and max_page_size " + f"({self.max_page_size}), got {self.default_page_size}. A default above the cap " + f"would serve more rows than the resource allows whenever page_size is omitted." + ) + if not self.tiebreaker: + raise ValueError(f"{self.resource}: tiebreaker is required; it is the final sort key on every query.") + undeclared = tuple(sorted(frozenset(key.field for key in self.default_sort) - self.sortable)) + if undeclared: + raise ValueError(f"{self.resource}: default_sort orders by non-sortable field(s): {', '.join(undeclared)}.") + non_text = tuple( + sorted(field for field, spec in self.filters.items() if "contains" in spec.ops and spec.type is not str) + ) + if non_text: + raise ValueError( + f"{self.resource}: contains renders as ILIKE and is only meaningful on text columns, " + f"but is declared on: {', '.join(non_text)}." + ) + + +@dataclass(frozen=True, slots=True) +class QueryPlan: + """`where` is an implicit AND, ordered scope-first; `order` always ends with the spec's tiebreaker.""" + + where: tuple[Predicate, ...] + order: tuple[SortKey, ...] + skip: int + take: int + + +class ListExecutor(Protocol[TRow_co]): + """The database half of a list, injected so this module never imports Prisma.""" + + async def count(self, where: tuple[Predicate, ...]) -> int: ... + + async def find_many(self, plan: QueryPlan) -> Sequence[TRow_co]: ... + + +def order_by_sql(order: tuple[SortKey, ...]) -> str: + """`ORDER BY` body for a plan, NULLS LAST in both directions. + + Postgres sorts nulls last ascending but first descending, so an unqualified flip of + the sort direction drags every "Unlimited" row to the top of the table. Every field + reaching here is either a member of `ListSpec.sortable` (caller-supplied sort is + checked against it, `default_sort` at construction) or the spec's `tiebreaker`, so + these are developer-declared column names, never caller-controlled text. + """ + return ", ".join(f'"{key.field}" {"DESC" if key.descending else "ASC"} NULLS LAST' for key in order) + + +def _sql_operator(op: ComparisonOp) -> str: + match op: + case "eq": + return "=" + case "not": + return "<>" + case "gte": + return ">=" + case "lte": + return "<=" + case "gt": + return ">" + case "lt": + return "<" + case "contains": + return "ILIKE" + case _: + assert_never(op) + + +def _render(predicate: Predicate, index: int) -> tuple[str, tuple[object, ...]]: + match predicate: + case IsNull(field=field, negated=negated): + return f'"{field}" IS {"NOT NULL" if negated else "NULL"}', () + case Within(field=field, values=values): + placeholders = ", ".join(f"${index + offset}" for offset in range(len(values))) + return f'"{field}" IN ({placeholders})', values + case AnyOf(clauses=clauses): + rendered, params = _render_all(clauses, index) + return f"({' OR '.join(rendered)})", params + case Compare(field=field, op="contains", value=value): + return f"\"{field}\" ILIKE ${index} ESCAPE '\\'", (f"%{escape_like(str(value))}%",) + case Compare(field=field, op=op, value=value): + return f'"{field}" {_sql_operator(op)} ${index}', (value,) + case _: + assert_never(predicate) + + +def _render_all(predicates: tuple[Predicate, ...], index: int) -> tuple[tuple[str, ...], tuple[object, ...]]: + if not predicates: + return (), () + head, head_params = _render(predicates[0], index) + tail, tail_params = _render_all(predicates[1:], index + len(head_params)) + return (head, *tail), head_params + tail_params + + +def where_sql(where: tuple[Predicate, ...], first_index: int = 1) -> tuple[str, tuple[object, ...]]: + """`WHERE` body and its bind parameters, numbered from `first_index`. + + Returns `("", ())` when there is nothing to filter on. Every caller-supplied value + becomes a `$n` placeholder rather than being written into the SQL text; only column + names reach the text, and those come from the spec's own declarations. + """ + clauses, params = _render_all(where, first_index) + return " AND ".join(clauses), params + + +def _problem(slug: str, title: str, status: int, detail: str, allowed: tuple[str, ...] | None = None) -> ProblemDetail: + return ProblemDetail( + type=f"{PROBLEM_TYPE_BASE}{slug}", + title=title, + status=status, + detail=detail, + allowed=sorted(allowed) if allowed is not None else None, + ) + + +def _invalid(detail: str) -> ProblemDetail: + return _problem("invalid-query-parameter", "Invalid query parameter", 400, detail) + + +def _parse_filter_key(name: str) -> tuple[str, FilterOp] | None: + """`filter[max_budget][gte]` -> `("max_budget", "gte")`; bare `filter[status]` -> `("status", "eq")`.""" + if not name.startswith("filter[") or not name.endswith("]"): + return None + field, separator, raw_op = name[len("filter[") : -1].partition("][") + if not separator: + return field, "eq" + try: + return field, _FILTER_OP_ADAPTER.validate_python(raw_op) + except ValidationError: + return None + + +def _is_known_param(spec: ListSpec[TRow, TOut], name: str) -> bool: + if name in (PAGE_PARAM, PAGE_SIZE_PARAM): + return True + if name == SORT_PARAM: + return bool(spec.sortable) + if name == SEARCH_PARAM: + return bool(spec.searchable) + parsed = _parse_filter_key(name) + return parsed is not None and parsed[0] in spec.filters + + +def _allowed_params(spec: ListSpec[TRow, TOut]) -> tuple[str, ...]: + return tuple( + sorted( + (PAGE_PARAM, PAGE_SIZE_PARAM) + + ((SORT_PARAM,) if spec.sortable else ()) + + ((SEARCH_PARAM,) if spec.searchable else ()) + + tuple( + f"filter[{field}]" if op == "eq" else f"filter[{field}][{op}]" + for field, filter_spec in spec.filters.items() + for op in filter_spec.ops + ) + ) + ) + + +def _parse_positive_int(name: str, raw: str) -> int | ProblemDetail: + try: + value = int(raw) + except ValueError: + return _invalid(f"'{name}' must be an integer.") + if value < 1: + return _invalid(f"'{name}' must be 1 or greater.") + return value + + +def _parse_page(params: Mapping[str, str]) -> int | ProblemDetail: + raw = params.get(PAGE_PARAM) + return 1 if raw is None else _parse_positive_int(PAGE_PARAM, raw) + + +def _parse_page_size(spec: ListSpec[TRow, TOut], params: Mapping[str, str]) -> int | ProblemDetail: + raw = params.get(PAGE_SIZE_PARAM) + if raw is None: + return spec.default_page_size + value = _parse_positive_int(PAGE_SIZE_PARAM, raw) + if isinstance(value, ProblemDetail): + return value + return min(value, spec.max_page_size) + + +def _parse_sort(spec: ListSpec[TRow, TOut], params: Mapping[str, str]) -> tuple[SortKey, ...] | ProblemDetail: + raw = params.get(SORT_PARAM) + if raw is None: + return spec.default_sort + segments = tuple(segment.strip() for segment in raw.split(",")) + keys = tuple( + SortKey(field=segment[1:] if segment.startswith("-") else segment, descending=segment.startswith("-")) + for segment in segments + ) + rejected = tuple(sorted(frozenset(key.field for key in keys) - spec.sortable)) + if rejected: + return _problem( + "invalid-sort-field", + "Invalid sort field", + 400, + f"Cannot sort {spec.resource} by: {', '.join(repr(field) for field in rejected)}.", + tuple(spec.sortable), + ) + return keys + + +def _to_utc(value: datetime) -> datetime: + return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value.astimezone(timezone.utc) + + +def _coerce(field: str, op: FilterOp, raw: str, target: FilterType) -> FilterValue | ProblemDetail: + try: + if target is str: + return raw + if target is int: + return int(raw) + if target is float: + return float(raw) + return _to_utc(datetime.fromisoformat(raw[:-1] + "+00:00" if raw.endswith("Z") else raw)) + except ValueError: + return _invalid(f"'filter[{field}][{op}]' is not a valid {target.__name__}: {raw!r}.") + + +def _null_predicate(field: str, raw: str) -> Predicate | ProblemDetail: + if raw.lower() == "true": + return IsNull(field=field, negated=False) + if raw.lower() == "false": + return IsNull(field=field, negated=True) + return _invalid(f"'filter[{field}][is_null]' must be 'true' or 'false'.") + + +def _within_predicate(field: str, raw: str, target: FilterType) -> Predicate | ProblemDetail: + coerced = tuple(_coerce(field, "in", item.strip(), target) for item in raw.split(",")) + problems = tuple(item for item in coerced if isinstance(item, ProblemDetail)) + if problems: + return problems[0] + return Within(field=field, values=tuple(item for item in coerced if not isinstance(item, ProblemDetail))) + + +def _parse_filter(field: str, op: FilterOp, raw: str, filter_spec: FilterSpec) -> Predicate | ProblemDetail: + if op not in filter_spec.ops: + return _problem( + "unsupported-filter-operator", + "Unsupported filter operator", + 400, + f"Operator '{op}' is not supported on '{field}'.", + tuple(filter_spec.ops), + ) + if op == "is_null": + return _null_predicate(field, raw) + if op == "in": + return _within_predicate(field, raw, filter_spec.type) + value = _coerce(field, op, raw, filter_spec.type) + if isinstance(value, ProblemDetail): + return value + return Compare(field=field, op=op, value=value) + + +def _parse_filters(spec: ListSpec[TRow, TOut], params: Mapping[str, str]) -> tuple[Predicate, ...] | ProblemDetail: + keys = tuple( + (name, parsed) + for name in sorted(params) + if (parsed := _parse_filter_key(name)) is not None and parsed[0] in spec.filters + ) + parsed = tuple(_parse_filter(field, op, params[name], spec.filters[field]) for name, (field, op) in keys) + problems = tuple(item for item in parsed if isinstance(item, ProblemDetail)) + if problems: + return problems[0] + return tuple(item for item in parsed if not isinstance(item, ProblemDetail)) + + +def _search_predicate(spec: ListSpec[TRow, TOut], params: Mapping[str, str]) -> Predicate | None: + raw = params.get(SEARCH_PARAM) + if not raw: + return None + return AnyOf(clauses=tuple(Compare(field=field, op="contains", value=raw) for field in sorted(spec.searchable))) + + +def _scope_predicates(scope: Scope) -> tuple[Predicate, ...] | ProblemDetail: + match scope: + case ScopeAll(): + return () + case ScopeWhere(where=where): + return where + case ScopeDenied(reason=reason): + return _problem("forbidden", "Forbidden", 403, reason) + case _: + assert_never(scope) + + +def build_query_plan( + spec: ListSpec[TRow, TOut], + params: Mapping[str, str], + caller: UserAPIKeyAuth, +) -> QueryPlan | ProblemDetail: + """Turn query parameters into a plan, or into the problem that explains why they are not one.""" + scope_predicates = _scope_predicates(spec.scope(caller)) + if isinstance(scope_predicates, ProblemDetail): + return scope_predicates + + unknown = tuple(sorted(name for name in params if not _is_known_param(spec, name))) + if unknown: + return unknown_query_param_problem(unknown=unknown, allowed=_allowed_params(spec)) + + page = _parse_page(params) + if isinstance(page, ProblemDetail): + return page + + page_size = _parse_page_size(spec, params) + if isinstance(page_size, ProblemDetail): + return page_size + + sort = _parse_sort(spec, params) + if isinstance(sort, ProblemDetail): + return sort + + filters = _parse_filters(spec, params) + if isinstance(filters, ProblemDetail): + return filters + + search = _search_predicate(spec, params) + return QueryPlan( + # Scope first: conjuncts a caller filter sits behind and cannot replace. + where=scope_predicates + filters + ((search,) if search is not None else ()), + # Ordering by an all-null column without a unique final key lets Postgres return + # the same row on two different pages. + order=sort + (SortKey(field=spec.tiebreaker, descending=False),), + skip=(page - 1) * page_size, + take=page_size, + ) + + +def _duplicate_params(request: Request) -> tuple[str, ...]: + names = tuple(name for name, _ in request.query_params.multi_items()) + return tuple(sorted(frozenset(name for name in names if names.count(name) > 1))) + + +async def handle_list( + spec: ListSpec[TRow, TOut], + executor: ListExecutor[TRow], + request: Request, + caller: UserAPIKeyAuth, +) -> ListResponse[TOut]: + """Plan, execute, count, serialize, envelope. Failures reach the client as RFC 9457 problems.""" + plan = build_query_plan(spec=spec, params=request.query_params, caller=caller) + if isinstance(plan, ProblemDetail): + raise ManagementProblem(plan) + + # Checked here rather than in build_query_plan because a Mapping[str, str] cannot + # represent a repeat: query_params.get() silently keeps the last one, so ?page=1&page=999 + # would page from 999 without the caller ever being told which value won. + duplicates = _duplicate_params(request) + if duplicates: + raise ManagementProblem( + _problem( + "duplicate-query-parameter", + "Duplicate query parameter", + 400, + f"Repeated query parameter(s): {', '.join(duplicates)}. Each may appear once; " + f"use a comma-separated list for multiple sort keys or filter values.", + ) + ) + + total_count = await executor.count(plan.where) + rows = await executor.find_many(plan) + total_pages = ceil(total_count / plan.take) + page = plan.skip // plan.take + 1 + return ListResponse[TOut]( + data=tuple(spec.serialize(row) for row in rows), + meta=ListMeta( + total_count=total_count, + page=page, + page_size=plan.take, + total_pages=total_pages, + ), + links=build_list_links(request=request, page=page, total_pages=total_pages), + ) diff --git a/litellm/proxy/management_endpoints/management_v1/spend_logs.py b/litellm/proxy/management_endpoints/management_v1/spend_logs.py index c11a14bbfea..ccde3c4112c 100644 --- a/litellm/proxy/management_endpoints/management_v1/spend_logs.py +++ b/litellm/proxy/management_endpoints/management_v1/spend_logs.py @@ -13,6 +13,7 @@ from litellm.proxy.management_endpoints.management_v1.common import ( PROBLEM_TYPE_BASE, ManagementProblem, build_page_links, + escape_like, reject_unknown_query_params, ) from litellm.proxy.utils import PrismaClient @@ -34,10 +35,6 @@ def _as_utc(value: datetime) -> datetime: return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value.astimezone(timezone.utc) -def _escape_like(value: str) -> str: - return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") - - async def _end_user_scope_clause( user_api_key_dict: UserAPIKeyAuth, prisma_client: PrismaClient, @@ -133,7 +130,7 @@ async def list_spend_log_end_users( ) window_params: tuple[Any, ...] = (_as_utc(start_time), _as_utc(end_time)) - search_params: tuple[Any, ...] = (f"%{_escape_like(q)}%",) if q else () + search_params: tuple[Any, ...] = (f"%{escape_like(q)}%",) if q else () search_clause = (f"end_user ILIKE ${len(window_params) + 1} ESCAPE '\\'",) if q else () scope_clause, scope_params = await _end_user_scope_clause( diff --git a/litellm/types/proxy/management_endpoints/management_v1.py b/litellm/types/proxy/management_endpoints/management_v1.py index 2aecc54f114..b2244f6eb9b 100644 --- a/litellm/types/proxy/management_endpoints/management_v1.py +++ b/litellm/types/proxy/management_endpoints/management_v1.py @@ -1,7 +1,11 @@ """Shared response shapes for the `/management/v1` control-plane surface.""" +from typing import Generic, TypeVar + from pydantic import BaseModel, ConfigDict, Field +TOut = TypeVar("TOut") + class ProblemDetail(BaseModel): """RFC 9457 problem details, served as `application/problem+json`.""" @@ -37,3 +41,33 @@ class FacetListResponse(BaseModel): data: list[str] meta: PageMeta links: PageLinks + + +class ListMeta(BaseModel): + """Page-mode counterpart to `PageMeta`: an entity list pays for the COUNT(*) so the table can show a page count.""" + + total_count: int + page: int + page_size: int + total_pages: int + + +class ListLinks(BaseModel): + """Page-mode counterpart to `PageLinks`. `first`/`last` are knowable here because the total count is.""" + + model_config = ConfigDict(populate_by_name=True) + + self_link: str = Field(alias="self") + first: str + prev: str | None = None + next: str | None = None + last: str + + +class ListResponse(BaseModel, Generic[TOut]): + """Rows stay flat: JSON:API's `{type, id, attributes}` wrapper is a deliberate deviation, so every + dashboard column accessor would otherwise have to go through `.attributes`.""" + + data: list[TOut] + meta: ListMeta + links: ListLinks diff --git a/tests/test_litellm/proxy/management_endpoints/management_v1/test_list_framework.py b/tests/test_litellm/proxy/management_endpoints/management_v1/test_list_framework.py new file mode 100644 index 00000000000..35bd5517361 --- /dev/null +++ b/tests/test_litellm/proxy/management_endpoints/management_v1/test_list_framework.py @@ -0,0 +1,871 @@ +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, replace +from datetime import datetime, timezone + +import pytest +from fastapi import Request +from pydantic import BaseModel + +from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.management_endpoints.management_v1.common import ( + MANAGEMENT_V1_PREFIX, + PROBLEM_TYPE_BASE, + ManagementProblem, + build_page_links, +) +from litellm.proxy.management_endpoints.management_v1.list_framework import ( + AnyOf, + Compare, + FilterSpec, + IsNull, + ListSpec, + QueryPlan, + ScopeAll, + ScopeDenied, + ScopeWhere, + SortKey, + Within, + build_query_plan, + handle_list, + order_by_sql, + where_sql, +) +from litellm.types.proxy.management_endpoints.management_v1 import ( + PageLinks, + PageMeta, + ProblemDetail, +) + +BUDGETS_PATH = f"{MANAGEMENT_V1_PREFIX}/budgets" +CALLER = UserAPIKeyAuth(user_id="caller-1") + + +@dataclass(frozen=True, slots=True) +class BudgetRow: + budget_id: str + max_budget: float | None + created_by: str + + +class BudgetOut(BaseModel): + budget_id: str + max_budget: float | None + + +def _serialize(row: BudgetRow) -> BudgetOut: + return BudgetOut(budget_id=row.budget_id, max_budget=row.max_budget) + + +def _spec( + scope=lambda caller: ScopeAll(), + searchable=frozenset({"budget_id", "created_by"}), + sortable=frozenset({"max_budget", "created_at", "budget_id"}), +) -> ListSpec[BudgetRow, BudgetOut]: + return ListSpec( + resource="budgets", + sortable=sortable, + searchable=searchable, + filters={ + "max_budget": FilterSpec(type=float, ops=frozenset({"eq", "gte", "lte", "is_null"})), + "created_at": FilterSpec(type=datetime, ops=frozenset({"gte", "lte"})), + "created_by": FilterSpec(type=str, ops=frozenset({"eq", "in", "contains"})), + "tpm_limit": FilterSpec(type=int, ops=frozenset({"eq"})), + }, + default_sort=(SortKey(field="created_at", descending=True),), + default_page_size=25, + max_page_size=100, + scope=scope, + serialize=_serialize, + tiebreaker="budget_id", + ) + + +def _spec_with(**overrides) -> ListSpec[BudgetRow, BudgetOut]: + """`replace` re-runs `__init__`, so the spec's own validation applies to the override.""" + return replace(_spec(), **overrides) + + +class RecordingExecutor: + """In-memory stand-in for the Prisma-backed executor PR 2 supplies.""" + + def __init__(self, rows: tuple[BudgetRow, ...], total_count: int | None = None) -> None: + self.rows = rows + self.total_count = len(rows) if total_count is None else total_count + self.plan: QueryPlan | None = None + self.count_where: tuple[object, ...] | None = None + + async def count(self, where: tuple[object, ...]) -> int: + self.count_where = where + return self.total_count + + async def find_many(self, plan: QueryPlan) -> Sequence[BudgetRow]: + self.plan = plan + return self.rows[plan.skip : plan.skip + plan.take] + + +def _request(query: str = "") -> Request: + return Request( + { + "type": "http", + "method": "GET", + "scheme": "http", + "root_path": "", + "path": BUDGETS_PATH, + "query_string": query.encode(), + "headers": [(b"host", b"testserver")], + } + ) + + +def _plan(query_params: Mapping[str, str], spec: ListSpec[BudgetRow, BudgetOut] | None = None) -> QueryPlan: + result = build_query_plan(spec=spec or _spec(), params=query_params, caller=CALLER) + assert isinstance(result, QueryPlan), result + return result + + +def _problem(query_params: Mapping[str, str], spec: ListSpec[BudgetRow, BudgetOut] | None = None) -> ProblemDetail: + result = build_query_plan(spec=spec or _spec(), params=query_params, caller=CALLER) + assert isinstance(result, ProblemDetail), result + return result + + +def _conjuncts(plan: QueryPlan) -> tuple[object, ...]: + return plan.where + + +# ---------------------------------------------------------------- invariant 1 + + +def test_appends_the_tiebreaker_to_the_default_sort(): + """Without a unique final key, ordering by an all-null column lets Postgres hand the + same row back on two different pages.""" + assert _plan({}).order == (SortKey(field="created_at", descending=True), SortKey(field="budget_id", descending=False)) + + +def test_appends_the_tiebreaker_to_an_explicit_multi_key_sort(): + order = _plan({"sort": "-max_budget,created_at"}).order + + assert len(order) == 3 + assert order[-1] == SortKey(field="budget_id", descending=False) + + +def test_appends_the_tiebreaker_even_when_the_caller_already_sorts_by_it(): + """Deduplicating it away is the tempting simplification, and it is the one that + reintroduces a non-total order the moment the leading key stops being unique.""" + assert _plan({"sort": "-budget_id"}).order == ( + SortKey(field="budget_id", descending=True), + SortKey(field="budget_id", descending=False), + ) + + +# ---------------------------------------------------------------- invariant 2 + + +def test_orders_nulls_last_in_both_directions(): + """Postgres sorts nulls last ascending but first descending, so flipping the sort + direction on max_budget would otherwise float every "Unlimited" row to the top.""" + sql = order_by_sql((SortKey(field="max_budget", descending=True), SortKey(field="budget_id", descending=False))) + + assert sql == '"max_budget" DESC NULLS LAST, "budget_id" ASC NULLS LAST' + + +def test_order_sql_covers_every_key_in_the_plan(): + sql = order_by_sql(_plan({"sort": "-max_budget,created_at"}).order) + + assert sql.count("NULLS LAST") == 3 + assert sql == '"max_budget" DESC NULLS LAST, "created_at" ASC NULLS LAST, "budget_id" ASC NULLS LAST' + + +# ------------------------------------------------------------ where rendering + + +def test_every_caller_value_is_bound_not_interpolated(): + """The one property that keeps a filter value from reaching the SQL text. A value that + looks like SQL has to come back as a parameter, never as part of the statement.""" + sql, params = where_sql((Compare(field="budget_id", op="eq", value="'; DROP TABLE x --"),)) + + assert sql == '"budget_id" = $1' + assert params == ("'; DROP TABLE x --",) + assert "DROP" not in sql + + +def test_placeholders_are_numbered_across_the_whole_plan(): + """A predicate that binds several values has to advance the counter by that many, or + every later predicate reads the wrong parameter.""" + sql, params = where_sql( + ( + Compare(field="created_by", op="eq", value="alice"), + Within(field="budget_id", values=("a", "b", "c")), + Compare(field="max_budget", op="gte", value=5.0), + ) + ) + + assert sql == '"created_by" = $1 AND "budget_id" IN ($2, $3, $4) AND "max_budget" >= $5' + assert params == ("alice", "a", "b", "c", 5.0) + + +def test_placeholder_numbering_can_start_past_earlier_parameters(): + sql, params = where_sql((Compare(field="created_by", op="eq", value="alice"),), first_index=4) + + assert sql == '"created_by" = $4' + assert params == ("alice",) + + +def test_is_null_binds_no_parameter_and_does_not_consume_a_placeholder(): + sql, params = where_sql( + (IsNull(field="max_budget", negated=False), Compare(field="created_by", op="eq", value="alice")) + ) + + assert sql == '"max_budget" IS NULL AND "created_by" = $1' + assert params == ("alice",) + + +def test_is_null_negated_renders_is_not_null(): + assert where_sql((IsNull(field="max_budget", negated=True),))[0] == '"max_budget" IS NOT NULL' + + +def test_a_search_renders_as_a_parenthesised_or(): + """Without the parentheses the OR would bind looser than the surrounding ANDs and the + scope predicate would stop constraining the search branch.""" + sql, params = where_sql( + ( + Compare(field="created_by", op="eq", value="alice"), + AnyOf( + clauses=( + Compare(field="budget_id", op="contains", value="prod"), + Compare(field="created_by", op="contains", value="prod"), + ) + ), + ) + ) + + assert sql == ( + '"created_by" = $1 AND (' + "\"budget_id\" ILIKE $2 ESCAPE '\\'" + " OR " + "\"created_by\" ILIKE $3 ESCAPE '\\'" + ")" + ) + assert params == ("alice", "%prod%", "%prod%") + + +def test_contains_escapes_like_metacharacters(): + """Budget ids routinely contain '_', which is a single-character wildcard unescaped.""" + _, params = where_sql((Compare(field="budget_id", op="contains", value="device_id%"),)) + + assert params == (r"%device\_id\%%",) + + +@pytest.mark.parametrize( + ("op", "operator"), + [("eq", "="), ("not", "<>"), ("gte", ">="), ("lte", "<="), ("gt", ">"), ("lt", "<")], +) +def test_each_comparison_operator_renders_its_sql_spelling(op, operator): + assert where_sql((Compare(field="max_budget", op=op, value=1),))[0] == f'"max_budget" {operator} $1' + + +def test_an_empty_plan_renders_no_where_body(): + assert where_sql(()) == ("", ()) + + +def test_a_planned_filter_renders_end_to_end(): + """Ties the parser to the renderer: what build_query_plan produces is what executes.""" + sql, params = where_sql(_plan({"filter[max_budget][is_null]": "true", "q": "prod"}).where) + + assert sql == ( + '"max_budget" IS NULL AND (' + "\"budget_id\" ILIKE $1 ESCAPE '\\'" + " OR " + "\"created_by\" ILIKE $2 ESCAPE '\\'" + ")" + ) + assert params == ("%prod%", "%prod%") + + +# ---------------------------------------------------------------- invariant 3 + + +def test_the_scope_predicate_is_the_first_conjunct(): + spec = _spec(scope=lambda caller: ScopeWhere(where=(Compare(field="created_by", op="eq", value=caller.user_id),))) + + conjuncts = _conjuncts(_plan({"filter[max_budget][gte]": "5"}, spec=spec)) + + assert conjuncts[0] == Compare(field="created_by", op="eq", value="caller-1") + + +def test_a_caller_filter_cannot_replace_the_scope_predicate(): + """The failure this guards is a `{**scope, **filters}` merge: a caller filtering on + the scoped column would silently overwrite the scope and read another user's rows.""" + spec = _spec(scope=lambda caller: ScopeWhere(where=(Compare(field="created_by", op="eq", value=caller.user_id),))) + + conjuncts = _conjuncts(_plan({"filter[created_by][eq]": "someone-else"}, spec=spec)) + + assert conjuncts[0] == Compare(field="created_by", op="eq", value="caller-1") + assert Compare(field="created_by", op="eq", value="someone-else") in conjuncts + assert len(conjuncts) == 2 + + +def test_the_scope_predicate_survives_a_search(): + spec = _spec(scope=lambda caller: ScopeWhere(where=(Compare(field="created_by", op="eq", value=caller.user_id),))) + + conjuncts = _conjuncts(_plan({"q": "prod"}, spec=spec)) + + assert conjuncts[0] == Compare(field="created_by", op="eq", value="caller-1") + assert any(isinstance(conjunct, AnyOf) for conjunct in conjuncts) + + +def test_an_unscoped_caller_gets_no_scope_conjunct(): + assert _plan({"filter[max_budget][gte]": "5"}).where == (Compare(field="max_budget", op="gte", value=5.0),) + + +def test_an_unfiltered_unscoped_list_has_an_empty_where(): + assert _plan({}).where == () + + +# ---------------------------------------------------------------- invariant 4 + + +def test_a_denied_scope_is_a_403_problem(): + spec = _spec(scope=lambda caller: ScopeDenied(reason="Only a proxy admin can list budgets.")) + + problem = _problem({}, spec=spec) + + assert problem.status == 403 + assert problem.type == f"{PROBLEM_TYPE_BASE}forbidden" + assert problem.detail == "Only a proxy admin can list budgets." + + +@pytest.mark.asyncio +async def test_a_denied_scope_never_reaches_the_database(): + """A 200 with an empty list would tell the caller the resource is empty rather than + that they cannot read it, and would still pay for the query.""" + spec = _spec(scope=lambda caller: ScopeDenied(reason="nope")) + executor = RecordingExecutor(rows=(BudgetRow(budget_id="b1", max_budget=None, created_by="x"),)) + + with pytest.raises(ManagementProblem) as raised: + await handle_list(spec=spec, executor=executor, request=_request(), caller=CALLER) + + assert raised.value.problem.status == 403 + assert executor.plan is None + assert executor.count_where is None + + +# ---------------------------------------------------------------- invariant 5 + + +def test_page_size_falls_back_to_the_spec_default(): + assert _plan({}).take == 25 + + +def test_page_size_is_clamped_to_the_spec_maximum(): + """Clamped rather than rejected: an over-large page is a UI bug, not a caller error, + but serving it would let one request read the whole table.""" + assert _plan({"page_size": "100000"}).take == 100 + + +def test_page_offsets_by_page_size(): + plan = _plan({"page": "3", "page_size": "10"}) + + assert (plan.skip, plan.take) == (20, 10) + + +@pytest.mark.parametrize("page", ["0", "-1"], ids=["zero", "negative"]) +def test_page_below_one_is_rejected(page): + problem = _problem({"page": page}) + + assert problem.status == 400 + assert problem.type == f"{PROBLEM_TYPE_BASE}invalid-query-parameter" + + +@pytest.mark.parametrize( + "params", + [{"page": "one"}, {"page_size": "many"}, {"page_size": "0"}], + ids=["page-not-an-int", "page-size-not-an-int", "page-size-zero"], +) +def test_unusable_paging_values_are_rejected(params): + assert _problem(params).status == 400 + + +# ---------------------------------------------------------------- invariant 6 + + +def test_an_unknown_query_parameter_is_rejected_with_the_allowed_set(): + problem = _problem({"page_sizee": "10"}) + + assert problem.status == 400 + assert problem.type == f"{PROBLEM_TYPE_BASE}unknown-query-parameter" + assert "page_sizee" in problem.detail + assert problem.allowed is not None + assert "page_size" in problem.allowed + assert "filter[max_budget][gte]" in problem.allowed + + +def test_the_allowed_set_enumerates_only_operators_the_field_declares(): + problem = _problem({"nope": "1"}) + + assert problem.allowed is not None + assert "filter[max_budget][is_null]" in problem.allowed + assert "filter[tpm_limit][gte]" not in problem.allowed + assert "filter[created_by][in]" in problem.allowed + + +def test_a_filter_on_an_undeclared_field_is_an_unknown_parameter(): + problem = _problem({"filter[secret_column][eq]": "x"}) + + assert problem.type == f"{PROBLEM_TYPE_BASE}unknown-query-parameter" + assert "filter[secret_column][eq]" in problem.detail + + +def test_every_declared_parameter_is_accepted(): + """Guards the unknown-param check against rejecting the spec's own contract.""" + plan = _plan( + { + "page": "2", + "page_size": "10", + "sort": "-max_budget", + "q": "prod", + "filter[max_budget][gte]": "5", + "filter[created_by][in]": "a,b", + } + ) + + assert plan.take == 10 + + +# ---------------------------------------------------------------- invariant 7 + + +def test_sorting_by_an_undeclared_field_is_rejected(): + problem = _problem({"sort": "api_key"}) + + assert problem.status == 400 + assert problem.type == f"{PROBLEM_TYPE_BASE}invalid-sort-field" + assert problem.allowed == ["budget_id", "created_at", "max_budget"] + assert "api_key" in problem.detail + + +def test_one_bad_key_rejects_the_whole_multi_key_sort(): + """Dropping the unknown key and sorting by the rest would silently return a + differently-ordered page than the one asked for.""" + assert _problem({"sort": "-created_at,api_key"}).type == f"{PROBLEM_TYPE_BASE}invalid-sort-field" + + +def test_a_double_dash_prefix_is_not_a_descending_sort(): + assert _problem({"sort": "--created_at"}).type == f"{PROBLEM_TYPE_BASE}invalid-sort-field" + + +# ---------------------------------------------------------------- invariant 8 + + +def test_an_operator_the_field_does_not_declare_is_rejected(): + problem = _problem({"filter[max_budget][contains]": "5"}) + + assert problem.status == 400 + assert problem.type == f"{PROBLEM_TYPE_BASE}unsupported-filter-operator" + assert problem.allowed == ["eq", "gte", "is_null", "lte"] + assert "contains" in problem.detail + + +def test_the_same_operator_is_accepted_on_a_field_that_declares_it(): + """Pins the rejection to the field's own operator set rather than a global denylist.""" + conjuncts = _conjuncts(_plan({"filter[created_by][contains]": "ops"})) + + assert conjuncts == (Compare(field="created_by", op="contains", value="ops"),) + + +def test_a_string_that_is_not_an_operator_at_all_is_an_unknown_parameter(): + """`gt3` is a typo, not an operator the field withheld, so the useful reply is the + parameter list rather than this field's operator set.""" + problem = _problem({"filter[max_budget][gt3]": "5"}) + + assert problem.type == f"{PROBLEM_TYPE_BASE}unknown-query-parameter" + assert problem.allowed is not None + assert "filter[max_budget][gte]" in problem.allowed + + +# ---------------------------------------------------------------- invariant 9 + + +def test_search_against_a_spec_with_nothing_searchable_is_rejected(): + """A silently-empty search filter returns the unfiltered table, which reads as + "no results were filtered out" rather than "this resource cannot be searched".""" + problem = _problem({"q": "prod"}, spec=_spec(searchable=frozenset())) + + assert problem.status == 400 + assert problem.type == f"{PROBLEM_TYPE_BASE}unknown-query-parameter" + assert problem.allowed is not None + assert "q" not in problem.allowed + + +def test_search_is_a_case_insensitive_or_across_every_searchable_field(): + conjuncts = _conjuncts(_plan({"q": "Prod"})) + + assert conjuncts == ( + AnyOf( + clauses=( + Compare(field="budget_id", op="contains", value="Prod"), + Compare(field="created_by", op="contains", value="Prod"), + ) + ), + ) + + +def test_an_empty_search_string_adds_no_filter(): + assert _plan({"q": ""}).where == () + + +# --------------------------------------------------------------- invariant 10 + + +def test_multi_key_sort_parses_the_json_api_grammar(): + order = _plan({"sort": "-created_at,budget_id,-max_budget"}).order + + assert order[:3] == ( + SortKey(field="created_at", descending=True), + SortKey(field="budget_id", descending=False), + SortKey(field="max_budget", descending=True), + ) + + +def test_sort_segments_tolerate_surrounding_whitespace(): + assert _plan({"sort": "-created_at, budget_id"}).order[:2] == ( + SortKey(field="created_at", descending=True), + SortKey(field="budget_id", descending=False), + ) + + +# ------------------------------------------------------------- filter parsing + + +def test_comparison_operators_become_prisma_range_fragments(): + conjuncts = _conjuncts(_plan({"filter[max_budget][gte]": "5", "filter[max_budget][lte]": "50"})) + + assert conjuncts == ( + Compare(field="max_budget", op="gte", value=5.0), + Compare(field="max_budget", op="lte", value=50.0), + ) + + +def test_eq_is_a_bare_value_not_a_wrapped_one(): + assert _conjuncts(_plan({"filter[tpm_limit][eq]": "100"})) == (Compare(field="tpm_limit", op="eq", value=100),) + + +def test_a_filter_with_no_operator_bracket_means_eq(): + """`filter[status]=active` is the design doc's canonical spelling for equality; + only the non-eq operators carry a second bracket.""" + assert _conjuncts(_plan({"filter[tpm_limit]": "100"})) == (Compare(field="tpm_limit", op="eq", value=100),) + + +def test_the_bare_form_and_the_explicit_eq_form_agree(): + assert _plan({"filter[created_by]": "alice"}) == _plan({"filter[created_by][eq]": "alice"}) + + +def test_the_bare_form_still_coerces_to_the_declared_type(): + assert _problem({"filter[tpm_limit]": "1.5"}).status == 400 + + +def test_the_bare_form_is_rejected_on_a_field_that_does_not_declare_eq(): + """The shorthand is sugar for the eq operator, not a bypass around the operator set.""" + problem = _problem({"filter[created_at]": "2026-07-23T00:00:00Z"}) + + assert problem.type == f"{PROBLEM_TYPE_BASE}unsupported-filter-operator" + assert problem.allowed == ["gte", "lte"] + + +def test_the_allowed_set_advertises_the_bare_spelling_for_eq(): + allowed = _problem({"nope": "1"}).allowed + + assert allowed is not None + assert "filter[max_budget]" in allowed + assert "filter[max_budget][eq]" not in allowed + assert "filter[created_at][gte]" in allowed + assert "filter[created_at]" not in allowed + + +@pytest.mark.parametrize( + "name", + ["filter[]", "filter[a][b][c]", "filter[a][", "filter", "filter[a][gte", "filter[max_budget]]["], + ids=["empty", "triple", "unbalanced", "bare-word", "unterminated", "bracketed-field"], +) +def test_malformed_filter_keys_are_unknown_parameters_not_eq_filters(name): + """A malformed key must not fall through to the bare-eq branch and silently filter + on a field nobody declared. `field in spec.filters` is the gate that makes this hold, + which is also why the parser needs no separate well-formedness guard.""" + assert _problem({name: "x"}).type == f"{PROBLEM_TYPE_BASE}unknown-query-parameter" + + +def test_in_splits_on_commas_and_coerces_every_member(): + assert _conjuncts(_plan({"filter[created_by][in]": "alice, bob"})) == ( + Within(field="created_by", values=("alice", "bob")), + ) + + +def test_is_null_true_matches_rows_with_no_budget(): + """Budgets renders a null max_budget as "Unlimited"; without is_null there is no way + to ask for those rows.""" + assert _conjuncts(_plan({"filter[max_budget][is_null]": "true"})) == ( + IsNull(field="max_budget", negated=False), + ) + + +def test_is_null_false_matches_rows_that_have_one(): + assert _conjuncts(_plan({"filter[max_budget][is_null]": "false"})) == ( + IsNull(field="max_budget", negated=True), + ) + + +def test_is_null_rejects_a_non_boolean(): + assert _problem({"filter[max_budget][is_null]": "maybe"}).status == 400 + + +@pytest.mark.parametrize( + "params", + [ + {"filter[max_budget][gte]": "lots"}, + {"filter[tpm_limit][eq]": "1.5"}, + {"filter[created_at][gte]": "yesterday"}, + {"filter[created_by][in]": "alice,"}, + ], + ids=["float", "int", "datetime", "in-member"], +) +def test_a_value_that_does_not_match_the_declared_type_is_rejected(params): + numeric_in = _spec_with(filters={**_spec().filters, "created_by": FilterSpec(type=int, ops=frozenset({"in"}))}) + target = numeric_in if "filter[created_by][in]" in params else _spec() + + assert _problem(params, spec=target).status == 400 + + +def test_a_datetime_filter_is_normalised_to_utc(): + """The dashboard sends both offset-bearing and naive timestamps; reading a naive one + as server-local time would shift the window off what the table is showing.""" + with_offset = _conjuncts(_plan({"filter[created_at][gte]": "2026-07-23T02:00:00+02:00"})) + naive = _conjuncts(_plan({"filter[created_at][gte]": "2026-07-23 00:00:00"})) + + assert with_offset == (Compare(field="created_at", op="gte", value=datetime(2026, 7, 23, tzinfo=timezone.utc)),) + assert naive == with_offset + + +def test_filters_are_ordered_deterministically(): + """Two requests differing only in query-string order must plan identically, or the + plan stops being a comparable value.""" + forwards = _plan({"filter[created_by][eq]": "a", "filter[max_budget][gte]": "5"}) + backwards = _plan({"filter[max_budget][gte]": "5", "filter[created_by][eq]": "a"}) + + assert forwards == backwards + + +# ------------------------------------------------------------------- envelope + + +@pytest.mark.asyncio +async def test_returns_the_page_mode_envelope(): + executor = RecordingExecutor( + rows=tuple(BudgetRow(budget_id=f"b{i}", max_budget=float(i), created_by="u") for i in range(10)), + total_count=42, + ) + + response = await handle_list( + spec=_spec(), executor=executor, request=_request("page=2&page_size=5"), caller=CALLER + ) + body = response.model_dump(by_alias=True) + + assert body["meta"] == {"total_count": 42, "page": 2, "page_size": 5, "total_pages": 9} + assert set(body) == {"data", "meta", "links"} + assert "has_more" not in body["meta"] + + +@pytest.mark.asyncio +async def test_serializes_rows_flat_without_a_json_api_resource_wrapper(): + executor = RecordingExecutor(rows=(BudgetRow(budget_id="b1", max_budget=None, created_by="u"),)) + + response = await handle_list(spec=_spec(), executor=executor, request=_request(), caller=CALLER) + body = response.model_dump(by_alias=True) + + assert body["data"] == [{"budget_id": "b1", "max_budget": None}] + assert "attributes" not in body["data"][0] + assert "created_by" not in body["data"][0] + + +@pytest.mark.asyncio +async def test_links_let_a_client_page_without_building_urls(): + executor = RecordingExecutor(rows=(), total_count=42) + + response = await handle_list( + spec=_spec(), executor=executor, request=_request("page=2&page_size=5"), caller=CALLER + ) + links = response.model_dump(by_alias=True)["links"] + + assert links["self"] == f"{BUDGETS_PATH}?page_size=5&page=2" + assert links["first"] == f"{BUDGETS_PATH}?page_size=5&page=1" + assert links["prev"] == f"{BUDGETS_PATH}?page_size=5&page=1" + assert links["next"] == f"{BUDGETS_PATH}?page_size=5&page=3" + assert links["last"] == f"{BUDGETS_PATH}?page_size=5&page=9" + + +@pytest.mark.asyncio +async def test_the_last_page_has_no_next_link(): + executor = RecordingExecutor(rows=(), total_count=10) + + response = await handle_list( + spec=_spec(), executor=executor, request=_request("page=2&page_size=5"), caller=CALLER + ) + links = response.model_dump(by_alias=True)["links"] + + assert links["next"] is None + assert links["prev"] == f"{BUDGETS_PATH}?page_size=5&page=1" + + +@pytest.mark.asyncio +async def test_an_empty_result_set_still_resolves_every_link(): + executor = RecordingExecutor(rows=(), total_count=0) + + response = await handle_list(spec=_spec(), executor=executor, request=_request(), caller=CALLER) + body = response.model_dump(by_alias=True) + + assert body["data"] == [] + assert body["meta"]["total_pages"] == 0 + assert body["links"]["first"] == body["links"]["last"] == f"{BUDGETS_PATH}?page=1" + assert body["links"]["next"] is None + assert body["links"]["prev"] is None + + +@pytest.mark.asyncio +async def test_the_executor_counts_the_same_predicate_it_reads(): + """Counting a wider predicate than the read inflates total_pages and hands the UI + pages that are always empty.""" + executor = RecordingExecutor(rows=(), total_count=3) + spec = _spec(scope=lambda caller: ScopeWhere(where=(Compare(field="created_by", op="eq", value=caller.user_id),))) + + await handle_list(spec=spec, executor=executor, request=_request("filter[max_budget][gte]=5"), caller=CALLER) + + assert executor.plan is not None + assert executor.count_where == executor.plan.where + + +@pytest.mark.asyncio +async def test_a_rejected_request_is_raised_as_a_problem_before_any_query(): + executor = RecordingExecutor(rows=()) + + with pytest.raises(ManagementProblem) as raised: + await handle_list(spec=_spec(), executor=executor, request=_request("sort=api_key"), caller=CALLER) + + assert raised.value.problem.status == 400 + assert executor.count_where is None + + +# ------------------------------------------------------- spec construction + + +def test_a_default_page_size_above_the_cap_is_rejected_at_construction(): + """The cap is only enforced on a supplied page_size, so a default above it would serve + more rows than the resource allows on exactly the request that omits page_size.""" + with pytest.raises(ValueError, match="default_page_size"): + _spec_with(default_page_size=200, max_page_size=100) + + +@pytest.mark.parametrize( + "overrides", + [{"default_page_size": 0}, {"default_page_size": -5}, {"max_page_size": 0}], + ids=["zero-default", "negative-default", "zero-cap"], +) +def test_a_non_positive_page_size_is_rejected_at_construction(overrides): + """take=0 divides by zero when handle_list computes total_pages, so the resource would + 500 on every request instead of failing when it is registered.""" + with pytest.raises(ValueError, match="default_page_size"): + _spec_with(**overrides) + + +def test_a_default_sort_on_a_non_sortable_field_is_rejected_at_construction(): + """Caller-supplied sort is validated against `sortable`; default_sort is not read from + the request, so without this it reaches order_by_sql and yields invalid SQL.""" + with pytest.raises(ValueError, match="default_sort"): + _spec_with(default_sort=(SortKey(field="not_a_column", descending=True),)) + + +def test_an_empty_tiebreaker_is_rejected_at_construction(): + with pytest.raises(ValueError, match="tiebreaker"): + _spec_with(tiebreaker="") + + +def test_a_page_size_equal_to_the_cap_is_a_valid_spec(): + """Guards the bound against being tightened into an off-by-one that bans max==default.""" + assert _spec_with(default_page_size=100, max_page_size=100).default_page_size == 100 + + +# ------------------------------------------------------ repeated parameters + + +@pytest.mark.asyncio +async def test_a_repeated_query_parameter_is_rejected(): + """Starlette keeps the last value, so ?page=1&page=999 would page from 999 with nothing + telling the caller which one won. The doc rejects silently-altered params for this reason.""" + executor = RecordingExecutor(rows=()) + + with pytest.raises(ManagementProblem) as raised: + await handle_list(spec=_spec(), executor=executor, request=_request("page=1&page=999"), caller=CALLER) + + assert raised.value.problem.status == 400 + assert raised.value.problem.type == f"{PROBLEM_TYPE_BASE}duplicate-query-parameter" + assert "page" in raised.value.problem.detail + assert executor.count_where is None + + +@pytest.mark.asyncio +async def test_a_repeated_filter_parameter_is_rejected(): + executor = RecordingExecutor(rows=()) + + with pytest.raises(ManagementProblem) as raised: + await handle_list( + spec=_spec(), + executor=executor, + request=_request("filter[created_by][eq]=alice&filter[created_by][eq]=bob"), + caller=CALLER, + ) + + assert raised.value.problem.type == f"{PROBLEM_TYPE_BASE}duplicate-query-parameter" + assert "filter[created_by][eq]" in raised.value.problem.detail + + +@pytest.mark.asyncio +async def test_distinct_parameters_are_not_treated_as_duplicates(): + """Guards the check against rejecting two different operators on one field, which is + how a range filter is expressed.""" + executor = RecordingExecutor(rows=(), total_count=0) + + response = await handle_list( + spec=_spec(), + executor=executor, + request=_request("filter[max_budget][gte]=5&filter[max_budget][lte]=50&page=2"), + caller=CALLER, + ) + + assert response.meta.page == 2 + assert executor.count_where is not None + + +@pytest.mark.asyncio +async def test_a_denied_scope_outranks_a_duplicate_parameter(): + """Permission is the stronger statement about the caller, so it is answered first.""" + spec = _spec(scope=lambda caller: ScopeDenied(reason="nope")) + executor = RecordingExecutor(rows=()) + + with pytest.raises(ManagementProblem) as raised: + await handle_list(spec=spec, executor=executor, request=_request("page=1&page=2"), caller=CALLER) + + assert raised.value.problem.status == 403 + + +# --------------------------------------------------- facet-mode regression + + +def test_the_facet_page_shapes_are_untouched_by_page_mode(): + """The live facet endpoint reports `has_more` and has no first/last, because it + deliberately skips the COUNT(*). Folding it into the page-mode shapes would either + break its response or make every keystroke pay for a full-table count.""" + assert set(PageMeta.model_fields) == {"page", "page_size", "has_more"} + assert set(PageLinks.model_fields) == {"self_link", "prev", "next"} + + links = build_page_links(request=_request("q=ac&page=2"), page=2, has_more=True).model_dump(by_alias=True) + + assert set(links) == {"self", "prev", "next"} + assert links["next"] == "/management/v1/budgets?q=ac&page=3" From 16507f11742144aa6c66c8239a05279b992cd597 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Fri, 31 Jul 2026 09:48:08 -0700 Subject: [PATCH 12/37] fix(aiohttp): dispose recycled client sessions deterministically (#33428) * fix(aiohttp): dispose recycled client sessions deterministically LiteLLMAiohttpTransport replaced its cached aiohttp.ClientSession on loop-mismatch, loop-inspection failure, and "Session is closed" retry without reliably closing the previous session: - the close task from asyncio.create_task() was never referenced, so it could be garbage-collected before running; - the (RuntimeError, AttributeError) fallback branch replaced the session without closing it at all; - sessions bound to a closed event loop were abandoned to the GC ("rely on GC"), and sessions bound to a loop running in another thread were closed from the wrong loop. Replaced sessions surfaced as intermittent "Unclosed client session" / "Unclosed connector" errors from the event-loop exception handler at GC time. _close_recycled_session() now covers the three lifecycles a recycled session can be in: same-loop closes keep a strong task reference until completion; sessions owned by a loop running elsewhere are closed on their own loop via run_coroutine_threadsafe; sessions whose loop is gone are disposed synchronously through the connector teardown that aiohttp's own finalizer uses, which releases pooled connections and silences the finalizer warnings. Fixes #24230 * fix(aiohttp): guard threadsafe close callback against cancelled futures --------- Co-authored-by: Anmol Jaiswal <68013660+anmolg1997@users.noreply.github.com> --- .../llms/custom_httpx/aiohttp_transport.py | 123 ++++++- .../custom_httpx/test_aiohttp_transport.py | 303 ++++++++++++++++++ 2 files changed, 416 insertions(+), 10 deletions(-) diff --git a/litellm/llms/custom_httpx/aiohttp_transport.py b/litellm/llms/custom_httpx/aiohttp_transport.py index df5b10b3bdc..2c5f455692c 100644 --- a/litellm/llms/custom_httpx/aiohttp_transport.py +++ b/litellm/llms/custom_httpx/aiohttp_transport.py @@ -1,10 +1,11 @@ import asyncio +import concurrent.futures import contextlib import os import ssl import typing import urllib.request -from typing import Any, Callable, Dict, Optional, Union +from typing import Any, Callable, ClassVar, Dict, Optional, Union import aiohttp import aiohttp.client_exceptions @@ -138,6 +139,11 @@ class LiteLLMAiohttpTransport(AiohttpTransport): Credit to: https://github.com/karpetrosyan/httpx-aiohttp for this implementation """ + # Strong references to scheduled session-close tasks. A bare + # asyncio.create_task() result may be garbage-collected before it runs, + # leaving the recycled session unclosed ("Unclosed client session"). + _background_close_tasks: ClassVar[set["asyncio.Task[None]"]] = set() # mutable-ok: strong refs for pending closes + def __init__( self, client: Union[ClientSession, Callable[[], ClientSession]], @@ -164,6 +170,92 @@ class LiteLLMAiohttpTransport(AiohttpTransport): self._owns_session = True return session + @classmethod + def _on_close_task_done(cls, task: "asyncio.Task[None]") -> None: + cls._background_close_tasks.discard(task) + if task.cancelled(): + return + exc = task.exception() + if exc is not None: + verbose_logger.debug("Error closing recycled aiohttp session: %s", exc) + + @staticmethod + def _on_threadsafe_close_done(future: "concurrent.futures.Future[None]") -> None: + if future.cancelled(): + return + exc = future.exception() + if exc is not None: + verbose_logger.debug("Error closing recycled aiohttp session on its own loop: %s", exc) + + @staticmethod + def _mark_connector_closed(session: ClientSession) -> None: + """Synchronously dispose a session whose event loop is gone. + + An async close can no longer run on a closed loop. BaseConnector._close + is the same synchronous teardown aiohttp's own finalizer (__del__) + uses: it is guarded for closed loops, releases pooled connections, and + flips the flags that ClientSession.closed / BaseConnector.closed read - + so no "Unclosed client session" / "Unclosed connector" warnings reach + the event-loop exception handler at garbage collection. + """ + connector = getattr(session, "_connector", None) + close_sync = getattr(connector, "_close", None) + if not callable(close_sync): + return + try: + close_sync() + except (RuntimeError, AttributeError, OSError) as e: + verbose_logger.debug("Best-effort connector close failed: %s", e) + + def _close_recycled_session(self, session: ClientSession) -> None: + """Deterministically dispose a ClientSession this transport is replacing. + + Covers the three lifecycles a recycled session can be in: + - its loop is the current running loop: schedule an async close and keep + a strong reference to the task until it completes; + - its loop is still running elsewhere (e.g. another thread): hand the + close to that loop thread-safely; + - its loop is stopped or closed, or there is no running loop: fall + back to the synchronous finalizer-safe teardown. + """ + if session.closed: + return + + session_loop = getattr(session, "_loop", None) + try: + current_loop: Optional[asyncio.AbstractEventLoop] = asyncio.get_running_loop() + except RuntimeError: + current_loop = None + + if session_loop is not None and session_loop is not current_loop: + if not session_loop.is_closed() and session_loop.is_running(): + # The session's loop is running somewhere else (e.g. another + # thread): closing from here would touch that loop's internals + # unsafely; hand the close to its own loop. + try: + future = asyncio.run_coroutine_threadsafe(session.close(), session_loop) + except RuntimeError as e: # loop shut down between the checks + verbose_logger.debug("Threadsafe session close failed: %s", e) + self._mark_connector_closed(session) + else: + future.add_done_callback(self._on_threadsafe_close_done) + return + + # Foreign loop that is stopped or closed: an async close can no + # longer run there, and running it on the current loop would touch + # another loop's internals. Dispose synchronously instead. + self._mark_connector_closed(session) + return + + if current_loop is None: + self._mark_connector_closed(session) + return + + task = current_loop.create_task(session.close()) + cls = type(self) + cls._background_close_tasks.add(task) + task.add_done_callback(cls._on_close_task_done) + def _get_valid_client_session(self) -> ClientSession: """ Helper to get a valid ClientSession for the current event loop. @@ -193,21 +285,25 @@ class LiteLLMAiohttpTransport(AiohttpTransport): # Close old session to prevent leaks old_session = self.client try: - if self._owns_session and not old_session.closed: - try: - asyncio.create_task(old_session.close()) - except RuntimeError: - # Different event loop - can't schedule task, rely on GC - verbose_logger.debug("Old session from different loop, relying on GC") + if self._owns_session: + self._close_recycled_session(old_session) except Exception as e: verbose_logger.debug(f"Error closing old session: {e}") # Create a new session in the current event loop self.client = self._rebuild_session() - except (RuntimeError, AttributeError): - # If we can't check the loop or session is invalid, recreate it + except (RuntimeError, AttributeError) as e: + # If we can't check the loop or session is invalid, recreate it, + # but still dispose of the session being replaced. + old_session = self.client + if self._owns_session: + try: + self._close_recycled_session(old_session) + except (RuntimeError, AttributeError, OSError) as close_error: + verbose_logger.debug(f"Error closing old session: {close_error}") self.client = self._rebuild_session() + verbose_logger.debug(f"Error checking session loop, created new session: {e}") return self.client @@ -301,7 +397,14 @@ class LiteLLMAiohttpTransport(AiohttpTransport): # Handle the case where session was closed between our check and actual use if "Session is closed" in str(e): verbose_logger.debug(f"Session closed during request, retrying with new session: {e}") - # Force creation of a new session + # Dispose of the session that actually faulted. Do NOT read + # self.client here: a concurrent task may already have + # replaced it with a healthy session that must stay open. + # Guarded by isinstance: factory-injected sessions may be + # duck-typed test doubles without a close() coroutine. + # Read _owns_session before _rebuild_session() claims ownership. + if self._owns_session and isinstance(client_session, ClientSession): + self._close_recycled_session(client_session) self.client = self._rebuild_session() client_session = self.client diff --git a/tests/test_litellm/llms/custom_httpx/test_aiohttp_transport.py b/tests/test_litellm/llms/custom_httpx/test_aiohttp_transport.py index 0550898c73d..b0b092a541f 100644 --- a/tests/test_litellm/llms/custom_httpx/test_aiohttp_transport.py +++ b/tests/test_litellm/llms/custom_httpx/test_aiohttp_transport.py @@ -1,4 +1,5 @@ import asyncio +import concurrent.futures import os import sys @@ -827,3 +828,305 @@ async def test_stale_loop_rebuild_does_not_close_unowned_session(): shared_session._loop = running_loop other_loop.close() await shared_session.close() + + +# --------------------------------------------------------------------------- +# Recycled-session leak tests (#24230) +# --------------------------------------------------------------------------- + + +async def _new_session() -> aiohttp.ClientSession: + return aiohttp.ClientSession() + + +def _make_session_on_dead_loop() -> aiohttp.ClientSession: + """Create a ClientSession bound to an event loop that is then closed. + + Runs in a worker thread: the caller may already be inside a running + event loop, where a nested run_until_complete is forbidden. + """ + import threading + + result: dict = {} + + def build() -> None: + loop = asyncio.new_event_loop() + try: + result["session"] = loop.run_until_complete(_new_session()) + finally: + loop.close() + + thread = threading.Thread(target=build) + thread.start() + thread.join(5) + return result["session"] + + +def _flaky_get_running_loop_factory(): + """get_running_loop stand-in that fails once, then delegates. + + Reproduces #24230: a transient loop-inspection failure sends + _get_valid_client_session into its (RuntimeError, AttributeError) + fallback branch. + """ + real_get_running_loop = asyncio.get_running_loop + calls = {"count": 0} + + def flaky(): + calls["count"] += 1 + if calls["count"] == 1: + raise RuntimeError("simulated loop inspection failure") + return real_get_running_loop() + + return flaky + + +@pytest.mark.asyncio +async def test_fallback_recreate_closes_previous_session(): + """ + Regression test for #24230: when loop inspection fails and the fallback + branch recreates the session, the replaced session must still be closed - + not silently abandoned to the garbage collector. + """ + from unittest.mock import patch + + old_session = aiohttp.ClientSession() + transport = LiteLLMAiohttpTransport(client=lambda: aiohttp.ClientSession()) + transport.client = old_session + + with patch( + "litellm.llms.custom_httpx.aiohttp_transport.asyncio.get_running_loop", + side_effect=_flaky_get_running_loop_factory(), + ): + new_session = transport._get_valid_client_session() + + try: + assert new_session is not old_session + for _ in range(3): + await asyncio.sleep(0) + assert old_session.closed, "replaced session must be closed, not leaked" + finally: + await new_session.close() + if not old_session.closed: + await old_session.close() + + +@pytest.mark.asyncio +async def test_replaced_session_emits_no_unclosed_warnings(): + """ + Regression test for #24230: a session replaced by the fallback branch must + not surface "Unclosed client session" / "Unclosed connector" warnings when + the garbage collector finalizes it. + """ + import gc + import warnings as warnings_mod + from unittest.mock import patch + + old_session = aiohttp.ClientSession() + transport = LiteLLMAiohttpTransport(client=lambda: aiohttp.ClientSession()) + transport.client = old_session + + with patch( + "litellm.llms.custom_httpx.aiohttp_transport.asyncio.get_running_loop", + side_effect=_flaky_get_running_loop_factory(), + ): + new_session = transport._get_valid_client_session() + + try: + for _ in range(3): + await asyncio.sleep(0) + + del old_session + with warnings_mod.catch_warnings(record=True) as caught: + warnings_mod.simplefilter("always") + gc.collect() + + unclosed = [ + str(w.message) + for w in caught + if "Unclosed client session" in str(w.message) or "Unclosed connector" in str(w.message) + ] + assert not unclosed, f"leaked session warnings: {unclosed}" + finally: + await new_session.close() + + +@pytest.mark.asyncio +async def test_dead_loop_session_closed_synchronously_on_recycle(): + """ + Regression test for #24230: a session whose event loop is already closed + cannot run an async close anywhere. Recycling it must dispose of it + deterministically, the session reads closed as soon as the recycle + returns, so no finalizer warning window remains. + """ + old_session = _make_session_on_dead_loop() + transport = LiteLLMAiohttpTransport(client=lambda: aiohttp.ClientSession()) + transport.client = old_session + + new_session = transport._get_valid_client_session() + + try: + assert new_session is not old_session + assert old_session.closed, "session from a closed loop must be disposed synchronously at recycle" + finally: + await new_session.close() + + +@pytest.mark.asyncio +async def test_close_task_strongly_referenced_until_done(): + """ + Regression test for #24230: scheduled session-close tasks must be strongly + referenced (and pruned on completion) so they cannot be garbage-collected + before they run. + """ + old_session = aiohttp.ClientSession() + transport = LiteLLMAiohttpTransport(client=lambda: aiohttp.ClientSession()) + + transport._close_recycled_session(old_session) + + assert LiteLLMAiohttpTransport._background_close_tasks, "close task must be strongly referenced while pending" + for _ in range(5): + await asyncio.sleep(0) + assert old_session.closed + assert not LiteLLMAiohttpTransport._background_close_tasks, "completed close tasks must be pruned from the registry" + + +@pytest.mark.asyncio +async def test_session_from_other_running_loop_closed_threadsafe(): + """ + Regression test for #24230: a session that belongs to a loop still running + in another thread must be closed on its own loop (thread-safe), not driven + from the current loop. + """ + import threading + import time + + ready = threading.Event() + holder: dict = {} + + def worker() -> None: + loop = asyncio.new_event_loop() + holder["loop"] = loop + + async def make() -> None: + holder["session"] = aiohttp.ClientSession() + + loop.run_until_complete(make()) + ready.set() + loop.run_forever() + loop.close() + + thread = threading.Thread(target=worker, daemon=True) + thread.start() + assert ready.wait(5), "worker loop failed to start" + + transport = LiteLLMAiohttpTransport(client=lambda: aiohttp.ClientSession()) + transport.client = holder["session"] + + new_session = transport._get_valid_client_session() + + try: + deadline = time.monotonic() + 5 + while not holder["session"].closed and time.monotonic() < deadline: + await asyncio.sleep(0.01) + assert holder["session"].closed, "foreign-loop session was never closed" + finally: + holder["loop"].call_soon_threadsafe(holder["loop"].stop) + thread.join(5) + await new_session.close() + + +def test_threadsafe_close_done_callback_tolerates_cancelled_future(): + """ + Regression test for #24230 (review finding): when the foreign loop stops + before the handed-off close coroutine runs, asyncio cancels the + concurrent.futures.Future. The done-callback must return quietly instead + of letting future.exception() raise CancelledError (a BaseException that + escapes _invoke_callbacks and crashes the foreign loop's thread). + """ + future: "concurrent.futures.Future[None]" = concurrent.futures.Future() + future.cancel() + + LiteLLMAiohttpTransport._on_threadsafe_close_done(future) + + +@pytest.mark.asyncio +async def test_session_closed_retry_does_not_close_concurrent_replacement(): + """ + Regression test for #24230 (review finding): when the "Session is closed" + retry fires, the handler must dispose the session that actually faulted, + not self.client - a concurrent task may already have replaced self.client + with a healthy session, which must stay open. + """ + from unittest.mock import patch + + faulted_session = aiohttp.ClientSession() + healthy_replacement = aiohttp.ClientSession() + transport = LiteLLMAiohttpTransport(client=lambda: aiohttp.ClientSession()) + transport.client = faulted_session + + calls = {"n": 0} + + async def fake_make_request(*args, **kwargs): + calls["n"] += 1 + if calls["n"] == 1: + # simulate a concurrent task replacing the shared session between + # the failed await and the exception handler + transport.client = healthy_replacement + raise RuntimeError("Session is closed") + raise StopAsyncIteration("stop after retry dispatch") + + with patch.object(transport, "_make_aiohttp_request", side_effect=fake_make_request): + with pytest.raises(Exception): + await transport.handle_async_request(httpx.Request("GET", "http://example.com")) + + try: + assert not healthy_replacement.closed, "concurrent replacement session must not be closed by the retry handler" + for _ in range(3): + await asyncio.sleep(0) + assert faulted_session.closed, "the faulted session must be disposed" + finally: + await faulted_session.close() + await healthy_replacement.close() + new_session = transport.client + if isinstance(new_session, aiohttp.ClientSession): + await new_session.close() + + +@pytest.mark.asyncio +async def test_stopped_loop_session_disposed_synchronously_on_recycle(): + """ + Regression test for #24230 (review finding): a session whose loop is + stopped but not yet closed cannot safely run an async close on another + loop, and nothing will ever process a close handed to the stopped loop. + Recycling must dispose it synchronously, like the closed-loop case. + """ + import threading + + result: dict = {} + + def build() -> None: + loop = asyncio.new_event_loop() + + async def make() -> None: + result["session"] = aiohttp.ClientSession() + + loop.run_until_complete(make()) + result["loop"] = loop # stopped, deliberately NOT closed + + thread = threading.Thread(target=build) + thread.start() + thread.join(5) + + old_session = result["session"] + transport = LiteLLMAiohttpTransport(client=lambda: aiohttp.ClientSession()) + transport.client = old_session + + new_session = transport._get_valid_client_session() + + try: + assert new_session is not old_session + assert old_session.closed, "session from a stopped (not yet closed) loop must be disposed synchronously" + finally: + await new_session.close() + result["loop"].close() From d9f53258e9f3a3281706b4692a42c660f01e4e21 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 31 Jul 2026 09:52:46 -0700 Subject: [PATCH 13/37] refactor(test): tighten typing on the tag list verification token double Replaces the double's Any annotations and List/Dict aliases with concrete types, matching the equivalent double in the tool policy tests: kwargs are object, records are Sequence[Mock] held as a tuple, and the call log is list[dict[str, object]]. Behaviour is unchanged; the double still binds every call against the real generated prisma action signature, verified by reintroducing the select kwarg and watching the regression tests fail --- .../test_tag_management_endpoints.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/test_litellm/proxy/management_endpoints/test_tag_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_tag_management_endpoints.py index 9927c56b847..4fe1b54694f 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_tag_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_tag_management_endpoints.py @@ -2,7 +2,8 @@ import inspect import json import os import sys -from typing import Any, Dict, List, Optional +from collections.abc import Sequence +from typing import Optional import pytest from fastapi import HTTPException @@ -13,7 +14,7 @@ sys.path.insert( 0, os.path.abspath("../../../..") ) # Adds the parent directory to the system path -from unittest.mock import patch +from unittest.mock import Mock, patch import litellm from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth @@ -33,11 +34,11 @@ class FakeVerificationTokenTable: surfaces as an HTTP 500. """ - def __init__(self, records: List[Any]): - self._records = records - self.calls: List[Dict[str, Any]] = [] + def __init__(self, records: Sequence[Mock]): + self._records = tuple(records) + self.calls: list[dict[str, object]] = [] - async def find_many(self, **kwargs: Any) -> List[Any]: + async def find_many(self, **kwargs: object) -> tuple[Mock, ...]: inspect.signature(LiteLLM_VerificationTokenActions.find_many).bind( self, **kwargs ) From 88ab22fefc6dda9f2827f0c1d9112b6ecf56d813 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Fri, 31 Jul 2026 10:19:20 -0700 Subject: [PATCH 14/37] test(e2e): skip the three Datadog MCP tool-call tests pending LIT-5052 (#35380) All three send a `telemetry` object in the arguments to Datadog's search_datadog_logs tool. Datadog tightened that tool's input schema to reject unknown properties, so every call now fails validation with 'unexpected additional properties ["telemetry"]' before the behavior each test exists to prove is reached. `telemetry` was never a documented Datadog parameter; the tests relied on the server ignoring extra properties. The proxy transmitted exactly what the tests supplied and surfaced the upstream error faithfully, so this is test-side. The covers markers and registry rows stay put: the collector counts a cell as covered only when a test pytest would actually run declares it, so skipping hands all four cells back to the gap list where they belong. --- tests/e2e/mcp/test_mcp_datadog_e2e.py | 10 ++++++++++ tests/e2e/mcp/test_mcp_guardrail_e2e.py | 10 ++++++++++ tests/e2e/mcp/test_mcp_key_access_e2e.py | 10 ++++++++++ 3 files changed, 30 insertions(+) diff --git a/tests/e2e/mcp/test_mcp_datadog_e2e.py b/tests/e2e/mcp/test_mcp_datadog_e2e.py index d093e307f99..138f654272d 100644 --- a/tests/e2e/mcp/test_mcp_datadog_e2e.py +++ b/tests/e2e/mcp/test_mcp_datadog_e2e.py @@ -49,6 +49,16 @@ def _seed_completion(proxy: ProxyClient, *, key: str, marker: str) -> None: class TestDatadogMcpRoundTrip: + @pytest.mark.skip( + reason=( + "LIT-5052: this test sends a `telemetry` argument that Datadog's " + "search_datadog_logs tool now rejects, so every tool call fails validation with " + "'unexpected additional properties [\"telemetry\"]' before the round-trip " + "assertion is reached. `telemetry` was never a documented Datadog parameter; the " + "test relied on the server ignoring unknown properties. Unskip once the argument " + "is dropped." + ) + ) @pytest.mark.covers("mcp.list_tools.api_key.succeeds", "mcp.call_tool.api_key.succeeds") def test_search_logs_finds_seeded_completion( self, diff --git a/tests/e2e/mcp/test_mcp_guardrail_e2e.py b/tests/e2e/mcp/test_mcp_guardrail_e2e.py index 9e3a8c48395..60a349ddc5e 100644 --- a/tests/e2e/mcp/test_mcp_guardrail_e2e.py +++ b/tests/e2e/mcp/test_mcp_guardrail_e2e.py @@ -78,6 +78,16 @@ def _search_on_synced_pod( class TestMcpToolCallGuardrail: + @pytest.mark.skip( + reason=( + "LIT-5052: the control call sends a `telemetry` argument that Datadog's " + "search_datadog_logs tool now rejects, so the clean-argument half of this test " + "errors with 'unexpected additional properties [\"telemetry\"]' and the guardrail " + "block it exists to prove is never exercised. `telemetry` was never a documented " + "Datadog parameter; the test relied on the server ignoring unknown properties. " + "Unskip once the argument is dropped." + ) + ) @pytest.mark.covers( "guardrail.litellm_content_filter.pre_mcp_call.blocks", exercised_on=["mcp_operations"], diff --git a/tests/e2e/mcp/test_mcp_key_access_e2e.py b/tests/e2e/mcp/test_mcp_key_access_e2e.py index 678424e36d1..788a0a3f45c 100644 --- a/tests/e2e/mcp/test_mcp_key_access_e2e.py +++ b/tests/e2e/mcp/test_mcp_key_access_e2e.py @@ -51,6 +51,16 @@ class TestMcpKeyWithoutAccessIsDenied: f"boundary: {denied_tools}" ) + @pytest.mark.skip( + reason=( + "LIT-5052: the control call proving a granted key CAN invoke the tool sends a " + "`telemetry` argument that Datadog's search_datadog_logs tool now rejects, so it " + "errors with 'unexpected additional properties [\"telemetry\"]' and the denial " + "assertion is never reached. `telemetry` was never a documented Datadog " + "parameter; the test relied on the server ignoring unknown properties. Unskip " + "once the argument is dropped." + ) + ) @pytest.mark.covers("mcp.call_tool.api_key.denied_without_permission") def test_call_tool_denied_without_permission( self, From 78c756dff94554435b2912cd416022ebb9c10291 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 31 Jul 2026 10:19:24 -0700 Subject: [PATCH 15/37] fix(proxy): rework the budgets list onto the merged list contract PR #35308 landed a different shape than this branch was written against: `where` is a tuple of frozen predicates rather than a Prisma-shaped mapping, `ListSpec` carries both the row and the wire type, and `where_sql` / `order_by_sql` render for a raw-SQL executor. The budgets executor now queries through `query_raw` the way the spend logs facet does, selecting only the columns it serves. Also casts datetime binds in `where_sql`. They cross into the query engine as JSON, so an uncast placeholder arrives as text and Postgres refuses `timestamp >= text` outright; every `filter[created_at][gte|lte]` was answering 500. The cast reads the bind as an instant and drops it to naive UTC to match Prisma's TIMESTAMP(3) column, the same one /spend/logs/ui applies. --- .../management_v1/budgets.py | 25 +++++--- .../management_v1/list_framework.py | 15 ++++- .../management_v1/test_budgets.py | 15 ++++- ui/litellm-dashboard/src/lib/http/schema.d.ts | 61 ++++++++++++++----- 4 files changed, 87 insertions(+), 29 deletions(-) diff --git a/litellm/proxy/management_endpoints/management_v1/budgets.py b/litellm/proxy/management_endpoints/management_v1/budgets.py index d9104eb5da2..bc1521caf0b 100644 --- a/litellm/proxy/management_endpoints/management_v1/budgets.py +++ b/litellm/proxy/management_endpoints/management_v1/budgets.py @@ -1,8 +1,9 @@ """`GET /management/v1/budgets`.""" -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from dataclasses import dataclass from datetime import datetime +from types import MappingProxyType from typing import Annotated from fastapi import APIRouter, Depends, Request @@ -112,15 +113,19 @@ def _scope(caller: UserAPIKeyAuth) -> Scope: # budget_duration is deliberately absent from `sortable`: the column holds strings # like "7d" and "30d", so a lexicographic ORDER BY puts "30d" ahead of "7d". +BUDGET_FILTERS: Mapping[str, FilterSpec] = MappingProxyType( + { # mutable-ok: an immutable mapping has no literal form; MappingProxyType freezes this one and it never escapes + "budget_duration": FilterSpec(type=str, ops=frozenset(("in", "is_null"))), + "max_budget": FilterSpec(type=float, ops=frozenset(("gte", "lte", "is_null"))), + "created_at": FilterSpec(type=datetime, ops=frozenset(("gte", "lte"))), + } +) + BUDGETS_LIST_SPEC: ListSpec[BudgetListItem, BudgetListItem] = ListSpec( resource="budgets", - sortable=frozenset({"budget_id", "max_budget", "tpm_limit", "rpm_limit", "created_at"}), - searchable=frozenset({"budget_id"}), - filters={ - "budget_duration": FilterSpec(type=str, ops=frozenset({"in", "is_null"})), - "max_budget": FilterSpec(type=float, ops=frozenset({"gte", "lte", "is_null"})), - "created_at": FilterSpec(type=datetime, ops=frozenset({"gte", "lte"})), - }, + sortable=frozenset(("budget_id", "max_budget", "tpm_limit", "rpm_limit", "created_at")), + searchable=frozenset(("budget_id",)), + filters=BUDGET_FILTERS, default_sort=(SortKey(field="created_at", descending=True),), default_page_size=50, max_page_size=100, @@ -132,8 +137,8 @@ BUDGETS_LIST_SPEC: ListSpec[BudgetListItem, BudgetListItem] = ListSpec( @router.get( "/budgets", - tags=["budget management"], - dependencies=[Depends(user_api_key_auth)], + tags=("budget management",), + dependencies=(Depends(user_api_key_auth),), response_model=ListResponse[BudgetListItem], ) async def list_budgets( diff --git a/litellm/proxy/management_endpoints/management_v1/list_framework.py b/litellm/proxy/management_endpoints/management_v1/list_framework.py index 3e4b9131d1e..e2bddefab83 100644 --- a/litellm/proxy/management_endpoints/management_v1/list_framework.py +++ b/litellm/proxy/management_endpoints/management_v1/list_framework.py @@ -213,12 +213,23 @@ def _sql_operator(op: ComparisonOp) -> str: assert_never(op) +def _placeholder(index: int, value: FilterValue) -> str: + """`$n`, cast when the bind is a datetime. + + Binds cross into the query engine as JSON, so a datetime arrives as text and + Postgres refuses `timestamp >= text` outright. Prisma stores DateTime as a naive + `TIMESTAMP(3)` holding UTC, so the bind is read as an instant and then dropped to + naive UTC to match the column, the same cast `/spend/logs/ui` applies. + """ + return f"${index}::timestamptz AT TIME ZONE 'UTC'" if isinstance(value, datetime) else f"${index}" + + def _render(predicate: Predicate, index: int) -> tuple[str, tuple[object, ...]]: match predicate: case IsNull(field=field, negated=negated): return f'"{field}" IS {"NOT NULL" if negated else "NULL"}', () case Within(field=field, values=values): - placeholders = ", ".join(f"${index + offset}" for offset in range(len(values))) + placeholders = ", ".join(_placeholder(index + offset, value) for offset, value in enumerate(values)) return f'"{field}" IN ({placeholders})', values case AnyOf(clauses=clauses): rendered, params = _render_all(clauses, index) @@ -226,7 +237,7 @@ def _render(predicate: Predicate, index: int) -> tuple[str, tuple[object, ...]]: case Compare(field=field, op="contains", value=value): return f"\"{field}\" ILIKE ${index} ESCAPE '\\'", (f"%{escape_like(str(value))}%",) case Compare(field=field, op=op, value=value): - return f'"{field}" {_sql_operator(op)} ${index}', (value,) + return f'"{field}" {_sql_operator(op)} {_placeholder(index, value)}', (value,) case _: assert_never(predicate) diff --git a/tests/test_litellm/proxy/management_endpoints/management_v1/test_budgets.py b/tests/test_litellm/proxy/management_endpoints/management_v1/test_budgets.py index fe6d0289e53..f98286985b7 100644 --- a/tests/test_litellm/proxy/management_endpoints/management_v1/test_budgets.py +++ b/tests/test_litellm/proxy/management_endpoints/management_v1/test_budgets.py @@ -371,15 +371,28 @@ def test_in_filter_binds_each_requested_duration(query_raw, as_proxy_admin): def test_created_at_range_is_bound_as_a_timestamp(query_raw, as_proxy_admin): + """The bind crosses into the query engine as JSON, so an uncast placeholder reaches + Postgres as text and `timestamp >= text` is a hard error, not a wrong answer.""" _serve(query_raw, []) _get("filter[created_at][gte]=2026-07-01T00:00:00Z") sql, *params = _select_call(query_raw) - assert '"created_at" >= $1' in sql + assert "\"created_at\" >= $1::timestamptz AT TIME ZONE 'UTC'" in sql assert params[0] == datetime(2026, 7, 1, tzinfo=timezone.utc) +def test_a_non_datetime_bind_is_not_cast(query_raw, as_proxy_admin): + """Guards the cast above from being applied to every placeholder.""" + _serve(query_raw, []) + + _get("filter[max_budget][gte]=5") + + sql = _select_call(query_raw)[0] + assert '"max_budget" >= $1' in sql + assert "timestamptz" not in sql + + def test_an_offsetless_created_at_bound_is_read_as_utc(query_raw, as_proxy_admin): """The dashboard sends 'YYYY-MM-DDTHH:MM:SS' with no offset. Left naive, Postgres would compare it in the session timezone and shift the window off the rows shown.""" diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 46a158bbac7..9a301f1c474 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -7187,10 +7187,11 @@ export interface paths { * * `sort` takes a comma-separated list of `budget_id`, `max_budget`, `tpm_limit`, * `rpm_limit` or `created_at`, each optionally prefixed with `-` for descending, - * and defaults to `-created_at,budget_id`. `q` is a case-insensitive substring - * match on `budget_id`. `page_size` defaults to 50 and is capped at 100. - * Filters are `filter[budget_duration][in|is_null]`, - * `filter[max_budget][gte|lte|is_null]` and `filter[created_at][gte|lte]`. + * and defaults to `-created_at`. `budget_id` is appended to every sort as the + * tiebreaker. `q` is a case-insensitive substring match on `budget_id`. + * `page_size` defaults to 50 and is capped at 100. Filters are + * `filter[budget_duration][in|is_null]`, `filter[max_budget][gte|lte|is_null]` + * and `filter[created_at][gte|lte]`. * * Example curl: * ``` @@ -21570,6 +21571,40 @@ export interface components { /** Reset At */ reset_at?: string | null; }; + /** + * BudgetListItem + * @description One budget as the Budgets page reads it, and as it comes back off the table. + * + * Validating the raw row through here is what makes `tpm_limit` / `rpm_limit` + * numbers: they are `BigInt?` in the schema, which the query engine hands back as + * decimal strings, and a quoted "60000" breaks arithmetic in the dashboard. + */ + BudgetListItem: { + /** Budget Duration */ + budget_duration?: string | null; + /** Budget Id */ + budget_id: string; + /** Budget Reset At */ + budget_reset_at?: string | null; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** Max Budget */ + max_budget?: number | null; + /** Rpm Limit */ + rpm_limit?: number | null; + /** Soft Budget */ + soft_budget?: number | null; + /** Tpm Limit */ + tpm_limit?: number | null; + /** + * Updated At + * Format: date-time + */ + updated_at: string; + }; /** BudgetNewRequest */ BudgetNewRequest: { /** @@ -24814,7 +24849,6 @@ export interface components { /** Updated By */ updated_by?: string | null; }; - JsonValue: unknown; /** KeyHealthResponse */ KeyHealthResponse: { /** @@ -24968,7 +25002,7 @@ export interface components { }; /** * ListLinks - * @description Hypermedia for an entity list. `first`/`last` exist here because `total_pages` is known. + * @description Page-mode counterpart to `PageLinks`. `first`/`last` are knowable here because the total count is. */ ListLinks: { /** First */ @@ -24984,7 +25018,7 @@ export interface components { }; /** * ListMeta - * @description An entity list can afford the COUNT(*) a facet cannot, so it reports a real total. + * @description Page-mode counterpart to `PageMeta`: an entity list pays for the COUNT(*) so the table can show a page count. */ ListMeta: { /** Page */ @@ -25011,15 +25045,10 @@ export interface components { /** Prompts */ prompts: components["schemas"]["PromptSpec"][]; }; - /** - * ListResponse - * @description One page of an entity collection. Rows are flat: no `{type, id, attributes}` wrapper. - */ - ListResponse: { + /** ListResponse[BudgetListItem] */ + ListResponse_BudgetListItem_: { /** Data */ - data: { - [key: string]: components["schemas"]["JsonValue"]; - }[]; + data: components["schemas"]["BudgetListItem"][]; links: components["schemas"]["ListLinks"]; meta: components["schemas"]["ListMeta"]; }; @@ -43574,7 +43603,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ListResponse"]; + "application/json": components["schemas"]["ListResponse_BudgetListItem_"]; }; }; }; From 0e9a624a97851b8c5f3bf9e1cdc3c272645568b7 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Fri, 31 Jul 2026 10:25:38 -0700 Subject: [PATCH 16/37] feat(mcp): source the ID-JAG subject from the user's stored SSO assertion (#35147) The ID-JAG egress arm could only assert a caller that presented its own IdP identity token on the request, so an agent holding a brokered LiteLLM credential got a 412 and never reached the upstream. The assertion captured at SSO login was already persisted per user for exactly this purpose, but nothing read it back. The arm now falls back to that stored assertion, keyed on the authenticated principal's user_id. The identity is always taken from the credential the gateway authenticated, never from a caller-supplied field, so no caller can select whose identity is asserted upstream. A missing, expired, or unidentified subject stays a 412; ID-JAG exists to assert a specific user and a missing subject has no safe substitute. A store outage is the one exception: it is surfaced as a typed AssertionStoreUnavailable and mapped to 503, so a database blip cannot 500 the egress or the upstream-401 retry, and does not tell the user to sign in again over something they cannot fix. Sourcing a subject from the store rather than the request changed what invalidation can rely on, so the exchanged-token cache changed with it. The entry is now addressed by a slot key derived from the principal, plus the caller's own token when it presented one, with a fingerprint of the subject token and config stored beside the bearer and compared on every read. A mismatch reads as a miss and re-mints, so a rotated assertion or an edited server config cannot be served a bearer authorized under the old inputs, and two callers cannot receive each other's. Invalidation is a single delete of a key it can always compute, needing no store lookup on the recovery path. The upstream-401 invalidate-and-retry path was also gated on a truthy inbound subject token, which skipped recovery entirely for store-sourced calls. The gate is now mode-aware: token_exchange still requires an inbound token because it has nothing else to mint from, id_jag does not. oauth2_id_jag is also now selectable in the admin dashboard with its own field set, instead of being reachable only from config.yaml or the REST API. The auth-type selects drop antd list virtualization: at eleven options the last one no longer mounts, which is a scroll in a browser but makes the option unreachable to anything reading the rendered list. Co-authored-by: Yassin Kortam --- .../mcp_server/mcp_server_manager.py | 16 +- .../outbound_credentials/resolver.py | 146 +++++- .../sso_assertion_store.py | 38 +- .../outbound_credentials/token_endpoint.py | 35 +- .../mcp_server/outbound_credentials/types.py | 3 +- .../outbound_credentials/test_resolver.py | 422 +++++++++++++++++- .../test_sso_assertion_store.py | 23 + .../mcp_server/test_mcp_server_manager.py | 32 +- ui/litellm-dashboard/eslint-suppressions.json | 5 + .../_components/IdJagFormFields.tsx | 158 +++++++ .../_components/create_mcp_server.test.tsx | 121 +++++ .../_components/create_mcp_server.tsx | 8 +- .../_components/mcp_server_edit.tsx | 8 +- .../src/components/mcp_tools/types.tsx | 8 +- 14 files changed, 987 insertions(+), 36 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/IdJagFormFields.tsx diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 89c83459524..db80c0f76ee 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -906,6 +906,20 @@ def _extract_upstream_auth_failure( return upstream_auth_challenge(exc) +def _obo_retry_applies(server: MCPServer, subject_token: str | None) -> bool: + """Whether an upstream 401/403 should invalidate the minted credential and retry once. + + ``oauth2_token_exchange`` can only mint from an inbound subject token, so with no token there is + nothing to re-mint and the plain single call is correct. ``oauth2_id_jag`` also sources its + subject from the identity assertion stored for the user at SSO login, so it qualifies whether or + not the caller presented a token of its own; gating it on the inbound token would leave a + store-sourced bearer un-invalidated and replayed until its TTL. + """ + if server.auth_type == MCPAuth.oauth2_id_jag: + return True + return server.auth_type == MCPAuth.oauth2_token_exchange and bool(subject_token) + + def _warn_on_server_name_fields( *, server_id: str, @@ -4778,7 +4792,7 @@ class MCPServerManager: arguments=arguments, ) - if mcp_server.auth_type in (MCPAuth.oauth2_token_exchange, MCPAuth.oauth2_id_jag) and subject_token: + if _obo_retry_applies(mcp_server, subject_token): # OBO / ID-JAG: the exchanged token may have been revoked/rotated upstream since it was # cached, so an upstream 401 gets one invalidate + re-mint + retry. Gated to these modes; # all others keep the plain single call below. diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py index 69984a56311..b70db64ba94 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py @@ -10,19 +10,24 @@ at runtime instead of returning `None`. `none`, `api_key` (shared-key source), and `passthrough` (forwards the caller's own inbound token) are live, as is `authorization_code`, which reads the user's token from the injected `OAuthTokenStore`, `token_exchange`, which swaps the caller's inbound token through the injected -`TokenExchanger`, and `client_credentials`, which mints and caches the gateway's M2M token through -the injected `ClientCredentialsTokenSource`. The remaining arms are `not_implemented` stubs that -each land in a follow-up PR with their seam. Pure v2: no imports from v1. +`TokenExchanger`, `client_credentials`, which mints and caches the gateway's M2M token through the +injected `ClientCredentialsTokenSource`, and `id_jag`, which runs the two-leg identity-assertion +grant against a subject token taken from the request or from the injected `SSOAssertionStore`. The +remaining arms are `not_implemented` stubs that each land in a follow-up PR with their seam. Pure +v2: no imports from v1. """ from __future__ import annotations import hashlib +from datetime import datetime, timezone from functools import partial import httpx from typing_extensions import assert_never +from litellm._logging import verbose_proxy_logger + from litellm.proxy._experimental.mcp_server.outbound_credentials.client_credentials import ( ClientCredentialsBearerAuth, ClientCredentialsTokenSource, @@ -41,6 +46,12 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.result import ( Ok, Result, ) +from litellm.proxy._experimental.mcp_server.outbound_credentials.sso_assertion_store import ( + AssertionStoreUnavailable, + DbSSOAssertionStore, + SSOAssertionStore, + SSOIdentityAssertion, +) from litellm.proxy._experimental.mcp_server.outbound_credentials.token_endpoint import ( ExchangedToken, ExchangedTokenCache, @@ -111,12 +122,14 @@ class UpstreamCredentialProvider: token_endpoint: TokenEndpointClient | None = None, exchanged_tokens: ExchangedTokenCache | None = None, client_credentials_source: ClientCredentialsTokenSource | None = None, + sso_assertion_store: SSOAssertionStore | None = None, ) -> None: self._oauth_token_store: OAuthTokenStore = oauth_token_store or _NullOAuthTokenStore() self._token_exchanger: TokenExchanger = token_exchanger or _NullTokenExchanger() self._token_endpoint: TokenEndpointClient = token_endpoint or TokenEndpointClient() self._exchanged_tokens: ExchangedTokenCache = exchanged_tokens or ExchangedTokenCache() self._client_credentials_source = client_credentials_source or ClientCredentialsTokenSource() + self._sso_assertion_store: SSOAssertionStore = sso_assertion_store or DbSSOAssertionStore() async def resolve_credentials(self, subject: Subject, server: ServerSpec) -> Result[httpx.Auth, CredError]: match server.config: @@ -171,15 +184,73 @@ class UpstreamCredentialProvider: assert_never(config.key_source) async def _id_jag(self, subject: Subject, server: ServerSpec, config: IdJagConfig) -> Result[httpx.Auth, CredError]: - if subject.inbound_token is None: + match await self._id_jag_subject_token(subject): + case Error(err): + return Error(err) + case Ok(subject_token): + return await self._id_jag_exchange(subject, subject_token, server, config) + + async def _id_jag_subject_token(self, subject: Subject) -> Result[str, CredError]: + """The identity token ID-JAG leg 1 asserts, from the request or from the SSO login it was captured at. + + A caller that presents its own IdP identity token wins: that is the strongest available + assertion of who is calling. Otherwise the subject is the assertion captured for this user + at LiteLLM SSO login, which is what lets an agent holding a brokered LiteLLM credential + reach an upstream as the user it was issued for. The user is always taken from the + authenticated principal, never from a caller-supplied field, so no caller can select whose + identity is asserted upstream. + + Every miss is ``precondition_required`` (412) rather than a fall-through to a weaker + credential: ID-JAG exists to assert a specific user, so a missing subject has no safe + substitute. A store outage is the one exception: it is ``upstream_unavailable`` (503), not + 412, because the user has nothing to fix by signing in again, and it is a value rather than + a raised error so a DB blip cannot 500 the egress or the upstream-401 retry. + """ + if subject.inbound_token is not None: + return Ok(subject.inbound_token.get_secret_value()) + if not subject.subject_id: return Error( CredError.of_precondition_required( - "ID-JAG requires a caller identity token; it asserts the calling " - "user's identity upstream and cannot use a static credential." + "ID-JAG requires an identified caller; this request carries neither an " + "identity token nor a resolved LiteLLM user." ) ) - token = subject.inbound_token.get_secret_value() - cache_key = _id_jag_cache_key(token, server.server_id, config) + try: + assertion = await self._sso_assertion_store.fetch(subject.subject_id) + except AssertionStoreUnavailable as exc: + # The driver's message can name hosts, schemas or connection details, and this summary + # is returned to the caller verbatim as a 503 body. Operators get it from the log. + verbose_proxy_logger.warning( + "ID-JAG: the IdP identity assertion store is unreachable for user_id=%s: %s", + subject.subject_id, + exc, + ) + return Error( + CredError.of_upstream_unavailable( + "The IdP identity assertion store is unreachable, so ID-JAG cannot resolve a subject." + ) + ) + if assertion is None: + return Error( + CredError.of_precondition_required( + "ID-JAG requires an IdP identity assertion for this user and none is stored. " + "Sign in through LiteLLM SSO so the gateway captures one." + ) + ) + if _assertion_expired(assertion, datetime.now(timezone.utc)): + return Error( + CredError.of_precondition_required( + "The stored IdP identity assertion for this user has expired. Sign in through " + "LiteLLM SSO again to capture a current one." + ) + ) + return Ok(assertion.id_token.get_secret_value()) + + async def _id_jag_exchange( + self, subject: Subject, token: str, server: ServerSpec, config: IdJagConfig + ) -> Result[httpx.Auth, CredError]: + slot = _id_jag_slot_key(subject, server) + fingerprint = _id_jag_fingerprint(token, server.server_id, config) async def _exchange() -> Result[ExchangedToken, CredError]: leg1_params = { @@ -211,7 +282,7 @@ class UpstreamCredentialProvider: config.client_auth, ) - match await self._exchanged_tokens.get_or_compute(cache_key, _exchange): + match await self._exchanged_tokens.get_or_compute(slot, _exchange, fingerprint=fingerprint): case Ok(access_token): return Ok(StaticHeaderAuth(f"Bearer {access_token}")) case Error(err): @@ -273,17 +344,27 @@ class UpstreamCredentialProvider: re-mintable cached credential here; `client_credentials` recovers inside its own auth flow (`ClientCredentialsBearerAuth` retries the 401'd request once with a fresh token), and other modes are a no-op. + + `id_jag` evicts by a slot key derived from the principal, so it needs no lookup against the + assertion store on this path; the fingerprint stored beside the entry is what keeps a slot + shared between callers safe. """ - if subject.inbound_token is None: - return - if isinstance(server.config, TokenExchangeConfig): + if isinstance(server.config, IdJagConfig): + self._invalidate_id_jag(subject, server) + elif isinstance(server.config, TokenExchangeConfig) and subject.inbound_token is not None: await self._token_exchanger.invalidate( subject.inbound_token.get_secret_value(), server, server.config, tenant_id=subject.tenant_id ) - if isinstance(server.config, IdJagConfig): - self._exchanged_tokens.invalidate( - _id_jag_cache_key(subject.inbound_token.get_secret_value(), server.server_id, server.config) - ) + + def _invalidate_id_jag(self, subject: Subject, server: ServerSpec) -> None: + """Evict the bearer this `(subject, server)` last resolved, without depending on the store. + + The slot is addressed by the principal (plus the caller's own token when it presented one), + never by the credential material, so it stays computable when the assertion store is down. + The fingerprint stored with the entry is what keeps that safe: an entry minted for different + inputs reads as a miss rather than being served. + """ + self._exchanged_tokens.invalidate(_id_jag_slot_key(subject, server)) async def _authz_token(self, subject: Subject, server: ServerSpec) -> OAuthToken | None: """The user's authorization_code token, or None when absent or the store is unreachable. @@ -297,8 +378,37 @@ class UpstreamCredentialProvider: return None -def _id_jag_cache_key(subject_token: str, server_id: str, config: IdJagConfig) -> str: - """Bind the cached leg-2 bearer to the caller token, the server, AND the config that minted it. +def _id_jag_slot_key(subject: Subject, server: ServerSpec) -> str: + """Which cache slot this caller's bearer for this upstream lives in. + + Addressed by the principal, plus the caller's own token when it presented one so two callers + sharing an empty principal do not contend for one slot. Deliberately free of the stored + assertion, which is what lets invalidation compute this while the assertion store is down. The + entry's fingerprint, not this key, is what guarantees a cached bearer matches current inputs. + """ + inbound = subject.inbound_token.get_secret_value() if subject.inbound_token is not None else "" + material = "\x00".join((subject.tenant_id, subject.subject_id, server.server_id, inbound)) + return hashlib.sha256(material.encode()).hexdigest() + + +def _assertion_expired(assertion: SSOIdentityAssertion, now: datetime) -> bool: + """Whether the stored assertion's ``exp`` has passed. An assertion carrying no expiry is + treated as usable and left for the IdP to reject, since the store records what the id_token + claimed rather than imposing a lifetime of its own. A naive ``expires_at`` is read as UTC so a + stored value that lost its offset compares instead of raising. + """ + expires_at = assertion.expires_at + if expires_at is None: + return False + normalized = expires_at if expires_at.tzinfo is not None else expires_at.replace(tzinfo=timezone.utc) + return normalized <= now + + +def _id_jag_fingerprint(subject_token: str, server_id: str, config: IdJagConfig) -> str: + """What the cached leg-2 bearer was minted from: the subject token, the server, and the config. + + Stored beside the bearer and compared on every read, so a rotated assertion or an edited server + config reads as a miss and re-mints instead of serving a bearer authorized under the old policy. Every exchange parameter derives from the config (endpoints, audience, resource, scopes, client auth), so a server update that changes any of them must change the key; otherwise the old bearer, diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/sso_assertion_store.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/sso_assertion_store.py index e0927cc4f64..d52c718c0b8 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/sso_assertion_store.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/sso_assertion_store.py @@ -18,7 +18,7 @@ from __future__ import annotations import json from datetime import datetime, timezone -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Protocol import jwt from pydantic import BaseModel, ConfigDict, SecretStr, TypeAdapter, ValidationError @@ -160,6 +160,42 @@ async def fetch_sso_identity_assertion(user_id: str) -> SSOIdentityAssertion | N ) +class AssertionStoreUnavailable(Exception): + """Raised by ``fetch`` when the backing store is unreachable (e.g. the DB is down). + + Distinct from returning ``None`` for "this user has no captured assertion": an outage must not + read as a definite absence, which would tell the user to sign in again over a transient failure, + and it must not escape as an unhandled error on the egress or retry path. Mirrors + ``TokenStoreUnavailable`` on the sibling per-user OAuth store. + """ + + +class SSOAssertionStore(Protocol): + """The read seam the ``id_jag`` egress arm depends on, so the arm takes a collaborator + rather than reaching for a module-level function and a proxy global at call time. + + Returns the user's captured assertion, or ``None`` when they have never signed in. Raises + ``AssertionStoreUnavailable`` when the backing store is unreachable. + """ + + async def fetch(self, user_id: str) -> SSOIdentityAssertion | None: ... + + +class DbSSOAssertionStore: + """The live store: the row the SSO callback wrote, read back by ``user_id``. + + A storage failure is re-raised as ``AssertionStoreUnavailable`` so the resolver can map it to a + typed fail-closed result; letting the raw driver error escape would surface a DB blip as a 500 + from credential resolution and from the upstream-401 retry. + """ + + async def fetch(self, user_id: str) -> SSOIdentityAssertion | None: + try: + return await fetch_sso_identity_assertion(user_id) + except Exception as exc: # noqa: BLE001 # any driver/storage failure is an outage, not an absence + raise AssertionStoreUnavailable(str(exc)) from exc + + async def rotate_sso_identity_assertions_master_key(prisma_client: PrismaClient, new_master_key: str) -> None: """Re-encrypt every stored assertion under ``new_master_key`` during a salt-key rotation, mirroring the sibling per-user credential tables; an unreadable row is skipped so one diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/token_endpoint.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/token_endpoint.py index 4bc5732ec0e..3ed22732c90 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/token_endpoint.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/token_endpoint.py @@ -23,7 +23,7 @@ from dataclasses import dataclass import httpx import jwt -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel, TypeAdapter, ValidationError from typing_extensions import assert_never from litellm._logging import verbose_proxy_logger @@ -51,6 +51,9 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.types import ( ) from litellm.types.llms.custom_http import httpxSpecialProvider +# The cache stores (fingerprint, token); anything else in the slot is treated as absent. +_CACHED_ENTRY_ADAPTER: TypeAdapter[tuple[str, str]] = TypeAdapter(tuple[str, str]) + CLIENT_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" CLIENT_ASSERTION_LIFETIME_SECONDS = 60 @@ -134,19 +137,28 @@ class ExchangedTokenCache: self, cache_key: str, compute: Callable[[], Awaitable[Result[ExchangedToken, CredError]]], + *, + fingerprint: str = "", ) -> Result[str, CredError]: - cached = self._get(cache_key) + """The cached token for `cache_key`, minting one when absent. + + `fingerprint` lets a caller address a slot by something stable (a principal) while still + guaranteeing the token it gets back was minted for the *current* inputs: a stored entry + whose fingerprint differs reads as a miss and is re-minted over. That keeps eviction + addressable without the key having to encode the credential material it protects. + """ + cached = self._get(cache_key, fingerprint) if cached is not None: return Ok(cached) async with self._lock(cache_key): - cached = self._get(cache_key) + cached = self._get(cache_key, fingerprint) if cached is not None: return Ok(cached) match await compute(): case Ok(token): self._cache.set_cache( # pyright: ignore[reportUnknownMemberType] # InMemoryCache is untyped cache_key, - token.access_token, + (fingerprint, token.access_token), ttl=_cache_ttl_seconds(token.expires_in), ) return Ok(token.access_token) @@ -157,9 +169,18 @@ class ExchangedTokenCache: """Evict one cached token so the next `get_or_compute` re-mints (e.g. after an upstream 401).""" self._cache.delete_cache(cache_key) # pyright: ignore[reportUnknownMemberType] # InMemoryCache is untyped - def _get(self, cache_key: str) -> str | None: - value = self._cache.get_cache(cache_key) # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType] # InMemoryCache is untyped; narrowed by isinstance below - return value if isinstance(value, str) else None + def _get(self, cache_key: str, fingerprint: str) -> str | None: + """The stored token, or None when absent or minted for different inputs. + + The fingerprint comparison is what makes a shared slot safe: a mismatch never returns the + other party's token, it just reads as a miss. + """ + value = self._cache.get_cache(cache_key) # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType] # InMemoryCache is untyped; the adapter below is the type gate + try: + stored_fingerprint, token = _CACHED_ENTRY_ADAPTER.validate_python(value) + except ValidationError: + return None + return token if stored_fingerprint == fingerprint else None def _lock(self, cache_key: str) -> asyncio.Lock: lock = self._locks.get(cache_key) diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/types.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/types.py index 0f276cb8e5c..f80954986e5 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/types.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/types.py @@ -391,7 +391,8 @@ class Subject(BaseModel): tenant_id: str subject_id: str - # Opaque, already-validated inbound identity. Only `token_exchange` / `passthrough` read it. + # Opaque, already-validated inbound identity. Read by `token_exchange`, `passthrough`, and + # `id_jag` (which falls back to the user's stored SSO assertion when it is absent). inbound_token: SecretStr | None = None diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py index a710da81962..0d130767bd5 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py @@ -7,6 +7,10 @@ also guards reachability: a dropped `case` would hit `assert_never` and raise in returning the stub. """ +import asyncio +import logging +from datetime import datetime, timedelta, timezone + import httpx import pytest from pydantic import SecretStr @@ -25,6 +29,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials import ( NoOpAuth, Ok, PassthroughConfig, + PrivateKeyJwtAuth, Result, ServerSpec, SharedKey, @@ -37,6 +42,10 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.oauth_token_sto OAuthToken, TokenStoreUnavailable, ) +from litellm.proxy._experimental.mcp_server.outbound_credentials.sso_assertion_store import ( + AssertionStoreUnavailable, + SSOIdentityAssertion, +) from litellm.proxy._experimental.mcp_server.outbound_credentials.token_endpoint import ( ExchangedToken, ) @@ -71,6 +80,23 @@ def _with_inbound(token: str) -> Subject: return Subject(tenant_id="", subject_id="alice", inbound_token=SecretStr(token)) +class _FakeAssertionStore: + """The SSO assertion read seam, canned per user_id and recording every lookup.""" + + def __init__(self, assertions: dict[str, SSOIdentityAssertion] | None = None) -> None: + self._assertions = dict(assertions or {}) + self.lookups: list[str] = [] + + async def fetch(self, user_id: str) -> SSOIdentityAssertion | None: + self.lookups.append(user_id) + return self._assertions.get(user_id) + + +def _assertion(id_token: str, expires_in: timedelta | None = timedelta(minutes=30)) -> SSOIdentityAssertion: + expires_at = datetime.now(timezone.utc) + expires_in if expires_in is not None else None + return SSOIdentityAssertion(id_token=SecretStr(id_token), expires_at=expires_at) + + def _spec(config): return ServerSpec(server_id="s", resource="https://upstream.example.com", config=config) @@ -471,9 +497,10 @@ async def test_id_jag_runs_both_legs_and_returns_the_leg2_bearer(): @pytest.mark.asyncio -async def test_id_jag_without_inbound_token_is_precondition_required_no_http(): +async def test_id_jag_without_inbound_token_or_stored_assertion_is_precondition_required_no_http(): endpoint = _FakeTokenEndpoint([]) - provider = UpstreamCredentialProvider(token_endpoint=endpoint) + store = _FakeAssertionStore() + provider = UpstreamCredentialProvider(token_endpoint=endpoint, sso_assertion_store=store) result = await provider.resolve_credentials( Subject(tenant_id="", subject_id="alice"), _spec(_id_jag_config()) ) @@ -481,6 +508,397 @@ async def test_id_jag_without_inbound_token_is_precondition_required_no_http(): assert isinstance(result, Error) assert result.error.tag == "precondition_required" assert endpoint.calls == [] + assert store.lookups == ["alice"] + + +@pytest.mark.asyncio +async def test_id_jag_exchanges_the_stored_sso_assertion_when_the_caller_presents_no_token(): + """The agent-triggered flow: a brokered LiteLLM credential carries no IdP token, so leg 1's + subject is the assertion captured for that user at SSO login.""" + endpoint = _FakeTokenEndpoint(_two_leg_ok("final-access")) + store = _FakeAssertionStore({"alice": _assertion("alice-id-token")}) + provider = UpstreamCredentialProvider(token_endpoint=endpoint, sso_assertion_store=store) + + result = await provider.resolve_credentials( + Subject(tenant_id="", subject_id="alice"), _spec(_id_jag_config()) + ) + + assert isinstance(result, Ok) + assert _emitted(result.ok)["Authorization"] == "Bearer final-access" + assert store.lookups == ["alice"] + _, _, leg1_params = endpoint.calls[0] + assert leg1_params["subject_token"] == "alice-id-token" + assert leg1_params["requested_token_type"] == "urn:ietf:params:oauth:token-type:id-jag" + + +@pytest.mark.asyncio +async def test_id_jag_prefers_the_callers_own_token_over_the_stored_assertion(): + endpoint = _FakeTokenEndpoint(_two_leg_ok("final-access")) + store = _FakeAssertionStore({"alice": _assertion("stored-id-token")}) + provider = UpstreamCredentialProvider(token_endpoint=endpoint, sso_assertion_store=store) + + result = await provider.resolve_credentials(_with_inbound("inbound-id-token"), _spec(_id_jag_config())) + + assert isinstance(result, Ok) + _, _, leg1_params = endpoint.calls[0] + assert leg1_params["subject_token"] == "inbound-id-token" + assert store.lookups == [] + + +@pytest.mark.asyncio +async def test_id_jag_refuses_an_expired_stored_assertion_without_calling_the_idp(): + endpoint = _FakeTokenEndpoint([]) + store = _FakeAssertionStore({"alice": _assertion("stale-id-token", expires_in=-timedelta(seconds=1))}) + provider = UpstreamCredentialProvider(token_endpoint=endpoint, sso_assertion_store=store) + + result = await provider.resolve_credentials( + Subject(tenant_id="", subject_id="alice"), _spec(_id_jag_config()) + ) + + assert isinstance(result, Error) + assert result.error.tag == "precondition_required" + assert endpoint.calls == [] + + +@pytest.mark.asyncio +async def test_id_jag_accepts_a_stored_assertion_that_declares_no_expiry(): + endpoint = _FakeTokenEndpoint(_two_leg_ok("final-access")) + store = _FakeAssertionStore({"alice": _assertion("undated-id-token", expires_in=None)}) + provider = UpstreamCredentialProvider(token_endpoint=endpoint, sso_assertion_store=store) + + result = await provider.resolve_credentials( + Subject(tenant_id="", subject_id="alice"), _spec(_id_jag_config()) + ) + + assert isinstance(result, Ok) + _, _, leg1_params = endpoint.calls[0] + assert leg1_params["subject_token"] == "undated-id-token" + + +@pytest.mark.asyncio +async def test_id_jag_never_reads_the_store_for_an_unidentified_caller(): + """An empty subject_id must not select a credential; otherwise every anonymous caller would + share one store slot.""" + endpoint = _FakeTokenEndpoint([]) + store = _FakeAssertionStore({"": _assertion("anonymous-slot")}) + provider = UpstreamCredentialProvider(token_endpoint=endpoint, sso_assertion_store=store) + + result = await provider.resolve_credentials(Subject(tenant_id="", subject_id=""), _spec(_id_jag_config())) + + assert isinstance(result, Error) + assert result.error.tag == "precondition_required" + assert store.lookups == [] + assert endpoint.calls == [] + + +@pytest.mark.asyncio +async def test_id_jag_keeps_store_sourced_bearers_partitioned_per_user(): + endpoint = _FakeTokenEndpoint( + [ + Ok(ExchangedToken(access_token="alice-id-jag", expires_in=300)), + Ok(ExchangedToken(access_token="alice-bearer", expires_in=3600)), + Ok(ExchangedToken(access_token="bob-id-jag", expires_in=300)), + Ok(ExchangedToken(access_token="bob-bearer", expires_in=3600)), + ] + ) + store = _FakeAssertionStore( + {"alice": _assertion("alice-id-token"), "bob": _assertion("bob-id-token")} + ) + provider = UpstreamCredentialProvider(token_endpoint=endpoint, sso_assertion_store=store) + + alice = await provider.resolve_credentials( + Subject(tenant_id="", subject_id="alice"), _spec(_id_jag_config()) + ) + bob = await provider.resolve_credentials(Subject(tenant_id="", subject_id="bob"), _spec(_id_jag_config())) + + assert isinstance(alice, Ok) and isinstance(bob, Ok) + assert _emitted(alice.ok)["Authorization"] == "Bearer alice-bearer" + assert _emitted(bob.ok)["Authorization"] == "Bearer bob-bearer" + + +_DRIVER_DETAIL = "could not connect to host=pg-primary.internal port=5432 user=litellm" + + +class _OutageAssertionStore: + """A store whose backing DB is down, failing with a driver message full of internals.""" + + def __init__(self) -> None: + self.lookups: list[str] = [] + + async def fetch(self, user_id: str) -> SSOIdentityAssertion | None: + self.lookups.append(user_id) + raise AssertionStoreUnavailable(_DRIVER_DETAIL) + + +@pytest.mark.asyncio +async def test_id_jag_maps_an_assertion_store_outage_to_upstream_unavailable(): + """A store outage must not escape as an unhandled error, and must not be reported as a missing + assertion: telling the user to sign in again does not fix a database that is down.""" + endpoint = _FakeTokenEndpoint([]) + provider = UpstreamCredentialProvider(token_endpoint=endpoint, sso_assertion_store=_OutageAssertionStore()) + + result = await provider.resolve_credentials( + Subject(tenant_id="", subject_id="alice"), _spec(_id_jag_config()) + ) + + assert isinstance(result, Error) + assert result.error.tag == "upstream_unavailable" + assert endpoint.calls == [] + + +@pytest.mark.asyncio +async def test_id_jag_store_outage_does_not_leak_driver_detail_to_the_caller(caplog): + """`upstream_unavailable` is rendered into the 503 body verbatim, so the driver's message, which + can name hosts, ports and users, must stay out of the summary and go to the log instead.""" + provider = UpstreamCredentialProvider( + token_endpoint=_FakeTokenEndpoint([]), sso_assertion_store=_OutageAssertionStore() + ) + + with caplog.at_level(logging.WARNING): + result = await provider.resolve_credentials( + Subject(tenant_id="", subject_id="alice"), _spec(_id_jag_config()) + ) + + assert isinstance(result, Error) + assert _DRIVER_DETAIL not in result.error.summary + assert "pg-primary.internal" not in result.error.summary + # The operator still needs it, so it must be in the log. + assert _DRIVER_DETAIL in caplog.text + + +@pytest.mark.asyncio +async def test_id_jag_invalidation_survives_an_assertion_store_outage(): + """invalidate_credentials runs on the upstream-401 retry path, so a store outage there must be + swallowed rather than turning a recoverable 401 into a 500.""" + provider = UpstreamCredentialProvider( + token_endpoint=_FakeTokenEndpoint([]), sso_assertion_store=_OutageAssertionStore() + ) + + await provider.invalidate_credentials( + Subject(tenant_id="", subject_id="alice"), _spec(_id_jag_config()) + ) + + +class _FlakyAssertionStore: + """Serves an assertion, but fails while ``down`` is set.""" + + def __init__(self, assertion: SSOIdentityAssertion) -> None: + self._assertion = assertion + self.down = False + + async def fetch(self, user_id: str) -> SSOIdentityAssertion | None: + if self.down: + raise AssertionStoreUnavailable("connection refused") + return self._assertion + + +@pytest.mark.asyncio +async def test_id_jag_evicts_the_rejected_bearer_even_if_the_store_is_down_during_invalidation(): + """The upstream-401 recovery sequence with a transient store blip. + + Invalidation runs while the store is unreachable and the store recovers before the retry + resolves. Deriving the eviction key from a fresh lookup would evict nothing and then recompute + the identical key, handing the retry the very bearer the upstream just rejected. + """ + endpoint = _FakeTokenEndpoint(_two_leg_ok("rejected-bearer") + _two_leg_ok("reminted-bearer")) + store = _FlakyAssertionStore(_assertion("alice-id-token")) + provider = UpstreamCredentialProvider(token_endpoint=endpoint, sso_assertion_store=store) + subject = Subject(tenant_id="", subject_id="alice") + spec = _spec(_id_jag_config()) + + first = await provider.resolve_credentials(subject, spec) + assert isinstance(first, Ok) + assert _emitted(first.ok)["Authorization"] == "Bearer rejected-bearer" + + store.down = True + await provider.invalidate_credentials(subject, spec) + store.down = False + + second = await provider.resolve_credentials(subject, spec) + assert isinstance(second, Ok) + assert _emitted(second.ok)["Authorization"] == "Bearer reminted-bearer" + assert len(endpoint.calls) == 4 + + +class _SwitchableAssertionStore: + """Serves whichever assertion the test currently points it at, as a re-login would.""" + + def __init__(self, id_token: str) -> None: + self.id_token = id_token + + async def fetch(self, user_id: str) -> SSOIdentityAssertion | None: + return _assertion(self.id_token) + + +@pytest.mark.asyncio +async def test_id_jag_invalidation_clears_every_live_bearer_for_the_principal(): + """Overlapping store-sourced requests for one principal can hold different keys (a re-login + between them mints a different subject token). Invalidation must clear all of them: keeping + only the newest would let one request's 401 recovery evict the other's entry and leave its own + rejected bearer cached to be replayed on the retry.""" + endpoint = _FakeTokenEndpoint( + _two_leg_ok("bearer-from-first") + _two_leg_ok("bearer-from-second") + _two_leg_ok("reminted") + ) + store = _SwitchableAssertionStore("id-token-first") + provider = UpstreamCredentialProvider(token_endpoint=endpoint, sso_assertion_store=store) + subject = Subject(tenant_id="", subject_id="alice") + spec = _spec(_id_jag_config()) + + first = await provider.resolve_credentials(subject, spec) + store.id_token = "id-token-second" + second = await provider.resolve_credentials(subject, spec) + assert isinstance(first, Ok) and isinstance(second, Ok) + assert _emitted(first.ok)["Authorization"] == "Bearer bearer-from-first" + assert _emitted(second.ok)["Authorization"] == "Bearer bearer-from-second" + + await provider.invalidate_credentials(subject, spec) + + # Point the store back at the first token. If that entry had survived the invalidation this + # would replay "bearer-from-first", which is the bearer an upstream may already have rejected. + store.id_token = "id-token-first" + third = await provider.resolve_credentials(subject, spec) + assert isinstance(third, Ok) + assert _emitted(third.ok)["Authorization"] == "Bearer reminted" + + +class _SequentialAssertionStore: + """Issues a distinct assertion per call unless pinned, so concurrent resolutions genuinely + mint distinct credentials rather than collapsing onto one through single-flight.""" + + def __init__(self) -> None: + self.pinned: str | None = None + self.issued: list[str] = [] + self._n = 0 + + async def fetch(self, user_id: str) -> SSOIdentityAssertion | None: + await asyncio.sleep(0) + if self.pinned is not None: + return _assertion(self.pinned) + self._n += 1 + token = f"id-token-{self._n}" + self.issued.append(token) + return _assertion(token) + + +class _CountingTokenEndpoint: + """Mints a unique bearer per exchange and yields, so exchanges interleave.""" + + def __init__(self) -> None: + self._n = 0 + + async def fetch(self, endpoint, client_id, grant_params, client_auth): + await asyncio.sleep(0) + self._n += 1 + return Ok(ExchangedToken(access_token=f"tok-{self._n}", expires_in=3600)) + + +@pytest.mark.asyncio +async def test_id_jag_invalidation_leaves_no_bearer_behind_under_concurrency(): + """After invalidation, no bearer minted before it may ever be served again. + + Drives many overlapping resolutions that each mint a distinct credential, invalidates once, + then replays every subject token that was issued. Any credential the eviction could not reach + would show up here as a replayed pre-invalidation bearer. + """ + concurrency = 20 + endpoint = _CountingTokenEndpoint() + store = _SequentialAssertionStore() + provider = UpstreamCredentialProvider(token_endpoint=endpoint, sso_assertion_store=store) + subject = Subject(tenant_id="t", subject_id="alice") + spec = _spec(_id_jag_config()) + + results = await asyncio.gather(*(provider.resolve_credentials(subject, spec) for _ in range(concurrency))) + before = {_emitted(r.ok)["Authorization"] for r in results if isinstance(r, Ok)} + issued = list(store.issued) + # Guard the guard: if these collapsed onto one credential the test would prove nothing. + assert len(before) > 1 + + await provider.invalidate_credentials(subject, spec) + + for token in issued: + store.pinned = token + replayed = await provider.resolve_credentials(subject, spec) + assert isinstance(replayed, Ok) + assert _emitted(replayed.ok)["Authorization"] not in before + + +@pytest.mark.asyncio +async def test_id_jag_never_serves_a_bearer_minted_for_a_different_caller(): + """Two unidentified-principal callers share a slot, so the fingerprint, not the key, is what + keeps them apart: a mismatch must read as a miss rather than hand over the other's bearer.""" + endpoint = _FakeTokenEndpoint(_two_leg_ok("first-callers-bearer") + _two_leg_ok("second-callers-bearer")) + provider = UpstreamCredentialProvider(token_endpoint=endpoint) + spec = _spec(_id_jag_config()) + + first = await provider.resolve_credentials(_with_inbound("caller-one-token"), spec) + second = await provider.resolve_credentials(_with_inbound("caller-two-token"), spec) + + assert isinstance(first, Ok) and isinstance(second, Ok) + assert _emitted(first.ok)["Authorization"] == "Bearer first-callers-bearer" + assert _emitted(second.ok)["Authorization"] == "Bearer second-callers-bearer" + + +@pytest.mark.asyncio +async def test_id_jag_rotating_the_signing_key_does_not_reuse_the_cached_bearer(): + """The cache key fingerprints the private-key-JWT client auth, so a rotated signing key + re-mints instead of serving a bearer authorized under the retired key.""" + endpoint = _FakeTokenEndpoint(_two_leg_ok("old-key-bearer") + _two_leg_ok("new-key-bearer")) + store = _FakeAssertionStore({"alice": _assertion("alice-id-token")}) + provider = UpstreamCredentialProvider(token_endpoint=endpoint, sso_assertion_store=store) + subject = Subject(tenant_id="", subject_id="alice") + + def _with_key(pem: str) -> IdJagConfig: + return _id_jag_config().model_copy( + update={"client_auth": PrivateKeyJwtAuth(private_key=SecretStr(pem), key_id="kid-1")} + ) + + first = await provider.resolve_credentials(subject, _spec(_with_key("-----OLD KEY-----"))) + second = await provider.resolve_credentials(subject, _spec(_with_key("-----NEW KEY-----"))) + + assert isinstance(first, Ok) and isinstance(second, Ok) + assert _emitted(first.ok)["Authorization"] == "Bearer old-key-bearer" + assert _emitted(second.ok)["Authorization"] == "Bearer new-key-bearer" + assert len(endpoint.calls) == 4 + + +@pytest.mark.asyncio +async def test_id_jag_reads_a_naive_stored_expiry_as_utc(): + """A stored expires_at that lost its offset must still compare rather than raise: an aware/naive + comparison would be a TypeError on the egress path, turning a 412 into a 500.""" + endpoint = _FakeTokenEndpoint([]) + naive_past = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(hours=1) + store = _FakeAssertionStore( + {"alice": SSOIdentityAssertion(id_token=SecretStr("stale"), expires_at=naive_past)} + ) + provider = UpstreamCredentialProvider(token_endpoint=endpoint, sso_assertion_store=store) + + result = await provider.resolve_credentials( + Subject(tenant_id="", subject_id="alice"), _spec(_id_jag_config()) + ) + + assert isinstance(result, Error) + assert result.error.tag == "precondition_required" + assert endpoint.calls == [] + + +@pytest.mark.asyncio +async def test_invalidate_evicts_a_store_sourced_id_jag_bearer(): + """The upstream-401 recovery path. Keyed off the request alone the eviction would miss, and the + rejected bearer would be replayed until its TTL.""" + endpoint = _FakeTokenEndpoint(_two_leg_ok("first-bearer") + _two_leg_ok("second-bearer")) + store = _FakeAssertionStore({"alice": _assertion("alice-id-token")}) + provider = UpstreamCredentialProvider(token_endpoint=endpoint, sso_assertion_store=store) + subject = Subject(tenant_id="", subject_id="alice") + spec = _spec(_id_jag_config()) + + first = await provider.resolve_credentials(subject, spec) + await provider.invalidate_credentials(subject, spec) + second = await provider.resolve_credentials(subject, spec) + + assert isinstance(first, Ok) and isinstance(second, Ok) + assert _emitted(first.ok)["Authorization"] == "Bearer first-bearer" + assert _emitted(second.ok)["Authorization"] == "Bearer second-bearer" + assert len(endpoint.calls) == 4 @pytest.mark.asyncio diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_sso_assertion_store.py b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_sso_assertion_store.py index a3f46a49ba9..7b82e004f37 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_sso_assertion_store.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_sso_assertion_store.py @@ -8,6 +8,7 @@ rotation re-encrypts stored rows like the sibling per-user credential tables. """ import json +import os import time from unittest.mock import AsyncMock, MagicMock, patch @@ -15,6 +16,8 @@ import jwt as pyjwt import pytest from litellm.proxy._experimental.mcp_server.outbound_credentials.sso_assertion_store import ( + AssertionStoreUnavailable, + DbSSOAssertionStore, assertion_from_sso_login, ema_assertion_retention_enabled, fetch_sso_identity_assertion, @@ -341,3 +344,23 @@ async def test_rotation_skips_unreadable_rows_but_rotates_readable_ones(): await rotate_sso_identity_assertions_master_key(prisma_client=prisma, new_master_key="another-new-salt-key-0000") assert stored["bad"] == "garbage-blob" assert stored["good"] != good_blob_before + + +@pytest.mark.asyncio +async def test_db_store_converts_a_driver_failure_into_assertion_store_unavailable(): + """The live store must not let a raw driver error escape: the resolver distinguishes an outage + from an absent assertion, and only a typed failure lets it do that.""" + prisma = MagicMock() + prisma.db.litellm_ssoidentityassertion.find_unique = AsyncMock(side_effect=RuntimeError("connection refused")) + with patch("litellm.proxy.proxy_server.prisma_client", prisma): + with pytest.raises(AssertionStoreUnavailable): + await DbSSOAssertionStore().fetch("alice") + + +@pytest.mark.asyncio +async def test_db_store_returns_none_for_a_user_with_no_stored_assertion(): + """An absent row stays an absence, not an outage, so a user who never signed in still gets the + 412 that tells them to.""" + with patch.dict(os.environ, {"LITELLM_SALT_KEY": SALT_KEY}): + with patch("litellm.proxy.proxy_server.prisma_client", _make_prisma({})): + assert await DbSSOAssertionStore().fetch("nobody") is None diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index db5b64ef131..f6753e28a66 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -40,6 +40,7 @@ from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( _oauth_endpoints_unresolved, _deserialize_json_list, _normalize_mcp_server_cost_info, + _obo_retry_applies, _should_strip_caller_authorization, _without_authorization, ) @@ -50,7 +51,7 @@ from litellm.proxy._types import ( MCPEnvVarScope, MCPTransport, ) -from litellm.types.mcp import MCPAuth +from litellm.types.mcp import MCPAuth, MCPAuthType from litellm.types.mcp_server.mcp_server_manager import MCPOAuthMetadata, MCPServer @@ -8232,6 +8233,35 @@ def test_should_strip_caller_authorization_for_token_exchange(): assert _should_strip_caller_authorization(mcp_server=server, raw_headers=None, user_api_key_auth=None) is True +def _retry_gate_server(auth_type: MCPAuthType) -> MCPServer: + return MCPServer( + server_id="retry-gate", + name="retry-gate-server", + url="https://up.example.com", + transport=MCPTransport.http, + auth_type=auth_type, + ) + + +def test_obo_retry_applies_to_id_jag_without_an_inbound_subject_token(): + """ID-JAG can source its subject from the user's stored SSO assertion, so the upstream-401 + invalidate-and-retry path must engage even when the caller presented no token of its own; + otherwise a store-sourced bearer is replayed until its TTL after being rejected.""" + assert _obo_retry_applies(_retry_gate_server(MCPAuth.oauth2_id_jag), None) is True + assert _obo_retry_applies(_retry_gate_server(MCPAuth.oauth2_id_jag), "inbound-id-token") is True + + +def test_obo_retry_still_requires_a_subject_token_for_token_exchange(): + """token_exchange can only mint from an inbound token, so with none there is nothing to re-mint.""" + assert _obo_retry_applies(_retry_gate_server(MCPAuth.oauth2_token_exchange), None) is False + assert _obo_retry_applies(_retry_gate_server(MCPAuth.oauth2_token_exchange), "inbound-token") is True + + +def test_obo_retry_does_not_apply_to_other_auth_modes(): + for auth_type in (MCPAuth.none, MCPAuth.api_key, MCPAuth.oauth2, MCPAuth.true_passthrough): + assert _obo_retry_applies(_retry_gate_server(auth_type), "some-token") is False + + class _UpstreamAuthError(Exception): """Mimics a wrapped upstream 401 the way _extract_upstream_auth_failure detects it.""" diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 4cb3ebe7e16..568b8c9c395 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -794,6 +794,11 @@ "count": 1 } }, + "src/app/(dashboard)/mcp-servers/_components/IdJagFormFields.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/app/(dashboard)/mcp-servers/_components/MCPLogoSelector.test.tsx": { "unused-imports/no-unused-imports": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/IdJagFormFields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/IdJagFormFields.tsx new file mode 100644 index 00000000000..e8730a5b974 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/IdJagFormFields.tsx @@ -0,0 +1,158 @@ +import React from "react"; +import { Form, Input, Select, Tooltip } from "antd"; +import { InfoCircleOutlined } from "@ant-design/icons"; + +interface IdJagFormFieldsProps { + isEditing?: boolean; +} + +const fieldClassName = "rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"; + +const FieldLabel: React.FC<{ label: string; tooltip: string }> = ({ label, tooltip }) => ( + + {label} + + + + +); + +const IdJagFormFields: React.FC = ({ isEditing = false }) => { + const placeholderSuffix = isEditing ? " (leave blank to keep existing)" : ""; + + return ( + <> + + } + name="token_exchange_endpoint" + rules={[{ required: !isEditing, message: "The org token endpoint is required for ID-JAG" }]} + > + + + + } + name={["credentials", "id_jag_resource_token_endpoint"]} + rules={[{ required: !isEditing, message: "The resource token endpoint is required for ID-JAG" }]} + > + + + } + name={["credentials", "client_id"]} + rules={[{ required: !isEditing, message: "Client ID is required for ID-JAG" }]} + > + + + + } + name={["credentials", "client_secret"]} + dependencies={[["credentials", "client_private_key"]]} + rules={[ + ({ getFieldValue }) => ({ + validator: (_, value) => { + if (isEditing || value || getFieldValue(["credentials", "client_private_key"])) { + return Promise.resolve(); + } + return Promise.reject(new Error("Provide either a client secret or a client private key")); + }, + }), + ]} + > + + + + } + name={["credentials", "client_private_key"]} + > + + + + } + name={["credentials", "client_private_key_id"]} + > + + + + } + name={["credentials", "client_assertion_signing_alg"]} + > + + + + } + name="audience" + > + + + + } + name={["credentials", "id_jag_resource"]} + > + + + + } + name="subject_token_type" + > + + + } + name={["credentials", "scopes"]} + > + + +