From 1afe3032fdda19eb96e8a36e61cc87f9f84ba4ed Mon Sep 17 00:00:00 2001 From: michelligabriele Date: Tue, 10 Feb 2026 18:33:16 +0100 Subject: [PATCH 01/11] fix(otel): auto-infer otlp_http exporter when endpoint is configured (#20438) When OpenTelemetry is configured via the UI, only OTEL_ENDPOINT and OTEL_HEADERS are set, but OTEL_EXPORTER is not specified. This caused the exporter to default to "console", meaning traces were printed to stdout instead of being sent to the configured endpoint. This fix adds logic in OpenTelemetryConfig.__post_init__ to automatically infer "otlp_http" as the exporter when an endpoint is specified but the exporter is still the default "console". Fixes issue reported by Elastic team where traces weren't being sent to their OTEL endpoint when configured through the LiteLLM UI. --- litellm/integrations/opentelemetry.py | 7 +++++++ .../integrations/test_opentelemetry.py | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index 138d508db4b..b847180174a 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -72,6 +72,13 @@ class OpenTelemetryConfig: model_id: Optional[str] = None def __post_init__(self) -> None: + # If endpoint is specified but exporter is still the default "console", + # automatically infer "otlp_http" to send traces to the endpoint. + # This fixes an issue where UI-configured OTEL settings would default + # to console output instead of sending traces to the configured endpoint. + if self.endpoint and isinstance(self.exporter, str) and self.exporter == "console": + self.exporter = "otlp_http" + if not self.service_name: self.service_name = os.getenv("OTEL_SERVICE_NAME", "litellm") if not self.deployment_environment: diff --git a/tests/test_litellm/integrations/test_opentelemetry.py b/tests/test_litellm/integrations/test_opentelemetry.py index 95fa6ed8f60..3da9d9857c9 100644 --- a/tests/test_litellm/integrations/test_opentelemetry.py +++ b/tests/test_litellm/integrations/test_opentelemetry.py @@ -274,6 +274,26 @@ class TestOpenTelemetry(unittest.TestCase): self.assertEqual(config.deployment_environment, "production") self.assertEqual(config.model_id, "custom-service") + @patch.dict(os.environ, {}, clear=True) + def test_open_telemetry_config_auto_infer_otlp_http_when_endpoint_set(self): + """When endpoint is set but exporter is default 'console', auto-infer 'otlp_http'. + + This fixes an issue where UI-configured OTEL settings would default to console + output instead of sending traces to the configured endpoint. + See: https://github.com/BerriAI/litellm/issues/XXXX + """ + # When endpoint is specified without explicit exporter, should auto-infer otlp_http + config = OpenTelemetryConfig(endpoint="https://otel-collector.example.com:443") + self.assertEqual(config.exporter, "otlp_http") + + # When exporter is explicitly set to something other than console, should not override + config_grpc = OpenTelemetryConfig(exporter="grpc", endpoint="https://otel-collector.example.com:443") + self.assertEqual(config_grpc.exporter, "grpc") + + # When no endpoint is set, should keep console as default + config_no_endpoint = OpenTelemetryConfig() + self.assertEqual(config_no_endpoint.exporter, "console") + def wait_for_spans(self, exporter: InMemorySpanExporter, prefix: str): """Poll until we see at least one span with an attribute key starting with `prefix`.""" deadline = time.time() + self.POLL_TIMEOUT From 3bbc25a3f006225f96976b6aef421da84e34611d Mon Sep 17 00:00:00 2001 From: michelligabriele Date: Tue, 10 Feb 2026 19:17:35 +0100 Subject: [PATCH 02/11] fix(aiohttp): respect ssl_verify with shared sessions (#20349) * fix(aiohttp): respect ssl_verify with shared sessions * fix(aiohttp): resolve mypy error for ssl parameter type Pass ssl kwarg conditionally to aiohttp request() only when explicitly configured, since None is not a valid value for the ssl parameter (expected SSLContext | bool | Fingerprint). --- .../llms/custom_httpx/aiohttp_transport.py | 23 +++++- litellm/llms/custom_httpx/http_handler.py | 16 +++- .../llms/custom_httpx/test_http_handler.py | 77 +++++++++++++++++++ 3 files changed, 114 insertions(+), 2 deletions(-) diff --git a/litellm/llms/custom_httpx/aiohttp_transport.py b/litellm/llms/custom_httpx/aiohttp_transport.py index a7b83d8c802..1b03ec47643 100644 --- a/litellm/llms/custom_httpx/aiohttp_transport.py +++ b/litellm/llms/custom_httpx/aiohttp_transport.py @@ -1,6 +1,7 @@ import asyncio import contextlib import os +import ssl import typing import urllib.request from typing import Callable, Dict, Optional, Union @@ -139,8 +140,13 @@ class LiteLLMAiohttpTransport(AiohttpTransport): Credit to: https://github.com/karpetrosyan/httpx-aiohttp for this implementation """ - def __init__(self, client: Union[ClientSession, Callable[[], ClientSession]]): + def __init__( + self, + client: Union[ClientSession, Callable[[], ClientSession]], + ssl_verify: Optional[Union[bool, ssl.SSLContext]] = None, + ): self.client = client + self._ssl_verify = ssl_verify # Store for per-request SSL override super().__init__(client=client) # Store the client factory for recreating sessions when needed if callable(client): @@ -214,6 +220,7 @@ class LiteLLMAiohttpTransport(AiohttpTransport): timeout: dict, proxy: Optional[str], sni_hostname: Optional[str], + ssl_verify: Optional[Union[bool, ssl.SSLContext]] = None, ) -> ClientResponse: """ Helper function to make an aiohttp request with the given parameters. @@ -224,6 +231,7 @@ class LiteLLMAiohttpTransport(AiohttpTransport): timeout: Timeout settings dict with 'connect', 'read', 'pool' keys proxy: Optional proxy URL sni_hostname: Optional SNI hostname for SSL + ssl_verify: Optional SSL verification setting (False to disable, SSLContext for custom) Returns: ClientResponse from aiohttp @@ -237,6 +245,13 @@ class LiteLLMAiohttpTransport(AiohttpTransport): data = request.stream # type: ignore request.headers.pop("transfer-encoding", None) # handled by aiohttp + # Only pass ssl kwarg when explicitly configured, to avoid + # overriding the session/connector defaults with None (which is + # not a valid value for aiohttp's ssl parameter). + ssl_kwargs: Dict[str, Union[bool, ssl.SSLContext]] = {} + if ssl_verify is not None: + ssl_kwargs["ssl"] = ssl_verify + response = await client_session.request( method=request.method, url=YarlURL(str(request.url), encoded=True), @@ -251,6 +266,7 @@ class LiteLLMAiohttpTransport(AiohttpTransport): ), proxy=proxy, server_hostname=sni_hostname, + **ssl_kwargs, ).__aenter__() return response @@ -268,6 +284,9 @@ class LiteLLMAiohttpTransport(AiohttpTransport): # Resolve proxy settings from environment variables proxy = await self._get_proxy_settings(request) + # Use stored SSL configuration for per-request override + ssl_config = self._ssl_verify + try: with map_aiohttp_exceptions(): response = await self._make_aiohttp_request( @@ -276,6 +295,7 @@ class LiteLLMAiohttpTransport(AiohttpTransport): timeout=timeout, proxy=proxy, sni_hostname=sni_hostname, + ssl_verify=ssl_config, ) except RuntimeError as e: # Handle the case where session was closed between our check and actual use @@ -296,6 +316,7 @@ class LiteLLMAiohttpTransport(AiohttpTransport): timeout=timeout, proxy=proxy, sni_hostname=sni_hostname, + ssl_verify=ssl_config, ) else: # Re-raise if it's a different RuntimeError diff --git a/litellm/llms/custom_httpx/http_handler.py b/litellm/llms/custom_httpx/http_handler.py index ac9dd5998e2..95f411c397c 100644 --- a/litellm/llms/custom_httpx/http_handler.py +++ b/litellm/llms/custom_httpx/http_handler.py @@ -846,6 +846,16 @@ class AsyncHTTPHandler: if str_to_bool(os.getenv("AIOHTTP_TRUST_ENV", "False")) is True: trust_env = True + ######################################################### + # Determine SSL config to pass to transport for per-request override + # This ensures ssl_verify works even with shared sessions + ######################################################### + ssl_for_transport: Optional[Union[bool, ssl.SSLContext]] = None + if ssl_context is not None: + ssl_for_transport = ssl_context + elif ssl_verify is False: + ssl_for_transport = False + verbose_logger.debug("Creating AiohttpTransport...") # Use shared session if provided and valid @@ -853,7 +863,10 @@ class AsyncHTTPHandler: verbose_logger.debug( f"SHARED SESSION: Reusing existing ClientSession (ID: {id(shared_session)})" ) - return LiteLLMAiohttpTransport(client=shared_session) + return LiteLLMAiohttpTransport( + client=shared_session, + ssl_verify=ssl_for_transport, + ) # Create new session only if none provided or existing one is invalid verbose_logger.debug( @@ -877,6 +890,7 @@ class AsyncHTTPHandler: connector=TCPConnector(**transport_connector_kwargs), trust_env=trust_env, ), + ssl_verify=ssl_for_transport, ) @staticmethod diff --git a/tests/test_litellm/llms/custom_httpx/test_http_handler.py b/tests/test_litellm/llms/custom_httpx/test_http_handler.py index 65f08ef5021..c249bd9970c 100644 --- a/tests/test_litellm/llms/custom_httpx/test_http_handler.py +++ b/tests/test_litellm/llms/custom_httpx/test_http_handler.py @@ -140,6 +140,83 @@ async def test_ssl_verification_with_aiohttp_transport(): litellm.disable_aiohttp_transport = original_disable +@pytest.mark.asyncio +async def test_ssl_verification_with_shared_session(): + """ + Test that ssl_verify=False is respected even with shared sessions. + + This was a bug where shared sessions bypassed SSL configuration because + _create_aiohttp_transport returned immediately without passing ssl_verify + to the LiteLLMAiohttpTransport constructor. + + The fix stores ssl_verify in the transport and passes it per-request. + """ + import aiohttp + + # Ensure aiohttp transport is enabled for this test + original_disable = litellm.disable_aiohttp_transport + litellm.disable_aiohttp_transport = False + + try: + # Create a shared session (simulating what happens in production) + shared_session = aiohttp.ClientSession() + + try: + # Create transport with shared session and ssl_verify=False + transport = AsyncHTTPHandler._create_aiohttp_transport( + ssl_verify=False, + shared_session=shared_session, + ) + + # Verify the transport uses the shared session + assert transport.client is shared_session + + # Verify the SSL setting is stored in the transport for per-request use + assert transport._ssl_verify is False + finally: + await shared_session.close() + finally: + # Restore original setting + litellm.disable_aiohttp_transport = original_disable + + +@pytest.mark.asyncio +async def test_ssl_context_with_shared_session(): + """ + Test that ssl_context is respected even with shared sessions. + """ + import aiohttp + + # Ensure aiohttp transport is enabled for this test + original_disable = litellm.disable_aiohttp_transport + litellm.disable_aiohttp_transport = False + + try: + # Create a custom SSL context + custom_ssl_context = ssl.create_default_context() + + # Create a shared session + shared_session = aiohttp.ClientSession() + + try: + # Create transport with shared session and custom ssl_context + transport = AsyncHTTPHandler._create_aiohttp_transport( + ssl_context=custom_ssl_context, + shared_session=shared_session, + ) + + # Verify the transport uses the shared session + assert transport.client is shared_session + + # Verify the SSL context is stored in the transport for per-request use + assert transport._ssl_verify is custom_ssl_context + finally: + await shared_session.close() + finally: + # Restore original setting + litellm.disable_aiohttp_transport = original_disable + + @pytest.mark.asyncio async def test_aiohttp_transport_trust_env_setting(monkeypatch): """Test that trust_env setting is properly configured in aiohttp transport""" From 7fd8c0e16045302f0b9982910ecf55fb7451924d Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 10 Feb 2026 12:44:38 -0800 Subject: [PATCH 03/11] Searchable Paginated Model Select For Spend Logs --- .../spend_management_endpoints.py | 6 + .../test_spend_management_endpoints.py | 79 +++++ .../hooks/models/useModels.test.ts | 242 +++++++++++++- .../app/(dashboard)/hooks/models/useModels.ts | 38 ++- .../PaginatedModelSelect.test.tsx | 301 ++++++++++++++++++ .../PaginatedModelSelect.tsx | 143 +++++++++ .../src/components/molecules/filter.tsx | 22 +- .../src/components/networking.tsx | 2 + .../src/components/view_logs/index.tsx | 12 +- .../components/view_logs/log_filter_logic.tsx | 1 + 10 files changed, 824 insertions(+), 22 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/ModelSelect/PaginatedModelSelect/PaginatedModelSelect.test.tsx create mode 100644 ui/litellm-dashboard/src/components/ModelSelect/PaginatedModelSelect/PaginatedModelSelect.tsx diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index 6e49e4244e7..c05eda85c81 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -1672,6 +1672,9 @@ async def ui_view_spend_logs( # noqa: PLR0915 model: Optional[str] = fastapi.Query( default=None, description="Filter logs by model" ), + model_id: Optional[str] = fastapi.Query( + default=None, description="Filter logs by model ID (litellm model deployment id)" + ), key_alias: Optional[str] = fastapi.Query( default=None, description="Filter logs by key alias" ), @@ -1763,6 +1766,9 @@ async def ui_view_spend_logs( # noqa: PLR0915 if model is not None: where_conditions["model"] = model + if model_id is not None: + where_conditions["model_id"] = model_id + # Build metadata filters metadata_filters = [] if key_alias is not None: diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py index 54a276bc97f..dd34cb47da4 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py @@ -1025,6 +1025,85 @@ async def test_ui_view_spend_logs_with_model(client, monkeypatch): assert data["data"][0]["model"] == "gpt-3.5-turbo" +@pytest.mark.asyncio +async def test_ui_view_spend_logs_with_model_id(client, monkeypatch): + """Test that the model_id query param filters spend logs by litellm model deployment id.""" + mock_spend_logs = [ + { + "id": "log1", + "request_id": "req1", + "api_key": "sk-test-key", + "user": "test_user_1", + "team_id": "team1", + "spend": 0.05, + "startTime": datetime.datetime.now(timezone.utc).isoformat(), + "model": "gpt-3.5-turbo", + "model_id": "deployment-id-1", + "status": "success", + }, + { + "id": "log2", + "request_id": "req2", + "api_key": "sk-test-key", + "user": "test_user_2", + "team_id": "team1", + "spend": 0.10, + "startTime": datetime.datetime.now(timezone.utc).isoformat(), + "model": "gpt-4", + "model_id": "deployment-id-2", + "status": "success", + }, + ] + + class MockDB: + async def find_many(self, *args, **kwargs): + if ( + "where" in kwargs + and "model_id" in kwargs["where"] + and kwargs["where"]["model_id"] == "deployment-id-1" + ): + return [mock_spend_logs[0]] + return mock_spend_logs + + async def count(self, *args, **kwargs): + if ( + "where" in kwargs + and "model_id" in kwargs["where"] + and kwargs["where"]["model_id"] == "deployment-id-1" + ): + return 1 + return len(mock_spend_logs) + + class MockPrismaClient: + def __init__(self): + self.db = MockDB() + self.db.litellm_spendlogs = self.db + + mock_prisma_client = MockPrismaClient() + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + + start_date = ( + datetime.datetime.now(timezone.utc) - datetime.timedelta(days=7) + ).strftime("%Y-%m-%d %H:%M:%S") + end_date = datetime.datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + + response = client.get( + "/spend/logs/ui", + params={ + "model_id": "deployment-id-1", + "start_date": start_date, + "end_date": end_date, + }, + headers={"Authorization": "Bearer sk-test"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert len(data["data"]) == 1 + assert data["data"][0]["model_id"] == "deployment-id-1" + + @pytest.mark.asyncio async def test_ui_view_spend_logs_with_key_hash(client, monkeypatch): # Mock data for the test diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts index 4985206092f..2539cc63f95 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts @@ -3,13 +3,14 @@ import { renderHook, waitFor } from "@testing-library/react"; import React, { ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { - useModelsInfo, - useModelHub, useAllProxyModels, + useInfiniteModelInfo, + useModelHub, + useModelsInfo, useSelectedTeamModels, - type ProxyModel, type AllProxyModelsResponse, type PaginatedModelInfoResponse, + type ProxyModel, } from "./useModels"; vi.mock("@/components/networking", () => ({ @@ -23,7 +24,7 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => mockUseAuthorized(), })); -import { modelInfoCall, modelHubCall, modelAvailableCall } from "@/components/networking"; +import { modelAvailableCall, modelHubCall, modelInfoCall } from "@/components/networking"; const mockProxyModel: ProxyModel = { id: "model-1", @@ -106,7 +107,7 @@ describe("useModelsInfo", () => { undefined, undefined, undefined, - undefined + undefined, ); expect(modelInfoCall).toHaveBeenCalledTimes(1); }); @@ -130,7 +131,7 @@ describe("useModelsInfo", () => { undefined, undefined, undefined, - undefined + undefined, ); }); @@ -393,7 +394,7 @@ describe("useAllProxyModels", () => { null, true, false, - "expand" + "expand", ); expect(modelAvailableCall).toHaveBeenCalledTimes(1); }); @@ -531,13 +532,7 @@ describe("useSelectedTeamModels", () => { expect(result.current.data).toEqual(mockAllProxyModelsResponse); expect(result.current.error).toBeNull(); - expect(modelAvailableCall).toHaveBeenCalledWith( - "test-access-token", - "test-user-id", - "Admin", - true, - "team-1" - ); + expect(modelAvailableCall).toHaveBeenCalledWith("test-access-token", "test-user-id", "Admin", true, "team-1"); expect(modelAvailableCall).toHaveBeenCalledTimes(1); }); @@ -639,3 +634,222 @@ describe("useSelectedTeamModels", () => { expect(modelAvailableCall).not.toHaveBeenCalled(); }); }); + +describe("useInfiniteModelInfo", () => { + let queryClient: QueryClient; + + const mockPageOneResponse: PaginatedModelInfoResponse = { + data: [{ model_name: "gpt-4", model_info: { id: "model-1" } }], + total_count: 2, + current_page: 1, + total_pages: 2, + size: 50, + }; + + const mockPageTwoResponse: PaginatedModelInfoResponse = { + data: [{ model_name: "claude-3", model_info: { id: "model-2" } }], + total_count: 2, + current_page: 2, + total_pages: 2, + size: 50, + }; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + vi.clearAllMocks(); + + mockUseAuthorized.mockReturnValue({ + accessToken: "test-access-token", + userId: "test-user-id", + userRole: "Admin", + token: "test-token", + userEmail: "test@example.com", + premiumUser: false, + disabledPersonalKeyCreation: null, + showSSOBanner: false, + }); + }); + + const wrapper = ({ children }: { children: ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + it("should return defined result", () => { + (modelInfoCall as any).mockResolvedValue(mockPageOneResponse); + + const { result } = renderHook(() => useInfiniteModelInfo(), { wrapper }); + + expect(result.current).toBeDefined(); + expect(result.current).toHaveProperty("data"); + expect(result.current).toHaveProperty("fetchNextPage"); + expect(result.current).toHaveProperty("hasNextPage"); + expect(result.current).toHaveProperty("isFetchingNextPage"); + expect(result.current).toHaveProperty("isLoading"); + }); + + it("should return paginated data and call modelInfoCall with page 1 initially", async () => { + (modelInfoCall as any).mockResolvedValue(mockPageOneResponse); + + const { result } = renderHook(() => useInfiniteModelInfo(), { wrapper }); + + expect(result.current.isLoading).toBe(true); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data?.pages).toHaveLength(1); + expect(result.current.data?.pages[0]).toEqual(mockPageOneResponse); + expect(result.current.hasNextPage).toBe(true); + expect(modelInfoCall).toHaveBeenCalledWith("test-access-token", "test-user-id", "Admin", 1, 50, undefined); + expect(modelInfoCall).toHaveBeenCalledTimes(1); + }); + + it("should use custom size parameter", async () => { + (modelInfoCall as any).mockResolvedValue(mockPageOneResponse); + + const { result } = renderHook(() => useInfiniteModelInfo(25), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(modelInfoCall).toHaveBeenCalledWith("test-access-token", "test-user-id", "Admin", 1, 25, undefined); + }); + + it("should pass search parameter to modelInfoCall", async () => { + (modelInfoCall as any).mockResolvedValue(mockPageOneResponse); + + const { result } = renderHook(() => useInfiniteModelInfo(50, "gpt"), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(modelInfoCall).toHaveBeenCalledWith("test-access-token", "test-user-id", "Admin", 1, 50, "gpt"); + }); + + it("should fetch next page when fetchNextPage is called", async () => { + (modelInfoCall as any).mockResolvedValueOnce(mockPageOneResponse).mockResolvedValueOnce(mockPageTwoResponse); + + const { result } = renderHook(() => useInfiniteModelInfo(), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + expect(result.current.hasNextPage).toBe(true); + }); + + await result.current.fetchNextPage(); + + await waitFor(() => { + expect(result.current.data?.pages).toHaveLength(2); + expect(result.current.data?.pages[1]).toEqual(mockPageTwoResponse); + expect(result.current.hasNextPage).toBe(false); + }); + + expect(modelInfoCall).toHaveBeenNthCalledWith(2, "test-access-token", "test-user-id", "Admin", 2, 50, undefined); + }); + + it("should return undefined for hasNextPage when on last page", async () => { + const lastPageResponse: PaginatedModelInfoResponse = { + ...mockPageOneResponse, + current_page: 1, + total_pages: 1, + }; + (modelInfoCall as any).mockResolvedValue(lastPageResponse); + + const { result } = renderHook(() => useInfiniteModelInfo(), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.hasNextPage).toBe(false); + }); + + it("should handle error when modelInfoCall fails", async () => { + const errorMessage = "Failed to fetch models"; + const testError = new Error(errorMessage); + (modelInfoCall as any).mockRejectedValue(testError); + + const { result } = renderHook(() => useInfiniteModelInfo(), { wrapper }); + + expect(result.current.isLoading).toBe(true); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toEqual(testError); + expect(result.current.data).toBeUndefined(); + expect(modelInfoCall).toHaveBeenCalledTimes(1); + }); + + it("should not execute query when accessToken is missing", () => { + mockUseAuthorized.mockReturnValue({ + accessToken: null, + userId: "test-user-id", + userRole: "Admin", + token: null, + userEmail: "test@example.com", + premiumUser: false, + disabledPersonalKeyCreation: null, + showSSOBanner: false, + }); + + const { result } = renderHook(() => useInfiniteModelInfo(), { wrapper }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(result.current.isFetched).toBe(false); + expect(modelInfoCall).not.toHaveBeenCalled(); + }); + + it("should not execute query when userId is missing", () => { + mockUseAuthorized.mockReturnValue({ + accessToken: "test-access-token", + userId: null, + userRole: "Admin", + token: "test-token", + userEmail: "test@example.com", + premiumUser: false, + disabledPersonalKeyCreation: null, + showSSOBanner: false, + }); + + const { result } = renderHook(() => useInfiniteModelInfo(), { wrapper }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(result.current.isFetched).toBe(false); + expect(modelInfoCall).not.toHaveBeenCalled(); + }); + + it("should not execute query when userRole is missing", () => { + mockUseAuthorized.mockReturnValue({ + accessToken: "test-access-token", + userId: "test-user-id", + userRole: null, + token: "test-token", + userEmail: "test@example.com", + premiumUser: false, + disabledPersonalKeyCreation: null, + showSSOBanner: false, + }); + + const { result } = renderHook(() => useInfiniteModelInfo(), { wrapper }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(result.current.isFetched).toBe(false); + expect(modelInfoCall).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts index c57de675e0e..fe1afdcc39f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts @@ -1,4 +1,4 @@ -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useInfiniteQuery } from "@tanstack/react-query"; import { createQueryKeys } from "../common/queryKeysFactory"; import { modelInfoCall, modelHubCall, modelAvailableCall } from "@/components/networking"; import useAuthorized from "../useAuthorized"; @@ -26,6 +26,7 @@ const modelKeys = createQueryKeys("models"); const modelHubKeys = createQueryKeys("modelHub"); const allProxyModelsKeys = createQueryKeys("allProxyModels"); const selectedTeamModelsKeys = createQueryKeys("selectedTeamModels"); +const infiniteModelKeys = createQueryKeys("infiniteModels"); export const useModelsInfo = (page: number = 1, size: number = 50, search?: string, modelId?: string, teamId?: string, sortBy?: string, sortOrder?: string) => { const { accessToken, userId, userRole } = useAuthorized(); @@ -74,3 +75,38 @@ export const useSelectedTeamModels = (teamID: string | null) => { enabled: Boolean(accessToken && userId && userRole && teamID), }); }; + +export const useInfiniteModelInfo = ( + size: number = 50, + search?: string, +) => { + const { accessToken, userId, userRole } = useAuthorized(); + return useInfiniteQuery({ + queryKey: infiniteModelKeys.list({ + filters: { + ...(userId && { userId }), + ...(userRole && { userRole }), + size, + ...(search && { search }), + }, + }), + queryFn: async ({ pageParam }) => { + return await modelInfoCall( + accessToken!, + userId!, + userRole!, + pageParam as number, + size, + search, + ); + }, + initialPageParam: 1, + getNextPageParam: (lastPage) => { + if (lastPage.current_page < lastPage.total_pages) { + return lastPage.current_page + 1; + } + return undefined; + }, + enabled: Boolean(accessToken && userId && userRole), + }); +}; diff --git a/ui/litellm-dashboard/src/components/ModelSelect/PaginatedModelSelect/PaginatedModelSelect.test.tsx b/ui/litellm-dashboard/src/components/ModelSelect/PaginatedModelSelect/PaginatedModelSelect.test.tsx new file mode 100644 index 00000000000..b91f97885e6 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ModelSelect/PaginatedModelSelect/PaginatedModelSelect.test.tsx @@ -0,0 +1,301 @@ +import { screen, waitFor } 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 { PaginatedModelSelect } from "./PaginatedModelSelect"; + +const mockFetchNextPage = vi.fn(); + +vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({ + useInfiniteModelInfo: vi.fn(), +})); + +vi.mock("@tanstack/react-pacer/debouncer", () => { + const React = require("react"); + return { + useDebouncedState: (initial: string) => { + const [value, setValue] = React.useState(initial); + return [value, setValue]; + }, + }; +}); + +import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels"; + +const mockUseInfiniteModelInfo = vi.mocked(useInfiniteModelInfo); + +const mockPagesWithModels = { + pages: [ + { + data: [ + { model_name: "GPT-4", model_info: { id: "model-1" } }, + { model_name: "Claude-3", model_info: { id: "model-2" } }, + ], + total_count: 2, + current_page: 1, + total_pages: 1, + size: 50, + }, + ], +}; + +const mockEmptyPages = { + pages: [{ data: [], total_count: 0, current_page: 1, total_pages: 1, size: 50 }], +}; + +describe("PaginatedModelSelect", () => { + const mockOnChange = vi.fn(); + + const defaultHookReturn = { + data: mockPagesWithModels, + fetchNextPage: mockFetchNextPage, + hasNextPage: false, + isFetchingNextPage: false, + isLoading: false, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockUseInfiniteModelInfo.mockReturnValue(defaultHookReturn as any); + }); + + it("should render", () => { + renderWithProviders(); + + expect(screen.getByRole("combobox")).toBeInTheDocument(); + expect(screen.getByText("Select a model")).toBeInTheDocument(); + }); + + it("should display custom placeholder when provided", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("Choose model")).toBeInTheDocument(); + }); + + it("should display model options when data is loaded", async () => { + renderWithProviders(); + + const combobox = screen.getByRole("combobox"); + await userEvent.click(combobox); + + await waitFor(() => { + expect(screen.getByRole("option", { name: "GPT-4 (model-1)" })).toBeInTheDocument(); + expect(screen.getByRole("option", { name: "Claude-3 (model-2)" })).toBeInTheDocument(); + }); + }); + + it("should call onChange when user selects a model", async () => { + const user = userEvent.setup({ delay: null }); + renderWithProviders(); + + const combobox = screen.getByRole("combobox"); + await user.click(combobox); + + const visibleOption = await screen.findByTitle("GPT-4 (model-1)"); + await user.click(visibleOption); + + await waitFor(() => { + expect(mockOnChange).toHaveBeenCalledWith("model-1"); + }); + }); + + it("should display selected value when value prop is provided", async () => { + renderWithProviders( + , + ); + + const combobox = screen.getByRole("combobox"); + await userEvent.click(combobox); + + await waitFor(() => { + expect(screen.getByRole("option", { name: "GPT-4 (model-1)" })).toBeInTheDocument(); + }); + }); + + it("should show loading state when isLoading is true", () => { + mockUseInfiniteModelInfo.mockReturnValue({ + ...defaultHookReturn, + isLoading: true, + } as any); + + renderWithProviders(); + + expect(screen.getByRole("combobox")).toHaveAttribute("aria-expanded", "false"); + }); + + it("should pass pageSize to useInfiniteModelInfo", () => { + renderWithProviders(); + + expect(mockUseInfiniteModelInfo).toHaveBeenCalledWith(25, undefined); + }); + + it("should pass search to useInfiniteModelInfo when user types", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const combobox = screen.getByRole("combobox"); + await user.click(combobox); + await user.keyboard("gpt"); + + await waitFor(() => { + expect(mockUseInfiniteModelInfo).toHaveBeenCalledWith(50, "gpt"); + }); + }); + + it("should have scroll container for infinite loading when hasNextPage is true", async () => { + mockUseInfiniteModelInfo.mockReturnValue({ + ...defaultHookReturn, + hasNextPage: true, + isFetchingNextPage: false, + } as any); + + renderWithProviders(); + + const combobox = screen.getByRole("combobox"); + await userEvent.click(combobox); + + await waitFor(() => { + expect(screen.getByRole("option", { name: "GPT-4 (model-1)" })).toBeInTheDocument(); + }); + + const scrollableContainer = document.querySelector( + ".ant-select-dropdown .rc-virtual-list-holder", + ); + expect(scrollableContainer).toBeInTheDocument(); + expect(scrollableContainer).toHaveAttribute("style"); + }); + + it("should deduplicate models with same id across pages", async () => { + mockUseInfiniteModelInfo.mockReturnValue({ + ...defaultHookReturn, + data: { + pages: [ + { + data: [ + { model_name: "GPT-4", model_info: { id: "model-1" } }, + { model_name: "GPT-4 Dupe", model_info: { id: "model-1" } }, + ], + total_count: 2, + current_page: 1, + total_pages: 1, + size: 50, + }, + ], + }, + fetchNextPage: mockFetchNextPage, + hasNextPage: false, + isFetchingNextPage: false, + isLoading: false, + } as any); + + renderWithProviders(); + + const combobox = screen.getByRole("combobox"); + await userEvent.click(combobox); + + await waitFor(() => { + const model1Options = screen.queryAllByRole("option", { name: /model-1/ }); + expect(model1Options.length).toBe(1); + }); + }); + + it("should skip models without model_info id", async () => { + mockUseInfiniteModelInfo.mockReturnValue({ + ...defaultHookReturn, + data: { + pages: [ + { + data: [ + { model_name: "Valid Model", model_info: { id: "valid-id" } }, + { model_name: "No ID", model_info: null }, + { model_name: "Empty ID", model_info: { id: "" } }, + ], + total_count: 3, + current_page: 1, + total_pages: 1, + size: 50, + }, + ], + }, + fetchNextPage: mockFetchNextPage, + hasNextPage: false, + isFetchingNextPage: false, + isLoading: false, + } as any); + + renderWithProviders(); + + const combobox = screen.getByRole("combobox"); + await userEvent.click(combobox); + + await waitFor(() => { + expect(screen.getByRole("option", { name: "Valid Model (valid-id)" })).toBeInTheDocument(); + expect(screen.queryByRole("option", { name: "No ID" })).not.toBeInTheDocument(); + expect(screen.queryByRole("option", { name: "Empty ID" })).not.toBeInTheDocument(); + }); + }); + + it("should show model ID only when model_name is empty", async () => { + mockUseInfiniteModelInfo.mockReturnValue({ + ...defaultHookReturn, + data: { + pages: [ + { + data: [{ model_name: "", model_info: { id: "id-only" } }], + total_count: 1, + current_page: 1, + total_pages: 1, + size: 50, + }, + ], + }, + fetchNextPage: mockFetchNextPage, + hasNextPage: false, + isFetchingNextPage: false, + isLoading: false, + } as any); + + renderWithProviders(); + + const combobox = screen.getByRole("combobox"); + await userEvent.click(combobox); + + await waitFor(() => { + expect(screen.getByRole("option", { name: "id-only" })).toBeInTheDocument(); + }); + }); + + it("should respect allowClear prop", () => { + renderWithProviders( + , + ); + + expect(screen.getByRole("combobox")).toBeInTheDocument(); + }); + + it("should respect disabled prop", () => { + renderWithProviders(); + + const combobox = screen.getByRole("combobox"); + expect(combobox.closest(".ant-select")).toHaveClass("ant-select-disabled"); + }); + + it("should not call fetchNextPage when hasNextPage is false", async () => { + mockUseInfiniteModelInfo.mockReturnValue({ + ...defaultHookReturn, + hasNextPage: false, + } as any); + + renderWithProviders(); + + await userEvent.click(screen.getByRole("combobox")); + + await waitFor(() => { + expect(screen.getByRole("option", { name: "GPT-4 (model-1)" })).toBeInTheDocument(); + }); + + expect(mockFetchNextPage).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/ModelSelect/PaginatedModelSelect/PaginatedModelSelect.tsx b/ui/litellm-dashboard/src/components/ModelSelect/PaginatedModelSelect/PaginatedModelSelect.tsx new file mode 100644 index 00000000000..285f4e94d68 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ModelSelect/PaginatedModelSelect/PaginatedModelSelect.tsx @@ -0,0 +1,143 @@ +import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels"; +import { LoadingOutlined } from "@ant-design/icons"; +import { useDebouncedState } from "@tanstack/react-pacer/debouncer"; +import { Select, Space, Typography } from "antd"; +import { useMemo, useState, type UIEvent } from "react"; + +const { Text } = Typography; + +export interface PaginatedModelSelectProps { + value?: string; + onChange?: (value: string) => void; + placeholder?: string; + style?: React.CSSProperties; + pageSize?: number; + allowClear?: boolean; + disabled?: boolean; +} + +const SCROLL_THRESHOLD = 0.8; +const DEBOUNCE_MS = 300; + +export const PaginatedModelSelect = ({ + value, + onChange, + placeholder = "Select a model", + style, + pageSize = 50, + allowClear = true, + disabled = false, +}: PaginatedModelSelectProps) => { + const [searchInput, setSearchInput] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useDebouncedState("", { + wait: DEBOUNCE_MS, + }); + + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + } = useInfiniteModelInfo(pageSize, debouncedSearch || undefined); + + const options = useMemo(() => { + if (!data?.pages) return []; + + const seen = new Set(); + const result: { label: string; value: string; modelName: string; modelId: string }[] = []; + + for (const page of data.pages) { + for (const model of page.data) { + const modelId = model.model_info?.id ?? ""; + const modelName = model.model_name ?? ""; + + // Dedupe by id - skip models without id (can't uniquely identify) + if (!modelId || seen.has(modelId)) continue; + seen.add(modelId); + + result.push({ + label: modelName ? `${modelName} (${modelId})` : modelId, + value: modelId, + modelName, + modelId, + }); + } + } + + return result; + }, [data]); + + const optionRender = (option: { data: { modelName: string; modelId: string; label: string } }) => { + const { modelName, modelId } = option.data; + + return ( + + {modelName ? ( + <> + + Model name: + {modelName} + + + Model ID: {modelId} + + + ) : ( + Model ID: {modelId} + )} + + ); + }; + + const handlePopupScroll = (e: UIEvent) => { + const target = e.currentTarget; + const scrollRatio = + (target.scrollTop + target.clientHeight) / target.scrollHeight; + + if (scrollRatio >= SCROLL_THRESHOLD && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }; + + const handleSearch = (value: string) => { + setSearchInput(value); + setDebouncedSearch(value); + }; + + const handleChange = (v: string | string[] | null) => { + const normalized = + typeof v === "string" ? v : Array.isArray(v) ? v[0] ?? "" : ""; + onChange?.(normalized); + }; + + return ( + + ) : option.customComponent ? ( + (() => { + const CustomComponent = option.customComponent; + return ( + handleFilterChange(option.name, value ?? "")} + placeholder={`Select ${option.label || option.name}...`} + /> + ); + })() ) : ( Date: Tue, 10 Feb 2026 12:53:51 -0800 Subject: [PATCH 04/11] fixing build --- .../PaginatedModelSelect.tsx | 14 +- .../src/components/team/team_info.tsx | 1296 ----------------- 2 files changed, 7 insertions(+), 1303 deletions(-) delete mode 100644 ui/litellm-dashboard/src/components/team/team_info.tsx diff --git a/ui/litellm-dashboard/src/components/ModelSelect/PaginatedModelSelect/PaginatedModelSelect.tsx b/ui/litellm-dashboard/src/components/ModelSelect/PaginatedModelSelect/PaginatedModelSelect.tsx index 285f4e94d68..9b22fd1bd87 100644 --- a/ui/litellm-dashboard/src/components/ModelSelect/PaginatedModelSelect/PaginatedModelSelect.tsx +++ b/ui/litellm-dashboard/src/components/ModelSelect/PaginatedModelSelect/PaginatedModelSelect.tsx @@ -72,21 +72,21 @@ export const PaginatedModelSelect = ({ const { modelName, modelId } = option.data; return ( - + <> {modelName ? ( - <> + Model name: - {modelName} + {modelName} - + Model ID: {modelId} - + ) : ( - Model ID: {modelId} + Model ID: {modelId} )} - + ); }; diff --git a/ui/litellm-dashboard/src/components/team/team_info.tsx b/ui/litellm-dashboard/src/components/team/team_info.tsx deleted file mode 100644 index 014f8fb9010..00000000000 --- a/ui/litellm-dashboard/src/components/team/team_info.tsx +++ /dev/null @@ -1,1296 +0,0 @@ -import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -import UserSearchModal from "@/components/common_components/user_search_modal"; -import { - getGuardrailsList, - getPoliciesList, - getPolicyInfoWithGuardrails, - Member, - Organization, - organizationInfoCall, - teamInfoCall, - teamMemberAddCall, - teamMemberDeleteCall, - teamMemberUpdateCall, - teamUpdateCall, -} from "@/components/networking"; -import { formatNumberWithCommas } from "@/utils/dataUtils"; -import { mapEmptyStringToNull } from "@/utils/keyUpdateUtils"; -import { isProxyAdminRole } from "@/utils/roles"; -import { InfoCircleOutlined } from "@ant-design/icons"; -import { ArrowLeftIcon } from "@heroicons/react/outline"; -import { - Badge, - Card, - Grid, - Tab, - TabGroup, - TabList, - TabPanel, - TabPanels, - Text, - TextInput, - Title, - Button as TremorButton, -} from "@tremor/react"; -import { Button, Form, Input, message, Select, Switch, Tooltip } from "antd"; -import { CheckIcon, CopyIcon } from "lucide-react"; -import React, { useEffect, useMemo, useState } from "react"; -import { copyToClipboard as utilCopyToClipboard } from "../../utils/dataUtils"; -import AgentSelector from "../agent_management/AgentSelector"; -import DeleteResourceModal from "../common_components/DeleteResourceModal"; -import DurationSelect from "../common_components/DurationSelect"; -import PassThroughRoutesSelector from "../common_components/PassThroughRoutesSelector"; -import { unfurlWildcardModelsInList } from "../key_team_helpers/fetch_available_models_team_key"; -import LoggingSettingsView from "../logging_settings_view"; -import MCPServerSelector from "../mcp_server_management/MCPServerSelector"; -import MCPToolPermissions from "../mcp_server_management/MCPToolPermissions"; -import { ModelSelect } from "../ModelSelect/ModelSelect"; -import NotificationsManager from "../molecules/notifications_manager"; -import { fetchMCPAccessGroups } from "../networking"; -import ObjectPermissionsView from "../object_permissions_view"; -import NumericalInput from "../shared/numerical_input"; -import VectorStoreSelector from "../vector_store_management/VectorStoreSelector"; -import EditLoggingSettings from "./EditLoggingSettings"; -import MemberModal from "./EditMembership"; -import MemberPermissions from "./member_permissions"; -import TeamMembersComponent from "./team_member_view"; - -export interface TeamMembership { - user_id: string; - team_id: string; - budget_id: string; - spend: number; - litellm_budget_table: { - budget_id: string; - soft_budget: number | null; - max_budget: number | null; - max_parallel_requests: number | null; - tpm_limit: number | null; - rpm_limit: number | null; - model_max_budget: Record | null; - budget_duration: string | null; - }; -} - -export interface TeamData { - team_id: string; - team_info: { - team_alias: string; - team_id: string; - organization_id: string | null; - admins: string[]; - members: string[]; - members_with_roles: Member[]; - metadata: Record; - tpm_limit: number | null; - rpm_limit: number | null; - max_budget: number | null; - soft_budget?: number | null; - budget_duration: string | null; - models: string[]; - blocked: boolean; - spend: number; - max_parallel_requests: number | null; - budget_reset_at: string | null; - model_id: string | null; - litellm_model_table: { - model_aliases: Record; - } | null; - created_at: string; - guardrails?: string[]; - policies?: string[]; - object_permission?: { - object_permission_id: string; - mcp_servers: string[]; - mcp_access_groups?: string[]; - mcp_tool_permissions?: Record; - vector_stores: string[]; - agents?: string[]; - agent_access_groups?: string[]; - }; - team_member_budget_table: { - max_budget: number; - budget_duration: string; - tpm_limit: number | null; - rpm_limit: number | null; - } | null; - }; - keys: any[]; - team_memberships: TeamMembership[]; -} - -export interface TeamInfoProps { - teamId: string; - onUpdate: (data: any) => void; - onClose: () => void; - accessToken: string | null; - is_team_admin: boolean; - is_proxy_admin: boolean; - userModels: string[]; - editTeam: boolean; - premiumUser?: boolean; -} - -const getOrganizationModels = (organization: Organization | null, userModels: string[]) => { - let tempModelsToPick = []; - - if (organization) { - // Check if organization has "all-proxy-models" in its models array - if (organization.models.includes("all-proxy-models")) { - // Treat as all-proxy-models (use userModels) - tempModelsToPick = userModels; - } else if (organization.models.length > 0) { - // Organization has specific models - tempModelsToPick = organization.models; - } else { - // Empty array [] is treated as all-proxy-models - tempModelsToPick = userModels; - } - } else { - // No organization, show all available models - tempModelsToPick = userModels; - } - - return unfurlWildcardModelsInList(tempModelsToPick, userModels); -}; - -const TeamInfoView: React.FC = ({ - teamId, - onClose, - accessToken, - is_team_admin, - is_proxy_admin, - userModels, - editTeam, - premiumUser = false, - onUpdate, -}) => { - const [teamData, setTeamData] = useState(null); - const [loading, setLoading] = useState(true); - const [isAddMemberModalVisible, setIsAddMemberModalVisible] = useState(false); - const [form] = Form.useForm(); - const [isEditMemberModalVisible, setIsEditMemberModalVisible] = useState(false); - const [selectedEditMember, setSelectedEditMember] = useState(null); - const [isEditing, setIsEditing] = useState(false); - const [mcpAccessGroups, setMcpAccessGroups] = useState([]); - const [mcpAccessGroupsLoaded, setMcpAccessGroupsLoaded] = useState(false); - const [copiedStates, setCopiedStates] = useState>({}); - const [guardrailsList, setGuardrailsList] = useState([]); - const [policiesList, setPoliciesList] = useState([]); - const [policyGuardrails, setPolicyGuardrails] = useState>({}); - const [loadingPolicies, setLoadingPolicies] = useState(false); - const [memberToDelete, setMemberToDelete] = useState(null); - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); - const [isTeamSaving, setIsTeamSaving] = useState(false); - const [organization, setOrganization] = useState(null); - const { userRole } = useAuthorized(); - - const canEditTeam = is_team_admin || is_proxy_admin; - - const fetchTeamInfo = async () => { - try { - setLoading(true); - if (!accessToken) return; - const response = await teamInfoCall(accessToken, teamId); - setTeamData(response); - } catch (error) { - NotificationsManager.fromBackend("Failed to load team information"); - console.error("Error fetching team info:", error); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - fetchTeamInfo(); - }, [teamId, accessToken]); - - // Fetch organization data when team has organization_id - useEffect(() => { - const fetchOrganization = async () => { - if (!accessToken || !teamData?.team_info?.organization_id) { - setOrganization(null); - return; - } - - try { - const orgData = await organizationInfoCall(accessToken, teamData.team_info.organization_id); - setOrganization(orgData); - } catch (error) { - console.error("Error fetching organization info:", error); - setOrganization(null); - } - }; - - fetchOrganization(); - }, [accessToken, teamData?.team_info?.organization_id]); - - // Compute modelsToPick based on organization and userModels - const modelsToPick = useMemo(() => { - return getOrganizationModels(organization, userModels); - }, [organization, userModels]); - - const fetchMcpAccessGroups = async () => { - if (!accessToken) return; - if (mcpAccessGroupsLoaded) return; - try { - const groups = await fetchMCPAccessGroups(accessToken); - setMcpAccessGroups(groups); - setMcpAccessGroupsLoaded(true); - } catch (error) { - console.error("Failed to fetch MCP access groups:", error); - } - }; - - useEffect(() => { - const fetchGuardrails = async () => { - try { - if (!accessToken) return; - const response = await getGuardrailsList(accessToken); - const guardrailNames = response.guardrails.map((g: { guardrail_name: string }) => g.guardrail_name); - setGuardrailsList(guardrailNames); - } catch (error) { - console.error("Failed to fetch guardrails:", error); - } - }; - - const fetchPolicies = async () => { - try { - if (!accessToken) return; - const response = await getPoliciesList(accessToken); - const policyNames = response.policies.map((p: { policy_name: string }) => p.policy_name); - setPoliciesList(policyNames); - } catch (error) { - console.error("Failed to fetch policies:", error); - } - }; - - fetchGuardrails(); - fetchPolicies(); - }, [accessToken]); - - // Fetch resolved guardrails for all policies - useEffect(() => { - const fetchPolicyGuardrails = async () => { - if (!accessToken || !teamData?.team_info?.policies || teamData.team_info.policies.length === 0) { - return; - } - - setLoadingPolicies(true); - const guardrailsMap: Record = {}; - - try { - await Promise.all( - teamData.team_info.policies.map(async (policyName: string) => { - try { - const policyInfo = await getPolicyInfoWithGuardrails(accessToken, policyName); - guardrailsMap[policyName] = policyInfo.resolved_guardrails || []; - } catch (error) { - console.error(`Failed to fetch guardrails for policy ${policyName}:`, error); - guardrailsMap[policyName] = []; - } - }) - ); - setPolicyGuardrails(guardrailsMap); - } catch (error) { - console.error("Failed to fetch policy guardrails:", error); - } finally { - setLoadingPolicies(false); - } - }; - - fetchPolicyGuardrails(); - }, [accessToken, teamData?.team_info?.policies]); - - const handleMemberCreate = async (values: any) => { - try { - if (accessToken == null) return; - - const member: Member = { - user_email: values.user_email, - user_id: values.user_id, - role: values.role, - }; - - await teamMemberAddCall(accessToken, teamId, member); - - NotificationsManager.success("Team member added successfully"); - setIsAddMemberModalVisible(false); - form.resetFields(); - - // Fetch updated team info - const updatedTeamData = await teamInfoCall(accessToken, teamId); - setTeamData(updatedTeamData); - - // Notify parent component of the update - onUpdate(updatedTeamData); - } catch (error: any) { - let errMsg = "Failed to add team member"; - - if (error?.raw?.detail?.error?.includes("Assigning team admins is a premium feature")) { - errMsg = "Assigning admins is an enterprise-only feature. Please upgrade your LiteLLM plan to enable this."; - } else if (error?.message) { - errMsg = error.message; - } - - NotificationsManager.fromBackend(errMsg); - console.error("Error adding team member:", error); - } - }; - - const handleMemberUpdate = async (values: any) => { - try { - if (accessToken == null) { - return; - } - - const member: Member = { - user_email: values.user_email, - user_id: values.user_id, - role: values.role, - max_budget_in_team: values.max_budget_in_team, - tpm_limit: values.tpm_limit, - rpm_limit: values.rpm_limit, - }; - console.log("Updating member with values:", member); - message.destroy(); // Remove all existing toasts - - await teamMemberUpdateCall(accessToken, teamId, member); - - NotificationsManager.success("Team member updated successfully"); - setIsEditMemberModalVisible(false); - - // Fetch updated team info - const updatedTeamData = await teamInfoCall(accessToken, teamId); - setTeamData(updatedTeamData); - - // Notify parent component of the update - onUpdate(updatedTeamData); - } catch (error: any) { - let errMsg = "Failed to update team member"; - if (error?.raw?.detail?.includes("Assigning team admins is a premium feature")) { - errMsg = "Assigning admins is an enterprise-only feature. Please upgrade your LiteLLM plan to enable this."; - } else if (error?.message) { - errMsg = error.message; - } - setIsEditMemberModalVisible(false); - - message.destroy(); // Remove all existing toasts - - NotificationsManager.fromBackend(errMsg); - console.error("Error updating team member:", error); - } - }; - - const handleMemberDelete = (member: Member) => { - setMemberToDelete(member); - setIsDeleteModalOpen(true); - }; - - const handleDeleteConfirm = async () => { - if (!memberToDelete || !accessToken) return; - - setIsDeleting(true); - try { - await teamMemberDeleteCall(accessToken, teamId, memberToDelete); - - NotificationsManager.success("Team member removed successfully"); - - // Fetch updated team info - const updatedTeamData = await teamInfoCall(accessToken, teamId); - setTeamData(updatedTeamData); - - // Notify parent component of the update - onUpdate(updatedTeamData); - } catch (error) { - NotificationsManager.fromBackend("Failed to remove team member"); - console.error("Error removing team member:", error); - } finally { - setIsDeleting(false); - setIsDeleteModalOpen(false); - setMemberToDelete(null); - } - }; - - const handleDeleteCancel = () => { - setIsDeleteModalOpen(false); - setMemberToDelete(null); - }; - - const handleTeamUpdate = async (values: any) => { - try { - if (!accessToken) return; - setIsTeamSaving(true); - - let parsedMetadata = {}; - try { - const rawMetadata = values.metadata ? JSON.parse(values.metadata) : {}; - // Exclude soft_budget_alerting_emails from parsed metadata since it's handled separately - const { soft_budget_alerting_emails, ...rest } = rawMetadata; - parsedMetadata = rest; - } catch (e) { - NotificationsManager.fromBackend("Invalid JSON in metadata field"); - return; - } - - let secretManagerSettings: Record | undefined; - if (typeof values.secret_manager_settings === "string") { - const trimmedSecretConfig = values.secret_manager_settings.trim(); - if (trimmedSecretConfig.length > 0) { - try { - secretManagerSettings = JSON.parse(values.secret_manager_settings); - } catch (e) { - NotificationsManager.fromBackend("Invalid JSON in secret manager settings"); - return; - } - } - } - - const sanitizeNumeric = (v: any) => { - if (v === null || v === undefined) return null; - if (typeof v === "string" && v.trim() === "") return null; - if (typeof v === "number" && Number.isNaN(v)) return null; - return v; - }; - - const updateData: any = { - team_id: teamId, - team_alias: values.team_alias, - models: values.models, - tpm_limit: sanitizeNumeric(values.tpm_limit), - rpm_limit: sanitizeNumeric(values.rpm_limit), - max_budget: values.max_budget, - soft_budget: sanitizeNumeric(values.soft_budget), - budget_duration: values.budget_duration, - metadata: { - ...parsedMetadata, - ...(values.guardrails?.length > 0 ? { guardrails: values.guardrails } : {}), - ...(values.logging_settings?.length > 0 ? { logging: values.logging_settings } : {}), - disable_global_guardrails: values.disable_global_guardrails || false, - soft_budget_alerting_emails: - typeof values.soft_budget_alerting_emails === "string" - ? values.soft_budget_alerting_emails - .split(",") - .map((email: string) => email.trim()) - .filter((email: string) => email.length > 0) - : values.soft_budget_alerting_emails || [], - ...(secretManagerSettings !== undefined ? { secret_manager_settings: secretManagerSettings } : {}), - }, - ...(values.policies?.length > 0 ? { policies: values.policies } : {}), - organization_id: values.organization_id, - }; - - updateData.max_budget = mapEmptyStringToNull(updateData.max_budget); - updateData.team_member_budget_duration = values.team_member_budget_duration; - - if (values.team_member_budget !== undefined) { - updateData.team_member_budget = Number(values.team_member_budget); - } - - if (values.team_member_key_duration !== undefined) { - updateData.team_member_key_duration = values.team_member_key_duration; - } - - if (values.team_member_tpm_limit !== undefined || values.team_member_rpm_limit !== undefined) { - updateData.team_member_tpm_limit = sanitizeNumeric(values.team_member_tpm_limit); - updateData.team_member_rpm_limit = sanitizeNumeric(values.team_member_rpm_limit); - } - - // Handle object_permission updates - const { servers, accessGroups } = values.mcp_servers_and_groups || { - servers: [], - accessGroups: [], - }; - const serverIds = new Set(servers || []); - const mcpToolPermissions = Object.fromEntries( - Object.entries(values.mcp_tool_permissions || {}).filter(([serverId]) => serverIds.has(serverId)), - ); - - updateData.object_permission = {}; - if (servers) { - updateData.object_permission.mcp_servers = servers; - } - if (accessGroups) { - updateData.object_permission.mcp_access_groups = accessGroups; - } - if (mcpToolPermissions) { - updateData.object_permission.mcp_tool_permissions = mcpToolPermissions; - } - delete values.mcp_servers_and_groups; - delete values.mcp_tool_permissions; - - // Handle agent permissions - const { agents, accessGroups: agentAccessGroups } = values.agents_and_groups || { - agents: [], - accessGroups: [], - }; - if (agents && agents.length > 0) { - updateData.object_permission.agents = agents; - } - if (agentAccessGroups && agentAccessGroups.length > 0) { - updateData.object_permission.agent_access_groups = agentAccessGroups; - } - delete values.agents_and_groups; - - // Handle vector stores permissions - if (values.vector_stores && values.vector_stores.length > 0) { - updateData.object_permission.vector_stores = values.vector_stores; - } - - const response = await teamUpdateCall(accessToken, updateData); - - NotificationsManager.success("Team settings updated successfully"); - setIsEditing(false); - fetchTeamInfo(); - } catch (error) { - console.error("Error updating team:", error); - } finally { - setIsTeamSaving(false); - } - }; - - if (loading) { - return
Loading...
; - } - - if (!teamData?.team_info) { - return
Team not found
; - } - - const { team_info: info } = teamData; - - const copyToClipboard = async (text: string, key: string) => { - const success = await utilCopyToClipboard(text); - if (success) { - setCopiedStates((prev) => ({ ...prev, [key]: true })); - setTimeout(() => { - setCopiedStates((prev) => ({ ...prev, [key]: false })); - }, 2000); - } - }; - - return ( -
-
-
- - Back to Teams - - {info.team_alias} -
- {info.team_id} -
-
-
- - - - {[ - Overview, - ...(canEditTeam - ? [ - Members, - Member Permissions, - Settings, - ] - : []), - ]} - - - - {/* Overview Panel */} - - - - Budget Status -
- ${formatNumberWithCommas(info.spend, 4)} - - of {info.max_budget === null ? "Unlimited" : `$${formatNumberWithCommas(info.max_budget, 4)}`} - - {info.budget_duration && Reset: {info.budget_duration}} -
- {info.team_member_budget_table && ( - - Team Member Budget: ${formatNumberWithCommas(info.team_member_budget_table.max_budget, 4)} - - )} -
-
- - - Rate Limits -
- TPM: {info.tpm_limit || "Unlimited"} - RPM: {info.rpm_limit || "Unlimited"} - {info.max_parallel_requests && Max Parallel Requests: {info.max_parallel_requests}} -
-
- - - Models -
- {info.models.length === 0 ? ( - All proxy models - ) : ( - info.models.map((model, index) => ( - - {model} - - )) - )} -
-
- - - Virtual Keys -
- User Keys: {teamData.keys.filter((key) => key.user_id).length} - Service Account Keys: {teamData.keys.filter((key) => !key.user_id).length} - Total: {teamData.keys.length} -
-
- - - - - Guardrails - {info.guardrails && info.guardrails.length > 0 ? ( -
- {info.guardrails.map((guardrail: string, index: number) => ( - - {guardrail} - - ))} -
- ) : ( - No guardrails configured - )} - {info.metadata?.disable_global_guardrails && ( -
- Global Guardrails Disabled -
- )} -
- - - Policies - {info.policies && info.policies.length > 0 ? ( -
- {info.policies.map((policy: string, index: number) => ( -
-
- {policy} - {loadingPolicies && Loading guardrails...} -
- {!loadingPolicies && policyGuardrails[policy] && policyGuardrails[policy].length > 0 && ( -
- Resolved Guardrails: -
- {policyGuardrails[policy].map((guardrail: string, gIndex: number) => ( - - {guardrail} - - ))} -
-
- )} -
- ))} -
- ) : ( - No policies configured - )} -
- - -
-
- - {/* Members Panel */} - - - - - {/* Member Permissions Panel */} - {canEditTeam && ( - - - - )} - - {/* Settings Panel */} - - -
- Team Settings - {canEditTeam && !isEditing && ( - setIsEditing(true)}>Edit Settings - )} -
- - {isEditing ? ( -
rest)(info.metadata), - null, - 2, - ) - : "", - logging_settings: info.metadata?.logging || [], - secret_manager_settings: info.metadata?.secret_manager_settings - ? JSON.stringify(info.metadata.secret_manager_settings, null, 2) - : "", - organization_id: info.organization_id, - vector_stores: info.object_permission?.vector_stores || [], - mcp_servers: info.object_permission?.mcp_servers || [], - mcp_access_groups: info.object_permission?.mcp_access_groups || [], - mcp_servers_and_groups: { - servers: info.object_permission?.mcp_servers || [], - accessGroups: info.object_permission?.mcp_access_groups || [], - }, - mcp_tool_permissions: info.object_permission?.mcp_tool_permissions || {}, - agents_and_groups: { - agents: info.object_permission?.agents || [], - accessGroups: info.object_permission?.agent_access_groups || [], - }, - }} - layout="vertical" - > - - - - - - form.setFieldValue("models", values)} - teamID={teamId} - organizationID={teamData?.team_info?.organization_id || undefined} - options={{ - includeSpecialOptions: true, - includeUserModels: !teamData?.team_info?.organization_id, - showAllProxyModelsOverride: isProxyAdminRole(userRole) && !teamData?.team_info?.organization_id, - }} - context="team" - dataTestId="models-select" - /> - - - - - - - - - - - - - - - - - - - - form.setFieldValue("team_member_budget_duration", value)} - value={form.getFieldValue("team_member_budget_duration")} - /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - Guardrails{" "} - - e.stopPropagation()} - > - - - - - } - name="guardrails" - help="Select existing guardrails or enter new ones" - > - ({ value: name, label: name }))} - /> - - - - form.setFieldValue("vector_stores", values)} - value={form.getFieldValue("vector_stores")} - accessToken={accessToken || ""} - placeholder="Select vector stores" - /> - - - - form.setFieldValue("allowed_passthrough_routes", values)} - value={form.getFieldValue("allowed_passthrough_routes")} - accessToken={accessToken || ""} - placeholder="Select pass through routes" - /> - - - - form.setFieldValue("mcp_servers_and_groups", val)} - value={form.getFieldValue("mcp_servers_and_groups")} - accessToken={accessToken || ""} - placeholder="Select MCP servers or access groups (optional)" - /> - - - {/* Hidden field to register mcp_tool_permissions with the form */} - - - - prevValues.mcp_servers_and_groups !== currentValues.mcp_servers_and_groups || - prevValues.mcp_tool_permissions !== currentValues.mcp_tool_permissions - } - > - {() => ( -
- form.setFieldsValue({ mcp_tool_permissions: toolPerms })} - /> -
- )} -
- - - form.setFieldValue("agents_and_groups", val)} - value={form.getFieldValue("agents_and_groups")} - accessToken={accessToken || ""} - placeholder="Select agents or access groups (optional)" - /> - - - - - - - - form.setFieldValue("logging_settings", values)} - /> - - - { - if (!value) { - return Promise.resolve(); - } - try { - JSON.parse(value); - return Promise.resolve(); - } catch (error) { - return Promise.reject(new Error("Please enter valid JSON")); - } - }, - }, - ]} - > - - - - - - - -
-
- setIsEditing(false)} disabled={isTeamSaving}> - Cancel - - - Save Changes - -
-
-
- ) : ( -
-
- Team Name -
{info.team_alias}
-
-
- Team ID -
{info.team_id}
-
-
- Created At -
{new Date(info.created_at).toLocaleString()}
-
-
- Models -
- {info.models.map((model, index) => ( - - {model} - - ))} -
-
-
- Rate Limits -
TPM: {info.tpm_limit || "Unlimited"}
-
RPM: {info.rpm_limit || "Unlimited"}
-
-
- Team Budget -
- Max Budget:{" "} - {info.max_budget !== null ? `$${formatNumberWithCommas(info.max_budget, 4)}` : "No Limit"} -
-
- Soft Budget:{" "} - {info.soft_budget !== null && info.soft_budget !== undefined - ? `$${formatNumberWithCommas(info.soft_budget, 4)}` - : "No Limit"} -
-
Budget Reset: {info.budget_duration || "Never"}
- {info.metadata?.soft_budget_alerting_emails && - Array.isArray(info.metadata.soft_budget_alerting_emails) && - info.metadata.soft_budget_alerting_emails.length > 0 && ( -
- Soft Budget Alerting Emails: {info.metadata.soft_budget_alerting_emails.join(", ")} -
- )} -
-
- - Team Member Settings{" "} - - - - -
Max Budget: {info.team_member_budget_table?.max_budget || "No Limit"}
-
Budget Duration: {info.team_member_budget_table?.budget_duration || "No Limit"}
-
Key Duration: {info.metadata?.team_member_key_duration || "No Limit"}
-
TPM Limit: {info.team_member_budget_table?.tpm_limit || "No Limit"}
-
RPM Limit: {info.team_member_budget_table?.rpm_limit || "No Limit"}
-
-
- Organization ID -
{info.organization_id}
-
-
- Status - {info.blocked ? "Blocked" : "Active"} -
- -
- Disable Global Guardrails -
- {info.metadata?.disable_global_guardrails === true ? ( - Enabled - Global guardrails bypassed - ) : ( - Disabled - Global guardrails active - )} -
-
- - - - - - {info.metadata?.secret_manager_settings && ( -
- Secret Manager Settings -
-                        {JSON.stringify(info.metadata.secret_manager_settings, null, 2)}
-                      
-
- )} -
- )} -
-
-
-
- - setIsEditMemberModalVisible(false)} - onSubmit={handleMemberUpdate} - initialData={selectedEditMember} - mode="edit" - config={{ - title: "Edit Member", - showEmail: true, - showUserId: true, - roleOptions: [ - { label: "Admin", value: "admin" }, - { label: "User", value: "user" }, - ], - additionalFields: [ - { - name: "max_budget_in_team", - label: ( - - Team Member Budget (USD){" "} - - - - - ), - type: "numerical" as const, - step: 0.01, - min: 0, - placeholder: "Budget limit for this member within this team", - }, - { - name: "tpm_limit", - label: ( - - Team Member TPM Limit{" "} - - - - - ), - type: "numerical" as const, - step: 1, - min: 0, - placeholder: "Tokens per minute limit for this member in this team", - }, - { - name: "rpm_limit", - label: ( - - Team Member RPM Limit{" "} - - - - - ), - type: "numerical" as const, - step: 1, - min: 0, - placeholder: "Requests per minute limit for this member in this team", - }, - ], - }} - /> - - setIsAddMemberModalVisible(false)} - onSubmit={handleMemberCreate} - accessToken={accessToken} - /> - - {/* Delete Member Confirmation Modal */} - -
- ); -}; - -export default TeamInfoView; From ea38630e7cfb240290758543dcc319b2263692a1 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 10 Feb 2026 13:58:57 -0800 Subject: [PATCH 05/11] =?UTF-8?q?bump:=20version=200.4.33=20=E2=86=92=200.?= =?UTF-8?q?4.34?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- litellm-proxy-extras/pyproject.toml | 4 ++-- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/litellm-proxy-extras/pyproject.toml b/litellm-proxy-extras/pyproject.toml index 8937858bbd9..e0a769a5edf 100644 --- a/litellm-proxy-extras/pyproject.toml +++ b/litellm-proxy-extras/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "litellm-proxy-extras" -version = "0.4.33" +version = "0.4.34" description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package." authors = ["BerriAI"] readme = "README.md" @@ -22,7 +22,7 @@ requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" [tool.commitizen] -version = "0.4.33" +version = "0.4.34" version_files = [ "pyproject.toml:version", "../requirements.txt:litellm-proxy-extras==", diff --git a/pyproject.toml b/pyproject.toml index f26e49093de..eb68cd2f4a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,7 @@ boto3 = { version = "1.40.76", optional = true } redisvl = {version = "^0.4.1", optional = true, markers = "python_version >= '3.9' and python_version < '3.14'"} mcp = {version = ">=1.25.0,<2.0.0", optional = true, python = ">=3.10"} a2a-sdk = {version = "^0.3.22", optional = true, python = ">=3.10"} -litellm-proxy-extras = {version = "0.4.33", optional = true} +litellm-proxy-extras = {version = "0.4.34", optional = true} rich = {version = "13.7.1", optional = true} litellm-enterprise = {version = "0.1.31", optional = true} diskcache = {version = "^5.6.1", optional = true} diff --git a/requirements.txt b/requirements.txt index f680de120c5..be4c6e2f7e2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -55,7 +55,7 @@ sentry_sdk==2.21.0 # for sentry error handling detect-secrets==1.5.0 # Enterprise - secret detection / masking in LLM requests cryptography==44.0.1 tzdata==2025.1 # IANA time zone database -litellm-proxy-extras==0.4.33 # for proxy extras - e.g. prisma migrations +litellm-proxy-extras==0.4.34 # for proxy extras - e.g. prisma migrations llm-sandbox==0.3.31 # for skill execution in sandbox ### LITELLM PACKAGE DEPENDENCIES python-dotenv==1.0.1 # for env From 7d2c874434577be17047372ebc8dcc4c48b226be Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 10 Feb 2026 13:59:48 -0800 Subject: [PATCH 06/11] Fixing ci pypi build --- ...litellm_proxy_extras-0.4.34-py3-none-any.whl | Bin 0 -> 53171 bytes .../dist/litellm_proxy_extras-0.4.34.tar.gz | Bin 0 -> 24232 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 litellm-proxy-extras/dist/litellm_proxy_extras-0.4.34-py3-none-any.whl create mode 100644 litellm-proxy-extras/dist/litellm_proxy_extras-0.4.34.tar.gz diff --git a/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.34-py3-none-any.whl b/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.34-py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..175d84543ec1d32172a52329fe2341a80f7ce006 GIT binary patch literal 53171 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 literal 0 HcmV?d00001 diff --git a/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.34.tar.gz b/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.34.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..e1fcc0c603ff7a6841ffa918cbba387cb3b32ba6 GIT binary patch literal 24232 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 I?EvTl0735q9smFU literal 0 HcmV?d00001 From a6f90586ac4a3c1efe97690ebafaa51a87c521f4 Mon Sep 17 00:00:00 2001 From: Emerson Gomes Date: Tue, 10 Feb 2026 16:46:40 -0600 Subject: [PATCH 07/11] feat(model-db): add azure_ai/kimi-k2.5 pricing entry (#20896) --- litellm/model_prices_and_context_window_backup.json | 13 +++++++++++++ model_prices_and_context_window.json | 13 +++++++++++++ 2 files changed, 26 insertions(+) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 4812a2d8a10..d794aa50d2e 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -5848,6 +5848,19 @@ "output_cost_per_token": 7e-07, "supports_tool_choice": true }, + "azure_ai/kimi-k2.5": { + "input_cost_per_token": 6e-07, + "litellm_provider": "azure_ai", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 3e-06, + "source": "https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/kimi-k2-5-now-in-microsoft-foundry/4492321", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, "azure_ai/ministral-3b": { "input_cost_per_token": 4e-08, "litellm_provider": "azure_ai", diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 4812a2d8a10..d794aa50d2e 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -5848,6 +5848,19 @@ "output_cost_per_token": 7e-07, "supports_tool_choice": true }, + "azure_ai/kimi-k2.5": { + "input_cost_per_token": 6e-07, + "litellm_provider": "azure_ai", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 3e-06, + "source": "https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/kimi-k2-5-now-in-microsoft-foundry/4492321", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, "azure_ai/ministral-3b": { "input_cost_per_token": 4e-08, "litellm_provider": "azure_ai", From 10d891a36579b012a027902f2d3482e0ba00a0ce Mon Sep 17 00:00:00 2001 From: Krish Dholakia Date: Tue, 10 Feb 2026 15:13:54 -0800 Subject: [PATCH 08/11] Guardrails - add logging to all unified_guardrails + link to custom code guardrail templates (#20900) * feat(guardrail_hooks/): add guardrail logging to all unified guardrails ensures unified guardrails use the 'log_guardrail_information' decorator for logging * fix(custom_guardrail.py): don't log inputs on guardrail response - just emit state * refactor: don't double log bedrock guardrail information * feat: add in-product nudges for contributing + trying community custom code guardrails allows users to contribute / share custom code guardrails --- .circleci/config.yml | 1 + litellm/integrations/custom_guardrail.py | 53 +++++++- .../out/{404.html => 404/index.html} | 0 .../index.html} | 0 .../index.html} | 0 .../index.html} | 0 .../{budgets.html => budgets/index.html} | 0 .../{caching.html => caching/index.html} | 0 .../index.html} | 0 .../{old-usage.html => old-usage/index.html} | 0 .../{prompts.html => prompts/index.html} | 0 .../index.html} | 0 .../index.html} | 0 .../out/{login.html => login/index.html} | 0 .../out/{logs.html => logs/index.html} | 0 .../{callback.html => callback/index.html} | 0 .../{model-hub.html => model-hub/index.html} | 0 .../{model_hub.html => model_hub/index.html} | 0 .../index.html} | 0 .../index.html} | 0 .../index.html} | 0 .../index.html} | 0 .../index.html} | 0 .../{policies.html => policies/index.html} | 0 .../index.html} | 0 .../index.html} | 0 .../index.html} | 0 .../{ui-theme.html => ui-theme/index.html} | 0 .../out/{teams.html => teams/index.html} | 0 .../{test-key.html => test-key/index.html} | 0 .../index.html} | 0 .../index.html} | 0 .../out/{usage.html => usage/index.html} | 0 .../out/{users.html => users/index.html} | 0 .../index.html} | 0 .../guardrail_hooks/bedrock_guardrails.py | 12 +- .../custom_code/custom_code_guardrail.py | 6 +- .../guardrail_hooks/enkryptai/enkryptai.py | 6 +- .../generic_guardrail_api.py | 6 +- .../guardrail_hooks/grayswan/grayswan.py | 43 +++--- .../hiddenlayer/hiddenlayer.py | 6 +- .../litellm_content_filter/content_filter.py | 13 +- .../guardrails/guardrail_hooks/onyx/onyx.py | 12 +- .../guardrail_hooks/openai/moderations.py | 100 +++++++------- .../guardrails/guardrail_hooks/presidio.py | 26 ++-- .../prompt_security/prompt_security.py | 6 +- .../guardrail_hooks/qualifire/qualifire.py | 12 +- .../zscaler_ai_guard/zscaler_ai_guard.py | 16 ++- .../check_guardrail_apply_decorator.py | 126 ++++++++++++++++++ ui/litellm-dashboard/package-lock.json | 15 +++ ui/litellm-dashboard/package.json | 2 +- .../custom_code/CustomCodeModal.tsx | 71 +++++++++- ui/litellm-dashboard/tsconfig.json | 2 +- 53 files changed, 418 insertions(+), 116 deletions(-) rename litellm/proxy/_experimental/out/{404.html => 404/index.html} (100%) rename litellm/proxy/_experimental/out/{_not-found.html => _not-found/index.html} (100%) rename litellm/proxy/_experimental/out/{api-reference.html => api-reference/index.html} (100%) rename litellm/proxy/_experimental/out/experimental/{api-playground.html => api-playground/index.html} (100%) rename litellm/proxy/_experimental/out/experimental/{budgets.html => budgets/index.html} (100%) rename litellm/proxy/_experimental/out/experimental/{caching.html => caching/index.html} (100%) rename litellm/proxy/_experimental/out/experimental/{claude-code-plugins.html => claude-code-plugins/index.html} (100%) rename litellm/proxy/_experimental/out/experimental/{old-usage.html => old-usage/index.html} (100%) rename litellm/proxy/_experimental/out/experimental/{prompts.html => prompts/index.html} (100%) rename litellm/proxy/_experimental/out/experimental/{tag-management.html => tag-management/index.html} (100%) rename litellm/proxy/_experimental/out/{guardrails.html => guardrails/index.html} (100%) rename litellm/proxy/_experimental/out/{login.html => login/index.html} (100%) rename litellm/proxy/_experimental/out/{logs.html => logs/index.html} (100%) rename litellm/proxy/_experimental/out/mcp/oauth/{callback.html => callback/index.html} (100%) rename litellm/proxy/_experimental/out/{model-hub.html => model-hub/index.html} (100%) rename litellm/proxy/_experimental/out/{model_hub.html => model_hub/index.html} (100%) rename litellm/proxy/_experimental/out/{model_hub_table.html => model_hub_table/index.html} (100%) rename litellm/proxy/_experimental/out/{models-and-endpoints.html => models-and-endpoints/index.html} (100%) rename litellm/proxy/_experimental/out/{onboarding.html => onboarding/index.html} (100%) rename litellm/proxy/_experimental/out/{organizations.html => organizations/index.html} (100%) rename litellm/proxy/_experimental/out/{playground.html => playground/index.html} (100%) rename litellm/proxy/_experimental/out/{policies.html => policies/index.html} (100%) rename litellm/proxy/_experimental/out/settings/{admin-settings.html => admin-settings/index.html} (100%) rename litellm/proxy/_experimental/out/settings/{logging-and-alerts.html => logging-and-alerts/index.html} (100%) rename litellm/proxy/_experimental/out/settings/{router-settings.html => router-settings/index.html} (100%) rename litellm/proxy/_experimental/out/settings/{ui-theme.html => ui-theme/index.html} (100%) rename litellm/proxy/_experimental/out/{teams.html => teams/index.html} (100%) rename litellm/proxy/_experimental/out/{test-key.html => test-key/index.html} (100%) rename litellm/proxy/_experimental/out/tools/{mcp-servers.html => mcp-servers/index.html} (100%) rename litellm/proxy/_experimental/out/tools/{vector-stores.html => vector-stores/index.html} (100%) rename litellm/proxy/_experimental/out/{usage.html => usage/index.html} (100%) rename litellm/proxy/_experimental/out/{users.html => users/index.html} (100%) rename litellm/proxy/_experimental/out/{virtual-keys.html => virtual-keys/index.html} (100%) create mode 100644 tests/code_coverage_tests/check_guardrail_apply_decorator.py diff --git a/.circleci/config.yml b/.circleci/config.yml index e171759f1c4..39182e4c6f3 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -2277,6 +2277,7 @@ jobs: - run: python ./tests/code_coverage_tests/router_code_coverage.py - run: python ./tests/code_coverage_tests/test_chat_completion_imports.py - run: python ./tests/code_coverage_tests/info_log_check.py + - run: python ./tests/code_coverage_tests/check_guardrail_apply_decorator.py - run: python ./tests/code_coverage_tests/test_ban_set_verbose.py - run: python ./tests/code_coverage_tests/code_qa_check_tests.py - run: python ./tests/code_coverage_tests/check_get_model_cost_key_performance.py diff --git a/litellm/integrations/custom_guardrail.py b/litellm/integrations/custom_guardrail.py index bbd55a59bce..407bc581f71 100644 --- a/litellm/integrations/custom_guardrail.py +++ b/litellm/integrations/custom_guardrail.py @@ -616,6 +616,7 @@ class CustomGuardrail(CustomLogger): end_time: Optional[float] = None, duration: Optional[float] = None, event_type: Optional[GuardrailEventHooks] = None, + original_inputs: Optional[Dict] = None, ): """ Add StandardLoggingGuardrailInformation to the request data @@ -625,6 +626,17 @@ class CustomGuardrail(CustomLogger): # Convert None to empty dict to satisfy type requirements guardrail_response = {} if response is None else response + # For apply_guardrail functions in custom_code_guardrail scenario, + # simplify the logged response to "allow", "deny", or "mask" + if original_inputs is not None and isinstance(response, dict): + # Check if inputs were modified by comparing them + if self._inputs_were_modified(original_inputs, response): + guardrail_response = "mask" + else: + guardrail_response = "allow" + + verbose_logger.debug(f"Guardrail response: {response}") + self.add_standard_logging_guardrail_information_to_request_data( guardrail_json_response=guardrail_response, request_data=request_data, @@ -650,8 +662,14 @@ class CustomGuardrail(CustomLogger): This gets logged on downsteam Langfuse, DataDog, etc. """ + # For custom_code_guardrail scenario, log as "deny" instead of full exception + # Check if this is from custom_code_guardrail by checking the class name + guardrail_response: Union[Exception, str] = e + if "CustomCodeGuardrail" in self.__class__.__name__: + guardrail_response = "deny" + self.add_standard_logging_guardrail_information_to_request_data( - guardrail_json_response=e, + guardrail_json_response=guardrail_response, request_data=request_data, guardrail_status="guardrail_failed_to_respond", duration=duration, @@ -661,6 +679,25 @@ class CustomGuardrail(CustomLogger): ) raise e + def _inputs_were_modified(self, original_inputs: Dict, response: Dict) -> bool: + """ + Compare original inputs with response to determine if content was modified. + + Returns True if the inputs were modified (mask scenario), False otherwise (allow scenario). + """ + # Get all keys from both dictionaries + all_keys = set(original_inputs.keys()) | set(response.keys()) + + # Compare each key's value + for key in all_keys: + original_value = original_inputs.get(key) + response_value = response.get(key) + if original_value != response_value: + return True + + # No modifications detected + return False + def mask_content_in_string( self, content_string: str, @@ -768,6 +805,12 @@ def log_guardrail_information(func): self: CustomGuardrail = args[0] request_data: dict = kwargs.get("data") or kwargs.get("request_data") or {} event_type = _infer_event_type_from_function_name(func.__name__) + + # Store original inputs for comparison (for apply_guardrail functions) + original_inputs = None + if func.__name__ == "apply_guardrail" and "inputs" in kwargs: + original_inputs = kwargs.get("inputs") + try: response = await func(*args, **kwargs) return self._process_response( @@ -777,6 +820,7 @@ def log_guardrail_information(func): end_time=datetime.now().timestamp(), duration=(datetime.now() - start_time).total_seconds(), event_type=event_type, + original_inputs=original_inputs, ) except Exception as e: return self._process_error( @@ -794,6 +838,12 @@ def log_guardrail_information(func): self: CustomGuardrail = args[0] request_data: dict = kwargs.get("data") or kwargs.get("request_data") or {} event_type = _infer_event_type_from_function_name(func.__name__) + + # Store original inputs for comparison (for apply_guardrail functions) + original_inputs = None + if func.__name__ == "apply_guardrail" and "inputs" in kwargs: + original_inputs = kwargs.get("inputs") + try: response = func(*args, **kwargs) return self._process_response( @@ -801,6 +851,7 @@ def log_guardrail_information(func): request_data=request_data, duration=(datetime.now() - start_time).total_seconds(), event_type=event_type, + original_inputs=original_inputs, ) except Exception as e: return self._process_error( diff --git a/litellm/proxy/_experimental/out/404.html b/litellm/proxy/_experimental/out/404/index.html similarity index 100% rename from litellm/proxy/_experimental/out/404.html rename to litellm/proxy/_experimental/out/404/index.html diff --git a/litellm/proxy/_experimental/out/_not-found.html b/litellm/proxy/_experimental/out/_not-found/index.html similarity index 100% rename from litellm/proxy/_experimental/out/_not-found.html rename to litellm/proxy/_experimental/out/_not-found/index.html diff --git a/litellm/proxy/_experimental/out/api-reference.html b/litellm/proxy/_experimental/out/api-reference/index.html similarity index 100% rename from litellm/proxy/_experimental/out/api-reference.html rename to litellm/proxy/_experimental/out/api-reference/index.html diff --git a/litellm/proxy/_experimental/out/experimental/api-playground.html b/litellm/proxy/_experimental/out/experimental/api-playground/index.html similarity index 100% rename from litellm/proxy/_experimental/out/experimental/api-playground.html rename to litellm/proxy/_experimental/out/experimental/api-playground/index.html diff --git a/litellm/proxy/_experimental/out/experimental/budgets.html b/litellm/proxy/_experimental/out/experimental/budgets/index.html similarity index 100% rename from litellm/proxy/_experimental/out/experimental/budgets.html rename to litellm/proxy/_experimental/out/experimental/budgets/index.html diff --git a/litellm/proxy/_experimental/out/experimental/caching.html b/litellm/proxy/_experimental/out/experimental/caching/index.html similarity index 100% rename from litellm/proxy/_experimental/out/experimental/caching.html rename to litellm/proxy/_experimental/out/experimental/caching/index.html diff --git a/litellm/proxy/_experimental/out/experimental/claude-code-plugins.html b/litellm/proxy/_experimental/out/experimental/claude-code-plugins/index.html similarity index 100% rename from litellm/proxy/_experimental/out/experimental/claude-code-plugins.html rename to litellm/proxy/_experimental/out/experimental/claude-code-plugins/index.html diff --git a/litellm/proxy/_experimental/out/experimental/old-usage.html b/litellm/proxy/_experimental/out/experimental/old-usage/index.html similarity index 100% rename from litellm/proxy/_experimental/out/experimental/old-usage.html rename to litellm/proxy/_experimental/out/experimental/old-usage/index.html diff --git a/litellm/proxy/_experimental/out/experimental/prompts.html b/litellm/proxy/_experimental/out/experimental/prompts/index.html similarity index 100% rename from litellm/proxy/_experimental/out/experimental/prompts.html rename to litellm/proxy/_experimental/out/experimental/prompts/index.html diff --git a/litellm/proxy/_experimental/out/experimental/tag-management.html b/litellm/proxy/_experimental/out/experimental/tag-management/index.html similarity index 100% rename from litellm/proxy/_experimental/out/experimental/tag-management.html rename to litellm/proxy/_experimental/out/experimental/tag-management/index.html diff --git a/litellm/proxy/_experimental/out/guardrails.html b/litellm/proxy/_experimental/out/guardrails/index.html similarity index 100% rename from litellm/proxy/_experimental/out/guardrails.html rename to litellm/proxy/_experimental/out/guardrails/index.html diff --git a/litellm/proxy/_experimental/out/login.html b/litellm/proxy/_experimental/out/login/index.html similarity index 100% rename from litellm/proxy/_experimental/out/login.html rename to litellm/proxy/_experimental/out/login/index.html diff --git a/litellm/proxy/_experimental/out/logs.html b/litellm/proxy/_experimental/out/logs/index.html similarity index 100% rename from litellm/proxy/_experimental/out/logs.html rename to litellm/proxy/_experimental/out/logs/index.html diff --git a/litellm/proxy/_experimental/out/mcp/oauth/callback.html b/litellm/proxy/_experimental/out/mcp/oauth/callback/index.html similarity index 100% rename from litellm/proxy/_experimental/out/mcp/oauth/callback.html rename to litellm/proxy/_experimental/out/mcp/oauth/callback/index.html diff --git a/litellm/proxy/_experimental/out/model-hub.html b/litellm/proxy/_experimental/out/model-hub/index.html similarity index 100% rename from litellm/proxy/_experimental/out/model-hub.html rename to litellm/proxy/_experimental/out/model-hub/index.html diff --git a/litellm/proxy/_experimental/out/model_hub.html b/litellm/proxy/_experimental/out/model_hub/index.html similarity index 100% rename from litellm/proxy/_experimental/out/model_hub.html rename to litellm/proxy/_experimental/out/model_hub/index.html diff --git a/litellm/proxy/_experimental/out/model_hub_table.html b/litellm/proxy/_experimental/out/model_hub_table/index.html similarity index 100% rename from litellm/proxy/_experimental/out/model_hub_table.html rename to litellm/proxy/_experimental/out/model_hub_table/index.html diff --git a/litellm/proxy/_experimental/out/models-and-endpoints.html b/litellm/proxy/_experimental/out/models-and-endpoints/index.html similarity index 100% rename from litellm/proxy/_experimental/out/models-and-endpoints.html rename to litellm/proxy/_experimental/out/models-and-endpoints/index.html diff --git a/litellm/proxy/_experimental/out/onboarding.html b/litellm/proxy/_experimental/out/onboarding/index.html similarity index 100% rename from litellm/proxy/_experimental/out/onboarding.html rename to litellm/proxy/_experimental/out/onboarding/index.html diff --git a/litellm/proxy/_experimental/out/organizations.html b/litellm/proxy/_experimental/out/organizations/index.html similarity index 100% rename from litellm/proxy/_experimental/out/organizations.html rename to litellm/proxy/_experimental/out/organizations/index.html diff --git a/litellm/proxy/_experimental/out/playground.html b/litellm/proxy/_experimental/out/playground/index.html similarity index 100% rename from litellm/proxy/_experimental/out/playground.html rename to litellm/proxy/_experimental/out/playground/index.html diff --git a/litellm/proxy/_experimental/out/policies.html b/litellm/proxy/_experimental/out/policies/index.html similarity index 100% rename from litellm/proxy/_experimental/out/policies.html rename to litellm/proxy/_experimental/out/policies/index.html diff --git a/litellm/proxy/_experimental/out/settings/admin-settings.html b/litellm/proxy/_experimental/out/settings/admin-settings/index.html similarity index 100% rename from litellm/proxy/_experimental/out/settings/admin-settings.html rename to litellm/proxy/_experimental/out/settings/admin-settings/index.html diff --git a/litellm/proxy/_experimental/out/settings/logging-and-alerts.html b/litellm/proxy/_experimental/out/settings/logging-and-alerts/index.html similarity index 100% rename from litellm/proxy/_experimental/out/settings/logging-and-alerts.html rename to litellm/proxy/_experimental/out/settings/logging-and-alerts/index.html diff --git a/litellm/proxy/_experimental/out/settings/router-settings.html b/litellm/proxy/_experimental/out/settings/router-settings/index.html similarity index 100% rename from litellm/proxy/_experimental/out/settings/router-settings.html rename to litellm/proxy/_experimental/out/settings/router-settings/index.html diff --git a/litellm/proxy/_experimental/out/settings/ui-theme.html b/litellm/proxy/_experimental/out/settings/ui-theme/index.html similarity index 100% rename from litellm/proxy/_experimental/out/settings/ui-theme.html rename to litellm/proxy/_experimental/out/settings/ui-theme/index.html diff --git a/litellm/proxy/_experimental/out/teams.html b/litellm/proxy/_experimental/out/teams/index.html similarity index 100% rename from litellm/proxy/_experimental/out/teams.html rename to litellm/proxy/_experimental/out/teams/index.html diff --git a/litellm/proxy/_experimental/out/test-key.html b/litellm/proxy/_experimental/out/test-key/index.html similarity index 100% rename from litellm/proxy/_experimental/out/test-key.html rename to litellm/proxy/_experimental/out/test-key/index.html diff --git a/litellm/proxy/_experimental/out/tools/mcp-servers.html b/litellm/proxy/_experimental/out/tools/mcp-servers/index.html similarity index 100% rename from litellm/proxy/_experimental/out/tools/mcp-servers.html rename to litellm/proxy/_experimental/out/tools/mcp-servers/index.html diff --git a/litellm/proxy/_experimental/out/tools/vector-stores.html b/litellm/proxy/_experimental/out/tools/vector-stores/index.html similarity index 100% rename from litellm/proxy/_experimental/out/tools/vector-stores.html rename to litellm/proxy/_experimental/out/tools/vector-stores/index.html diff --git a/litellm/proxy/_experimental/out/usage.html b/litellm/proxy/_experimental/out/usage/index.html similarity index 100% rename from litellm/proxy/_experimental/out/usage.html rename to litellm/proxy/_experimental/out/usage/index.html diff --git a/litellm/proxy/_experimental/out/users.html b/litellm/proxy/_experimental/out/users/index.html similarity index 100% rename from litellm/proxy/_experimental/out/users.html rename to litellm/proxy/_experimental/out/users/index.html diff --git a/litellm/proxy/_experimental/out/virtual-keys.html b/litellm/proxy/_experimental/out/virtual-keys/index.html similarity index 100% rename from litellm/proxy/_experimental/out/virtual-keys.html rename to litellm/proxy/_experimental/out/virtual-keys/index.html diff --git a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py index f8fba5f5984..6800dff55ac 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py +++ b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py @@ -795,9 +795,9 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): ######################################################### ########## 1. Make the Bedrock API request ########## ######################################################### - bedrock_guardrail_response: Optional[ - Union[BedrockGuardrailResponse, str] - ] = None + bedrock_guardrail_response: Optional[Union[BedrockGuardrailResponse, str]] = ( + None + ) try: bedrock_guardrail_response = await self.make_bedrock_api_request( source="INPUT", messages=filtered_messages, request_data=data @@ -867,9 +867,9 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): ######################################################### ########## 1. Make the Bedrock API request ########## ######################################################### - bedrock_guardrail_response: Optional[ - Union[BedrockGuardrailResponse, str] - ] = None + bedrock_guardrail_response: Optional[Union[BedrockGuardrailResponse, str]] = ( + None + ) try: bedrock_guardrail_response = await self.make_bedrock_api_request( source="INPUT", messages=filtered_messages, request_data=data diff --git a/litellm/proxy/guardrails/guardrail_hooks/custom_code/custom_code_guardrail.py b/litellm/proxy/guardrails/guardrail_hooks/custom_code/custom_code_guardrail.py index 68f9dfd7abc..66b80c10f18 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/custom_code/custom_code_guardrail.py +++ b/litellm/proxy/guardrails/guardrail_hooks/custom_code/custom_code_guardrail.py @@ -35,7 +35,10 @@ from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Type, cast from fastapi import HTTPException from litellm._logging import verbose_proxy_logger -from litellm.integrations.custom_guardrail import CustomGuardrail +from litellm.integrations.custom_guardrail import ( + CustomGuardrail, + log_guardrail_information, +) from litellm.types.guardrails import GuardrailEventHooks from litellm.types.proxy.guardrails.guardrail_hooks.base import GuardrailConfigModel from litellm.types.utils import GenericGuardrailAPIInputs @@ -179,6 +182,7 @@ class CustomCodeGuardrail(CustomGuardrail): self._compile_error = f"Failed to compile custom code: {e}" raise CustomCodeCompilationError(self._compile_error) from e + @log_guardrail_information async def apply_guardrail( self, inputs: GenericGuardrailAPIInputs, diff --git a/litellm/proxy/guardrails/guardrail_hooks/enkryptai/enkryptai.py b/litellm/proxy/guardrails/guardrail_hooks/enkryptai/enkryptai.py index 8e992297e5d..63541a1e2f9 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/enkryptai/enkryptai.py +++ b/litellm/proxy/guardrails/guardrail_hooks/enkryptai/enkryptai.py @@ -23,7 +23,10 @@ import httpx import litellm from litellm._logging import verbose_proxy_logger from litellm.caching.caching import DualCache -from litellm.integrations.custom_guardrail import CustomGuardrail +from litellm.integrations.custom_guardrail import ( + CustomGuardrail, + log_guardrail_information, +) from litellm.llms.custom_httpx.http_handler import ( get_async_httpx_client, httpxSpecialProvider, @@ -483,6 +486,7 @@ class EnkryptAIGuardrails(CustomGuardrail): request_data=data, guardrail_name=self.guardrail_name ) + @log_guardrail_information async def apply_guardrail( self, inputs: "GenericGuardrailAPIInputs", diff --git a/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py b/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py index b37074e25e7..9018675d7a5 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py +++ b/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py @@ -10,7 +10,10 @@ from typing import TYPE_CHECKING, Any, Dict, Literal, Optional from litellm._logging import verbose_proxy_logger from litellm.exceptions import GuardrailRaisedException -from litellm.integrations.custom_guardrail import CustomGuardrail +from litellm.integrations.custom_guardrail import ( + CustomGuardrail, + log_guardrail_information, +) from litellm.llms.custom_httpx.http_handler import ( get_async_httpx_client, httpxSpecialProvider, @@ -150,6 +153,7 @@ class GenericGuardrailAPI(CustomGuardrail): return result_metadata + @log_guardrail_information async def apply_guardrail( self, inputs: GenericGuardrailAPIInputs, diff --git a/litellm/proxy/guardrails/guardrail_hooks/grayswan/grayswan.py b/litellm/proxy/guardrails/guardrail_hooks/grayswan/grayswan.py index 90f689ed23c..8955bffc125 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/grayswan/grayswan.py +++ b/litellm/proxy/guardrails/guardrail_hooks/grayswan/grayswan.py @@ -9,7 +9,8 @@ from fastapi import HTTPException from litellm._logging import verbose_proxy_logger from litellm.integrations.custom_guardrail import ( CustomGuardrail, - ModifyResponseException + ModifyResponseException, + log_guardrail_information, ) from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.litellm_core_utils.safe_json_loads import safe_json_loads @@ -108,7 +109,9 @@ class GraySwanGuardrail(CustomGuardrail): self.categories = categories self.policy_id = policy_id self.fail_open = True if fail_open is None else bool(fail_open) - self.guardrail_timeout = 30.0 if guardrail_timeout is None else float(guardrail_timeout) + self.guardrail_timeout = ( + 30.0 if guardrail_timeout is None else float(guardrail_timeout) + ) # Streaming configuration self.streaming_end_of_stream_only = streaming_end_of_stream_only @@ -155,6 +158,7 @@ class GraySwanGuardrail(CustomGuardrail): # Unified Guardrail Interface (works with ALL endpoints automatically) # ------------------------------------------------------------------ + @log_guardrail_information async def apply_guardrail( self, inputs: GenericGuardrailAPIInputs, @@ -208,7 +212,9 @@ class GraySwanGuardrail(CustomGuardrail): messages = [{"role": role, "content": text} for text in texts] # Get dynamic params from request metadata - dynamic_body = self.get_guardrail_dynamic_request_body_params(request_data) or {} + dynamic_body = ( + self.get_guardrail_dynamic_request_body_params(request_data) or {} + ) if dynamic_body: verbose_proxy_logger.debug( "Gray Swan Guardrail: dynamic extra_body=%s", safe_dumps(dynamic_body) @@ -271,12 +277,12 @@ class GraySwanGuardrail(CustomGuardrail): async def run_grayswan_guardrail(self, payload: dict) -> Dict[str, Any]: """ Run the GraySwan guardrail on a payload. - + This is a legacy method for testing purposes. - + Args: payload: The payload to scan - + Returns: Dict containing the GraySwan API response """ @@ -293,11 +299,11 @@ class GraySwanGuardrail(CustomGuardrail): ) -> None: """ Legacy method for processing GraySwan API responses. - + This method is maintained for backward compatibility with existing tests. It handles the test scenarios where responses need to be processed with knowledge of the request context (pre/during/post call hooks). - + Args: response_json: Response from GraySwan API data: Optional request data (for passthrough exceptions) @@ -365,7 +371,10 @@ class GraySwanGuardrail(CustomGuardrail): ) # If hook_type is provided and in pre/during call, raise exception - if hook_type in [GuardrailEventHooks.pre_call, GuardrailEventHooks.during_call]: + if hook_type in [ + GuardrailEventHooks.pre_call, + GuardrailEventHooks.during_call, + ]: # Raise ModifyResponseException to short-circuit LLM call if data is None: data = {} @@ -540,7 +549,9 @@ class GraySwanGuardrail(CustomGuardrail): if isinstance(litellm_metadata, dict) and litellm_metadata: cleaned_litellm_metadata = dict(litellm_metadata) # cleaned_litellm_metadata.pop("user_api_key_auth", None) - sanitized = safe_json_loads(safe_dumps(cleaned_litellm_metadata), default={}) + sanitized = safe_json_loads( + safe_dumps(cleaned_litellm_metadata), default={} + ) if isinstance(sanitized, dict) and sanitized: payload["litellm_metadata"] = sanitized @@ -566,7 +577,9 @@ class GraySwanGuardrail(CustomGuardrail): detection_info = detection_info[0] # Extract fields from detection_info dict - detection_dict: dict = detection_info if isinstance(detection_info, dict) else {} + detection_dict: dict = ( + detection_info if isinstance(detection_info, dict) else {} + ) violation_score = detection_dict.get("violation_score", 0.0) violated_rules = detection_dict.get("violated_rules", []) mutation = detection_dict.get("mutation", False) @@ -582,7 +595,9 @@ class GraySwanGuardrail(CustomGuardrail): if violated_rules: formatted_rules = self._format_violated_rules(violated_rules) if formatted_rules: - message_parts.append(f"It was violating the rule(s): {formatted_rules}.") + message_parts.append( + f"It was violating the rule(s): {formatted_rules}." + ) if mutation: message_parts.append( @@ -590,9 +605,7 @@ class GraySwanGuardrail(CustomGuardrail): ) if ipi: - message_parts.append( - "Indirect Prompt Injection was DETECTED." - ) + message_parts.append("Indirect Prompt Injection was DETECTED.") return "\n".join(message_parts) diff --git a/litellm/proxy/guardrails/guardrail_hooks/hiddenlayer/hiddenlayer.py b/litellm/proxy/guardrails/guardrail_hooks/hiddenlayer/hiddenlayer.py index e2c20604880..b907fbbcbda 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/hiddenlayer/hiddenlayer.py +++ b/litellm/proxy/guardrails/guardrail_hooks/hiddenlayer/hiddenlayer.py @@ -10,7 +10,10 @@ from httpx import HTTPStatusError from requests.auth import HTTPBasicAuth from litellm._logging import verbose_proxy_logger -from litellm.integrations.custom_guardrail import CustomGuardrail +from litellm.integrations.custom_guardrail import ( + CustomGuardrail, + log_guardrail_information, +) from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.llms.custom_httpx.http_handler import ( get_async_httpx_client, @@ -110,6 +113,7 @@ class HiddenlayerGuardrail(CustomGuardrail): ) super().__init__(**kwargs) + @log_guardrail_information async def apply_guardrail( self, inputs: GenericGuardrailAPIInputs, diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py index 083a407e9cf..263b6eee768 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py +++ b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py @@ -28,7 +28,10 @@ from fastapi import HTTPException from litellm import Router from litellm._logging import verbose_proxy_logger -from litellm.integrations.custom_guardrail import CustomGuardrail +from litellm.integrations.custom_guardrail import ( + CustomGuardrail, + log_guardrail_information, +) from litellm.proxy._types import UserAPIKeyAuth from litellm.types.utils import ModelResponseStream @@ -50,6 +53,7 @@ from litellm.types.proxy.guardrails.guardrail_hooks.litellm_content_filter impor ContentFilterDetection, PatternDetection, ) + from .patterns import PATTERN_EXTRA_CONFIG, get_compiled_pattern MAX_KEYWORD_VALUE_GAP_WORDS = 1 @@ -168,9 +172,9 @@ class ContentFilterGuardrail(CustomGuardrail): self.image_model = image_model # Store loaded categories self.loaded_categories: Dict[str, CategoryConfig] = {} - self.category_keywords: Dict[ - str, Tuple[str, str, ContentFilterAction] - ] = {} # keyword -> (category, severity, action) + self.category_keywords: Dict[str, Tuple[str, str, ContentFilterAction]] = ( + {} + ) # keyword -> (category, severity, action) # Load categories if provided if categories: @@ -994,6 +998,7 @@ class ContentFilterGuardrail(CustomGuardrail): masked_entity_count=masked_entity_count, ) + @log_guardrail_information async def apply_guardrail( self, inputs: "GenericGuardrailAPIInputs", diff --git a/litellm/proxy/guardrails/guardrail_hooks/onyx/onyx.py b/litellm/proxy/guardrails/guardrail_hooks/onyx/onyx.py index 3598dbe741e..1cfc805dbf9 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/onyx/onyx.py +++ b/litellm/proxy/guardrails/guardrail_hooks/onyx/onyx.py @@ -12,7 +12,10 @@ import httpx from fastapi import HTTPException from litellm._logging import verbose_proxy_logger -from litellm.integrations.custom_guardrail import CustomGuardrail +from litellm.integrations.custom_guardrail import ( + CustomGuardrail, + log_guardrail_information, +) from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.llms.custom_httpx.http_handler import ( get_async_httpx_client, @@ -26,7 +29,11 @@ if TYPE_CHECKING: class OnyxGuardrail(CustomGuardrail): def __init__( - self, api_base: Optional[str] = None, api_key: Optional[str] = None, timeout: Optional[float] = 10.0, **kwargs + self, + api_base: Optional[str] = None, + api_key: Optional[str] = None, + timeout: Optional[float] = 10.0, + **kwargs, ): timeout = timeout or int(os.getenv("ONYX_TIMEOUT", 10.0)) self.async_handler = get_async_httpx_client( @@ -79,6 +86,7 @@ class OnyxGuardrail(CustomGuardrail): ) return result + @log_guardrail_information async def apply_guardrail( self, inputs: GenericGuardrailAPIInputs, diff --git a/litellm/proxy/guardrails/guardrail_hooks/openai/moderations.py b/litellm/proxy/guardrails/guardrail_hooks/openai/moderations.py index 030b6036815..a196937ef6c 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/openai/moderations.py +++ b/litellm/proxy/guardrails/guardrail_hooks/openai/moderations.py @@ -58,7 +58,9 @@ class OpenAIModerationGuardrail(OpenAIGuardrailBase, CustomGuardrail): guardrail_name: str, api_key: Optional[str] = None, api_base: Optional[str] = None, - model: Optional[Literal["omni-moderation-latest", "text-moderation-latest"]] = None, + model: Optional[ + Literal["omni-moderation-latest", "text-moderation-latest"] + ] = None, **kwargs, ): """Initialize OpenAI Moderation guardrail handler.""" @@ -75,7 +77,7 @@ class OpenAIModerationGuardrail(OpenAIGuardrailBase, CustomGuardrail): supported_event_hooks=supported_event_hooks, **kwargs, ) - + self.async_handler = get_async_httpx_client( llm_provider=httpxSpecialProvider.GuardrailCallback ) @@ -83,10 +85,14 @@ class OpenAIModerationGuardrail(OpenAIGuardrailBase, CustomGuardrail): # Store configuration self.api_key = api_key or self._get_api_key() self.api_base = api_base or "https://api.openai.com/v1" - self.model: Literal["omni-moderation-latest", "text-moderation-latest"] = model or "omni-moderation-latest" + self.model: Literal["omni-moderation-latest", "text-moderation-latest"] = ( + model or "omni-moderation-latest" + ) if not self.api_key: - raise ValueError("OpenAI Moderation: api_key is required. Set OPENAI_API_KEY environment variable or pass it in configuration.") + raise ValueError( + "OpenAI Moderation: api_key is required. Set OPENAI_API_KEY environment variable or pass it in configuration." + ) verbose_proxy_logger.debug( f"Initialized OpenAI Moderation Guardrail: {guardrail_name} with model: {self.model}" @@ -98,7 +104,7 @@ class OpenAIModerationGuardrail(OpenAIGuardrailBase, CustomGuardrail): import litellm from litellm.secret_managers.main import get_secret_str - + return ( os.environ.get("OPENAI_API_KEY") or litellm.api_key @@ -106,21 +112,14 @@ class OpenAIModerationGuardrail(OpenAIGuardrailBase, CustomGuardrail): or get_secret_str("OPENAI_API_KEY") ) - async def async_make_request( - self, input_text: str - ) -> "OpenAIModerationResponse": + async def async_make_request(self, input_text: str) -> "OpenAIModerationResponse": """ Make a request to the OpenAI Moderation API. """ - request_body = { - "model": self.model, - "input": input_text - } - - verbose_proxy_logger.debug( - "OpenAI Moderation guard request: %s", request_body - ) - + request_body = {"model": self.model, "input": input_text} + + verbose_proxy_logger.debug("OpenAI Moderation guard request: %s", request_body) + response = await self.async_handler.post( url=f"{self.api_base}/moderations", headers={ @@ -133,7 +132,7 @@ class OpenAIModerationGuardrail(OpenAIGuardrailBase, CustomGuardrail): verbose_proxy_logger.debug( "OpenAI Moderation guard response: %s", response.json() ) - + if response.status_code != 200: raise HTTPException( status_code=response.status_code, @@ -144,9 +143,12 @@ class OpenAIModerationGuardrail(OpenAIGuardrailBase, CustomGuardrail): ) from litellm.types.llms.openai import OpenAIModerationResponse + return OpenAIModerationResponse(**response.json()) - def _check_moderation_result(self, moderation_response: "OpenAIModerationResponse") -> None: + def _check_moderation_result( + self, moderation_response: "OpenAIModerationResponse" + ) -> None: """ Check if the moderation response indicates harmful content and raise exception if needed. """ @@ -168,10 +170,10 @@ class OpenAIModerationGuardrail(OpenAIGuardrailBase, CustomGuardrail): } verbose_proxy_logger.warning( - "OpenAI Moderation: Content flagged for violations: %s", - violation_details + "OpenAI Moderation: Content flagged for violations: %s", + violation_details, ) - + raise HTTPException( status_code=400, detail={ @@ -180,6 +182,7 @@ class OpenAIModerationGuardrail(OpenAIGuardrailBase, CustomGuardrail): }, ) + @log_guardrail_information async def apply_guardrail( self, inputs: GenericGuardrailAPIInputs, @@ -189,51 +192,50 @@ class OpenAIModerationGuardrail(OpenAIGuardrailBase, CustomGuardrail): ) -> GenericGuardrailAPIInputs: """ Apply OpenAI moderation guardrail using the unified guardrail interface. - + This method is called by the UnifiedLLMGuardrails system for all endpoint types (chat completions, embeddings, responses API, etc.). - + Args: inputs: GenericGuardrailAPIInputs containing texts and/or structured_messages request_data: The original request data input_type: Whether this is a "request" (pre-call) or "response" (post-call) logging_obj: Optional logging object - + Returns: The inputs unchanged (moderation doesn't modify content, only blocks) - + Raises: HTTPException: If content violates moderation policy """ # Extract text to moderate from inputs text_to_moderate: Optional[str] = None - + # Prefer structured_messages if available (has role context) if structured_messages := inputs.get("structured_messages"): text_to_moderate = self.get_user_prompt(structured_messages) - + # Fall back to texts if not text_to_moderate: if texts := inputs.get("texts"): # Join all texts for moderation text_to_moderate = "\n".join(texts) - + if not text_to_moderate: verbose_proxy_logger.debug( "OpenAI Moderation: No text content to moderate in inputs" ) return inputs - + # Make moderation request moderation_response = await self.async_make_request(input_text=text_to_moderate) - + # Check if content is flagged and raise exception if needed self._check_moderation_result(moderation_response) - + # Moderation doesn't modify content, just blocks - return inputs unchanged return inputs - @log_guardrail_information async def async_post_call_streaming_iterator_hook( self, @@ -252,9 +254,7 @@ class OpenAIModerationGuardrail(OpenAIGuardrailBase, CustomGuardrail): from litellm.main import stream_chunk_builder from litellm.types.utils import TextCompletionResponse - verbose_proxy_logger.debug( - "OpenAI Moderation: Running streaming response scan" - ) + verbose_proxy_logger.debug("OpenAI Moderation: Running streaming response scan") # Collect all chunks to process them together all_chunks: List["ModelResponseStream"] = [] @@ -269,7 +269,7 @@ class OpenAIModerationGuardrail(OpenAIGuardrailBase, CustomGuardrail): ) if isinstance(assembled_model_response, (type(None), TextCompletionResponse)): - # If we can't assemble a ModelResponse or it's a text completion, + # If we can't assemble a ModelResponse or it's a text completion, # just yield the original chunks without moderation verbose_proxy_logger.warning( "OpenAI Moderation: Could not assemble ModelResponse from chunks, skipping moderation" @@ -284,19 +284,17 @@ class OpenAIModerationGuardrail(OpenAIGuardrailBase, CustomGuardrail): verbose_proxy_logger.debug( f"OpenAI Moderation: Streaming response text: {response_text[:100]}..." # Log first 100 chars ) - + # Make moderation request - this will raise HTTPException if content is flagged moderation_response = await self.async_make_request( input_text=response_text, ) - + # Check if content is flagged and raise exception if needed self._check_moderation_result(moderation_response) # If we reach here, content passed moderation - yield the original chunks - mock_response = MockResponseIterator( - model_response=assembled_model_response - ) + mock_response = MockResponseIterator(model_response=assembled_model_response) # Return the reconstructed stream async for chunk in mock_response: @@ -306,34 +304,34 @@ class OpenAIModerationGuardrail(OpenAIGuardrailBase, CustomGuardrail): """ Extract text content from the model response for moderation. """ - if not hasattr(response, 'choices') or not response.choices: + if not hasattr(response, "choices") or not response.choices: return None response_texts = [] for choice in response.choices: try: # Try to get content from message (chat completion) - message = getattr(choice, 'message', None) + message = getattr(choice, "message", None) if message: - content = getattr(message, 'content', None) + content = getattr(message, "content", None) if content and isinstance(content, str): response_texts.append(content) continue - + # Try to get text (text completion) - text = getattr(choice, 'text', None) + text = getattr(choice, "text", None) if text and isinstance(text, str): response_texts.append(text) continue - + # Try to get content from delta (streaming) - delta = getattr(choice, 'delta', None) + delta = getattr(choice, "delta", None) if delta: - content = getattr(delta, 'content', None) + content = getattr(delta, "content", None) if content and isinstance(content, str): response_texts.append(content) continue - + except (AttributeError, TypeError): # Skip choices that don't have expected attributes continue diff --git a/litellm/proxy/guardrails/guardrail_hooks/presidio.py b/litellm/proxy/guardrails/guardrail_hooks/presidio.py index 71ad9819146..d71b8449f94 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/presidio.py +++ b/litellm/proxy/guardrails/guardrail_hooks/presidio.py @@ -9,10 +9,10 @@ import asyncio -import threading import json -from datetime import datetime +import threading from contextlib import asynccontextmanager +from datetime import datetime from typing import ( TYPE_CHECKING, Any, @@ -39,7 +39,10 @@ if TYPE_CHECKING: from litellm._uuid import uuid from litellm.caching.caching import DualCache from litellm.exceptions import BlockedPiiEntityError -from litellm.integrations.custom_guardrail import CustomGuardrail +from litellm.integrations.custom_guardrail import ( + CustomGuardrail, + log_guardrail_information, +) from litellm.proxy._types import UserAPIKeyAuth from litellm.types.guardrails import ( GuardrailEventHooks, @@ -568,9 +571,9 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail): if messages is None: return data tasks = [] - task_mappings: List[ - Tuple[int, Optional[int]] - ] = [] # Track (message_index, content_index) for each task + task_mappings: List[Tuple[int, Optional[int]]] = ( + [] + ) # Track (message_index, content_index) for each task for msg_idx, m in enumerate(messages): content = m.get("content", None) @@ -671,9 +674,9 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail): ): # /chat/completions requests messages: Optional[List] = kwargs.get("messages", None) tasks = [] - task_mappings: List[ - Tuple[int, Optional[int]] - ] = [] # Track (message_index, content_index) for each task + task_mappings: List[Tuple[int, Optional[int]]] = ( + [] + ) # Track (message_index, content_index) for each task if messages is None: return kwargs, result @@ -792,11 +795,11 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail): # Type narrowing: StreamingChoices doesn't have .message attribute if not hasattr(choice, "message"): continue - content = getattr(choice.message, "content", None) + content = getattr(choice.message, "content", None) # type: ignore if content is None: continue if isinstance(content, str): - choice.message.content = await self.check_pii( + choice.message.content = await self.check_pii( # type: ignore text=content, output_parse_pii=False, presidio_config=presidio_config, @@ -989,6 +992,7 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail): except Exception: pass + @log_guardrail_information async def apply_guardrail( self, inputs: "GenericGuardrailAPIInputs", diff --git a/litellm/proxy/guardrails/guardrail_hooks/prompt_security/prompt_security.py b/litellm/proxy/guardrails/guardrail_hooks/prompt_security/prompt_security.py index 5ebc7b96eb8..b3e761869b0 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/prompt_security/prompt_security.py +++ b/litellm/proxy/guardrails/guardrail_hooks/prompt_security/prompt_security.py @@ -6,7 +6,10 @@ from typing import TYPE_CHECKING, Any, List, Literal, Optional, Type from fastapi import HTTPException from litellm._logging import verbose_proxy_logger -from litellm.integrations.custom_guardrail import CustomGuardrail +from litellm.integrations.custom_guardrail import ( + CustomGuardrail, + log_guardrail_information, +) from litellm.llms.custom_httpx.http_handler import ( get_async_httpx_client, httpxSpecialProvider, @@ -67,6 +70,7 @@ class PromptSecurityGuardrail(CustomGuardrail): super().__init__(**kwargs) + @log_guardrail_information async def apply_guardrail( self, inputs: GenericGuardrailAPIInputs, diff --git a/litellm/proxy/guardrails/guardrail_hooks/qualifire/qualifire.py b/litellm/proxy/guardrails/guardrail_hooks/qualifire/qualifire.py index 87da11efad0..6486da7f714 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/qualifire/qualifire.py +++ b/litellm/proxy/guardrails/guardrail_hooks/qualifire/qualifire.py @@ -12,10 +12,11 @@ from typing import Any, Dict, List, Literal, Optional, Type from fastapi import HTTPException from litellm._logging import verbose_proxy_logger -from litellm.integrations.custom_guardrail import CustomGuardrail -from litellm.litellm_core_utils.litellm_logging import ( - Logging as LiteLLMLoggingObj, +from litellm.integrations.custom_guardrail import ( + CustomGuardrail, + log_guardrail_information, ) +from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.llms.custom_httpx.http_handler import ( get_async_httpx_client, httpxSpecialProvider, @@ -343,9 +344,7 @@ class QualifireGuardrail(CustomGuardrail): ) url = f"{self.qualifire_api_base}/api/evaluation/evaluate" - verbose_proxy_logger.debug( - f"Qualifire Guardrail: Making request to {url}" - ) + verbose_proxy_logger.debug(f"Qualifire Guardrail: Making request to {url}") # Make the API request response = await self.async_handler.post( @@ -393,6 +392,7 @@ class QualifireGuardrail(CustomGuardrail): verbose_proxy_logger.exception(f"Qualifire Guardrail error: {e}") raise + @log_guardrail_information async def apply_guardrail( self, inputs: GenericGuardrailAPIInputs, diff --git a/litellm/proxy/guardrails/guardrail_hooks/zscaler_ai_guard/zscaler_ai_guard.py b/litellm/proxy/guardrails/guardrail_hooks/zscaler_ai_guard/zscaler_ai_guard.py index c60752d7952..ff00cd73ca5 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/zscaler_ai_guard/zscaler_ai_guard.py +++ b/litellm/proxy/guardrails/guardrail_hooks/zscaler_ai_guard/zscaler_ai_guard.py @@ -9,7 +9,10 @@ from typing import TYPE_CHECKING, Literal, Optional from fastapi import HTTPException from litellm._logging import verbose_proxy_logger -from litellm.integrations.custom_guardrail import CustomGuardrail +from litellm.integrations.custom_guardrail import ( + CustomGuardrail, + log_guardrail_information, +) from litellm.llms.custom_httpx.http_handler import ( get_async_httpx_client, httpxSpecialProvider, @@ -70,6 +73,7 @@ class ZscalerAIGuard(CustomGuardrail): return str(value).strip() return "N/A" + @log_guardrail_information async def apply_guardrail( self, inputs: "GenericGuardrailAPIInputs", @@ -92,7 +96,7 @@ class ZscalerAIGuard(CustomGuardrail): Raises: Exception: If content is blocked by Zscaler AI Guard """ - + texts = inputs.get("texts", []) try: verbose_proxy_logger.debug(f"ZscalerAIGuard: Checking {len(texts)} text(s)") @@ -102,8 +106,8 @@ class ZscalerAIGuard(CustomGuardrail): team_metadata = metadata.get("team_metadata", {}) or {} # Precedence for policy_id: - # 1. metadata.zguard_policy_id # request level - # 2. user_api_key_metadata.zguard_policy_id # Key level + # 1. metadata.zguard_policy_id # request level + # 2. user_api_key_metadata.zguard_policy_id # Key level # 3. team_metadata.zguard_policy_id # Team level # 4. self.policy_id (from environment) # Global policy_id = ( @@ -154,9 +158,7 @@ class ZscalerAIGuard(CustomGuardrail): zscaler_ai_guard_result and zscaler_ai_guard_result.get("action") == "BLOCK" ): - blocking_info = zscaler_ai_guard_result.get( - "zscaler_ai_guard_response" - ) + blocking_info = zscaler_ai_guard_result.get("zscaler_ai_guard_response") error_message = f"Content blocked by Zscaler AI Guard: {self.extract_blocking_info(blocking_info)}" raise Exception(error_message) except Exception as e: diff --git a/tests/code_coverage_tests/check_guardrail_apply_decorator.py b/tests/code_coverage_tests/check_guardrail_apply_decorator.py new file mode 100644 index 00000000000..18a86277aa9 --- /dev/null +++ b/tests/code_coverage_tests/check_guardrail_apply_decorator.py @@ -0,0 +1,126 @@ +""" +Test that all guardrail hooks with async def apply_guardrail use @log_guardrail_information decorator. + +This ensures consistent logging and observability across all guardrail implementations. +""" + +import ast +from pathlib import Path +from typing import List, Tuple + + +def find_apply_guardrail_methods(file_path: Path) -> List[Tuple[str, int, bool]]: + """ + Find all apply_guardrail methods and check if they have the decorator. + + Returns: + List of tuples: (class_name, line_number, has_decorator) + """ + with open(file_path, "r") as f: + content = f.read() + + try: + tree = ast.parse(content) + except SyntaxError: + return [] + + results = [] + + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef): + class_name = node.name + + # Check if this class has apply_guardrail method + for item in node.body: + if ( + isinstance(item, ast.AsyncFunctionDef) + and item.name == "apply_guardrail" + ): + # Check if it has the log_guardrail_information decorator + has_decorator = False + for decorator in item.decorator_list: + if ( + isinstance(decorator, ast.Name) + and decorator.id == "log_guardrail_information" + ): + has_decorator = True + break + + results.append((class_name, item.lineno, has_decorator)) + + return results + + +def test_guardrail_apply_decorator(): + """Test that all guardrail hooks with apply_guardrail have the decorator.""" + # Path to the guardrail hooks directory + guardrail_hooks_dir = ( + Path(__file__).parent.parent.parent + / "litellm" + / "proxy" + / "guardrails" + / "guardrail_hooks" + ) + + # Find all Python files in the guardrail hooks directory + python_files = list(guardrail_hooks_dir.rglob("*.py")) + + # Track violations + violations = [] + + for python_file in python_files: + # Skip __init__.py files and test files + if python_file.name == "__init__.py" or python_file.name.startswith("test_"): + continue + + # Skip base files and primitives + if python_file.name in ["base.py", "primitives.py", "patterns.py"]: + continue + + # Skip bedrock_guardrails.py - it implements logging differently via + # add_standard_logging_guardrail_information_to_request_data calls + # in make_bedrock_api_request method instead of using the decorator + if python_file.name == "bedrock_guardrails.py": + continue + + results = find_apply_guardrail_methods(python_file) + + for class_name, line_num, has_decorator in results: + if not has_decorator: + relative_path = python_file.relative_to( + Path(__file__).parent.parent.parent + ) + violations.append((relative_path, class_name, line_num)) + + # Assert no violations found + if violations: + print( + f"\nFound {len(violations)} guardrail hook(s) without @log_guardrail_information decorator:" + ) + print( + "\nAll guardrail hooks must use @log_guardrail_information decorator on their apply_guardrail method." + ) + print( + "This ensures consistent logging and observability across all guardrails.\n" + ) + + for file_path, class_name, line_num in violations: + print(f" - {file_path}:{line_num} ({class_name}.apply_guardrail)") + + print("\nTo fix, add the decorator:") + print( + " from litellm.integrations.custom_guardrail import log_guardrail_information" + ) + print(" ") + print(" @log_guardrail_information") + print(" async def apply_guardrail(self, ...):") + print(" ...") + + raise AssertionError( + f"Found {len(violations)} guardrail hook(s) without @log_guardrail_information decorator" + ) + + +if __name__ == "__main__": + test_guardrail_apply_decorator() + print("✓ All guardrail hooks have @log_guardrail_information decorator") diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index 4205657ca8c..3a21813fbf4 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -13159,6 +13159,21 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz", + "integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } } } } diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index 76ac97f008c..74caf14a594 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev --webpack", + "dev": "next dev", "build": "next build", "start": "next start", "lint": "next lint", diff --git a/ui/litellm-dashboard/src/components/guardrails/custom_code/CustomCodeModal.tsx b/ui/litellm-dashboard/src/components/guardrails/custom_code/CustomCodeModal.tsx index fb1b967334c..866d24df50b 100644 --- a/ui/litellm-dashboard/src/components/guardrails/custom_code/CustomCodeModal.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/custom_code/CustomCodeModal.tsx @@ -1,5 +1,5 @@ import React, { useState, useRef, useEffect } from "react"; -import { Modal, Select, Switch, Collapse, Input } from "antd"; +import { Modal, Select, Switch, Collapse, Input, Divider } from "antd"; import { Button, TextInput } from "@tremor/react"; import { CodeOutlined, @@ -8,6 +8,8 @@ import { CloseCircleOutlined, CaretRightOutlined, SaveOutlined, + UsergroupAddOutlined, + ExportOutlined, } from "@ant-design/icons"; import { createGuardrailCall, updateGuardrailCall, testCustomCodeGuardrail } from "../../networking"; import NotificationsManager from "../../molecules/notifications_manager"; @@ -91,6 +93,7 @@ const CODE_TEMPLATES = { }, }; + // Available primitives organized by category const PRIMITIVES = { "Return Values": [ @@ -241,6 +244,8 @@ const CustomCodeModal: React.FC = ({ // Handle template change const handleTemplateChange = (templateKey: string) => { setSelectedTemplate(templateKey); + + // Check if it's a standard template setCode(CODE_TEMPLATES[templateKey as keyof typeof CODE_TEMPLATES].code); }; @@ -486,12 +491,45 @@ const CustomCodeModal: React.FC = ({ onChange={handleTemplateChange} className="w-full" size="middle" + dropdownRender={(menu) => ( + <> + {menu} + +
{ + e.preventDefault(); + window.open('https://models.litellm.ai/guardrails', '_blank'); + }} + onMouseEnter={(e) => { + e.currentTarget.style.backgroundColor = '#f0f0f0'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = 'transparent'; + }} + > + + Browse Community templates + +
+ + )} > - {Object.entries(CODE_TEMPLATES).map(([key, template]) => ( - - {template.name} - - ))} + + {Object.entries(CODE_TEMPLATES).map(([key, template]) => ( + + {template.name} + + ))} +
@@ -632,6 +670,27 @@ const CustomCodeModal: React.FC = ({
+ {/* Contribution CTA Banner */} +
+
+
+ +
+
+
Built a useful guardrail?
+
Share it with the community and help others build faster
+
+
+ +
+ {/* Primitives Panel */} diff --git a/ui/litellm-dashboard/tsconfig.json b/ui/litellm-dashboard/tsconfig.json index d24bdd340f7..5b0352feb98 100644 --- a/ui/litellm-dashboard/tsconfig.json +++ b/ui/litellm-dashboard/tsconfig.json @@ -14,7 +14,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "react-jsx", + "jsx": "preserve", "incremental": true, "plugins": [ { From f8619e2000e66dba5c98a09ce0ef517a97f2ff1b Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Tue, 10 Feb 2026 15:17:01 -0800 Subject: [PATCH 09/11] [Stability] Investigate + fix issue where model cost map became poorly formatted (#20895) * init: GetModelCostMap * fix * docs * docs fix * docs fixes * docs fix * test model cost map resilience * MODEL_COST_MAP_MIN_MODEL_COUNT * validate_model_cost_map * test_should_have_minimum_models_in_backup * docs fix * docs fix * fix * dos fix * docs fix * docs fix * docs fix * docs fix * validate_model_cost_map * fix * cleanup --- .../blog/model_cost_map_incident/index.md | 95 ++++++ docs/my-website/sidebars.js | 11 + litellm/constants.py | 8 + .../litellm_core_utils/get_model_cost_map.py | 201 ++++++++++-- .../test_model_cost_map_resilience.py | 291 ++++++++++++++++++ 5 files changed, 579 insertions(+), 27 deletions(-) create mode 100644 docs/my-website/blog/model_cost_map_incident/index.md create mode 100644 tests/llm_translation/test_model_cost_map_resilience.py diff --git a/docs/my-website/blog/model_cost_map_incident/index.md b/docs/my-website/blog/model_cost_map_incident/index.md new file mode 100644 index 00000000000..7f1324e5fab --- /dev/null +++ b/docs/my-website/blog/model_cost_map_incident/index.md @@ -0,0 +1,95 @@ +--- +slug: model-cost-map-incident +title: "Incident Report: Invalid model cost map on main" +date: 2026-02-10T10:00:00 +authors: + - name: Ishaan Jaffer + title: "CTO, LiteLLM" + url: https://www.linkedin.com/in/ishaanjaffer/ + image_url: https://pbs.twimg.com/profile_images/1613813310264340481/lz54oEiB_400x400.jpg +tags: [incident-report, stability] +hide_table_of_contents: false +--- + +**Date:** January 27, 2026 +**Duration:** ~20 minutes +**Severity:** Low +**Status:** Resolved + +## Summary + +A malformed JSON entry in `model_prices_and_context_window.json` was merged to `main` ([`562f0a0`](https://github.com/BerriAI/litellm/commit/562f0a028251750e3d75386bee0e630d9796d0df)). This caused LiteLLM to silently fall back to a stale local copy of the model cost map. Users on older package versions lost cost tracking for newer models only (e.g. `azure/gpt-5.2`). No LLM calls were blocked. + +- **LLM calls and proxy routing:** No impact. +- **Cost tracking:** Impacted for newer models not present in the local backup. Older models were unaffected. The incident lasted ~20 minutes until the commit was reverted. + +{/* truncate */} + +--- + +## Background + +The model cost map is not in the request path. It is used after the LLM response comes back, inside a try/catch, to calculate spend. A missing entry never blocks a call. + +```mermaid +flowchart TD + A["1. litellm.completion() receives request + litellm/main.py"] --> B["2. Route to provider + litellm/litellm_core_utils/get_llm_provider_logic.py"] + B --> C["3. LLM returns response + litellm/main.py"] + C --> D["4. Post-call: look up model in cost map + litellm/cost_calculator.py"] + D -->|"found"| E["5a. Attach cost to response"] + D -->|"not found (try/catch)"| F["5b. Log warning, set cost=0"] + E --> G["6. Return response to caller"] + F --> G + + style D fill:#fff3cd,stroke:#ffc107 + style F fill:#fff3cd,stroke:#ffc107 + style E fill:#d4edda,stroke:#28a745 + style G fill:#d4edda,stroke:#28a745 +``` + +Both paths return a response to the caller. When the cost map lookup fails, the only difference is `cost=0` on that request. + +--- + +## Root cause + +LiteLLM fetches the model cost map from GitHub `main` at import time. If the fetch fails, it falls back to a local backup bundled with the package. Before this incident, the fallback was completely silent -- no warning was logged. + +A contributor PR introduced an extra `{` bracket, producing invalid JSON. The remote fetch failed with `JSONDecodeError`, triggering the silent fallback. Users on older package versions had backup files missing newer models. + +**Timeline:** + +1. Malformed JSON merged to `main` +2. LiteLLM installations fall back to local backup on next import +3. Users report `"This model isn't mapped yet"` for newer models +4. Bad commit identified and reverted (~20 minutes) + +--- + +## Remediation + +| # | Action | Status | Code | +|---|---|---|---| +| 1 | CI validation on `model_prices_and_context_window.json` | ✅ Done | [PR #20605](https://github.com/BerriAI/litellm/pull/20605) | +| 2 | Warning log on fallback to local backup | ✅ Done | [`get_model_cost_map.py`](https://github.com/BerriAI/litellm/blob/main/litellm/litellm_core_utils/get_model_cost_map.py) | +| 3 | `GetModelCostMap` class with integrity validation helpers | ✅ Done | [`get_model_cost_map.py`](https://github.com/BerriAI/litellm/blob/main/litellm/litellm_core_utils/get_model_cost_map.py) | +| 4 | Resilience test suite (bad hosted map, bad backup, fallback, completion) | ✅ Done | [`test_model_cost_map_resilience.py`](https://github.com/BerriAI/litellm/blob/main/tests/llm_translation/test_model_cost_map_resilience.py) | +| 5 | Test that backup model cost map always exists and contains common models | ✅ Done | [`test_model_cost_map_resilience.py`](https://github.com/BerriAI/litellm/blob/main/tests/llm_translation/test_model_cost_map_resilience.py) | + +Enterprises that require zero external dependencies at import time can set `LITELLM_LOCAL_MODEL_COST_MAP=True` to skip the GitHub fetch entirely. + +--- + +## Other dependencies on external resources + +| Dependency | Impact if unavailable | Fallback | +|---|---|---| +| Model cost map (GitHub) | Cost tracking for newer models | Local backup (now with warning) | +| JWT public keys (IDP/SSO) | Auth fails | None | +| OIDC UserInfo (IDP/SSO) | Auth fails | None | +| HuggingFace model API | HF provider calls fail | None | +| Ollama tags (localhost) | Ollama model list stale | Static list | diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index 2c3dfb2b863..28e1724ef82 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -1085,6 +1085,17 @@ const sidebars = { "troubleshoot/max_callbacks", ], }, + { + type: "category", + label: "Blog", + items: [ + { + type: "link", + label: "Incident: Broken Model Cost Map", + href: "/blog/model-cost-map-incident", + }, + ], + }, ], }; diff --git a/litellm/constants.py b/litellm/constants.py index 9c25cf77906..180315ace0e 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -48,6 +48,14 @@ DEFAULT_REPLICATE_POLLING_DELAY_SECONDS = int( os.getenv("DEFAULT_REPLICATE_POLLING_DELAY_SECONDS", 1) ) DEFAULT_IMAGE_TOKEN_COUNT = int(os.getenv("DEFAULT_IMAGE_TOKEN_COUNT", 250)) + +# Model cost map validation constants +MODEL_COST_MAP_MIN_MODEL_COUNT = int( + os.getenv("MODEL_COST_MAP_MIN_MODEL_COUNT", 50) +) # Minimum number of models a fetched cost map must contain to be considered valid +MODEL_COST_MAP_MAX_SHRINK_RATIO = float( + os.getenv("MODEL_COST_MAP_MAX_SHRINK_RATIO", 0.5) +) # Maximum allowed shrinkage ratio vs local backup (0.5 = reject if fetched map is <50% of backup) DEFAULT_IMAGE_WIDTH = int(os.getenv("DEFAULT_IMAGE_WIDTH", 300)) DEFAULT_IMAGE_HEIGHT = int(os.getenv("DEFAULT_IMAGE_HEIGHT", 300)) # Maximum size for image URL downloads in MB (default 50MB, set to 0 to disable limit) diff --git a/litellm/litellm_core_utils/get_model_cost_map.py b/litellm/litellm_core_utils/get_model_cost_map.py index 9b86f4ca2f0..e622a317454 100644 --- a/litellm/litellm_core_utils/get_model_cost_map.py +++ b/litellm/litellm_core_utils/get_model_cost_map.py @@ -8,40 +8,187 @@ export LITELLM_LOCAL_MODEL_COST_MAP=True ``` """ +import json import os +from importlib.resources import files import httpx +from litellm import verbose_logger +from litellm.constants import ( + MODEL_COST_MAP_MAX_SHRINK_RATIO, + MODEL_COST_MAP_MIN_MODEL_COUNT, +) + + +class GetModelCostMap: + """ + Handles fetching, validating, and loading the model cost map. + + Only the backup model *count* is cached (a single int). The full + backup dict is never held in memory — it is only parsed when it + needs to be *returned* as a fallback. + """ + + _backup_model_count: int = -1 # -1 = not yet loaded + + @staticmethod + def load_local_model_cost_map() -> dict: + """Load the local backup model cost map bundled with the package.""" + content = json.loads( + files("litellm") + .joinpath("model_prices_and_context_window_backup.json") + .read_text(encoding="utf-8") + ) + return content + + @classmethod + def _get_backup_model_count(cls) -> int: + """Return the number of models in the local backup (cached int).""" + if cls._backup_model_count < 0: + backup = cls.load_local_model_cost_map() + cls._backup_model_count = len(backup) + return cls._backup_model_count + + @staticmethod + def _check_is_valid_dict(fetched_map: dict) -> bool: + """Check 1: fetched map is a non-empty dict.""" + if not isinstance(fetched_map, dict): + verbose_logger.warning( + "LiteLLM: Fetched model cost map is not a dict (type=%s). " + "Falling back to local backup.", + type(fetched_map).__name__, + ) + return False + + if len(fetched_map) == 0: + verbose_logger.warning( + "LiteLLM: Fetched model cost map is empty. " + "Falling back to local backup.", + ) + return False + + return True + + @classmethod + def _check_model_count_not_reduced( + cls, + fetched_map: dict, + backup_model_count: int, + min_model_count: int = MODEL_COST_MAP_MIN_MODEL_COUNT, + max_shrink_ratio: float = MODEL_COST_MAP_MAX_SHRINK_RATIO, + ) -> bool: + """Check 2: model count has not reduced significantly vs backup.""" + fetched_count = len(fetched_map) + + if fetched_count < min_model_count: + verbose_logger.warning( + "LiteLLM: Fetched model cost map has only %d models (minimum=%d). " + "This may indicate a corrupted upstream file. " + "Falling back to local backup.", + fetched_count, + min_model_count, + ) + return False + + if backup_model_count > 0 and fetched_count < backup_model_count * max_shrink_ratio: + verbose_logger.warning( + "LiteLLM: Fetched model cost map shrank significantly " + "(fetched=%d, backup=%d, threshold=%.0f%%). " + "This may indicate a corrupted upstream file. " + "Falling back to local backup.", + fetched_count, + backup_model_count, + max_shrink_ratio * 100, + ) + return False + + return True + + @classmethod + def validate_model_cost_map( + cls, + fetched_map: dict, + backup_model_count: int, + min_model_count: int = MODEL_COST_MAP_MIN_MODEL_COUNT, + max_shrink_ratio: float = MODEL_COST_MAP_MAX_SHRINK_RATIO, + ) -> bool: + """ + Validate the integrity of a fetched model cost map. + + Runs each check in order and returns False on the first failure. + + Checks: + 1. ``_check_is_valid_dict`` -- fetched map is a non-empty dict. + 2. ``_check_model_count_not_reduced`` -- model count meets minimum + and has not shrunk >``max_shrink_ratio`` vs backup. + + Returns True if all checks pass, False otherwise. + """ + if not cls._check_is_valid_dict(fetched_map): + return False + + if not cls._check_model_count_not_reduced( + fetched_map=fetched_map, + backup_model_count=backup_model_count, + min_model_count=min_model_count, + max_shrink_ratio=max_shrink_ratio, + ): + return False + + return True + + @staticmethod + def fetch_remote_model_cost_map(url: str, timeout: int = 5) -> dict: + """ + Fetch the model cost map from a remote URL. + + Returns the parsed JSON dict. Raises on network/parse errors + (caller is expected to handle). + """ + response = httpx.get(url, timeout=timeout) + response.raise_for_status() + return response.json() + def get_model_cost_map(url: str) -> dict: - if ( - os.getenv("LITELLM_LOCAL_MODEL_COST_MAP", False) - or os.getenv("LITELLM_LOCAL_MODEL_COST_MAP", False) == "True" - ): - from importlib.resources import files - import json + """ + Public entry point — returns the model cost map dict. - content = json.loads( - files("litellm") - .joinpath("model_prices_and_context_window_backup.json") - .read_text(encoding="utf-8") - ) - return content + 1. If ``LITELLM_LOCAL_MODEL_COST_MAP`` is set, uses the local backup only. + 2. Otherwise fetches from ``url``, validates integrity, and falls back + to the local backup on any failure. + + Only the backup model count is cached (a single int) for validation. + The full backup dict is only parsed when it must be *returned* as a + fallback — it is never held in memory long-term. + """ + # Note: can't use get_secret_bool here — this runs during litellm.__init__ + # before litellm._key_management_settings is set. + if os.getenv("LITELLM_LOCAL_MODEL_COST_MAP", "").lower() == "true": + return GetModelCostMap.load_local_model_cost_map() try: - response = httpx.get( - url, timeout=5 - ) # set a 5 second timeout for the get request - response.raise_for_status() # Raise an exception if the request is unsuccessful - content = response.json() - return content - except Exception: - from importlib.resources import files - import json - - content = json.loads( - files("litellm") - .joinpath("model_prices_and_context_window_backup.json") - .read_text(encoding="utf-8") + content = GetModelCostMap.fetch_remote_model_cost_map(url) + except Exception as e: + verbose_logger.warning( + "LiteLLM: Failed to fetch remote model cost map from %s: %s. " + "Falling back to local backup.", + url, + str(e), ) - return content + return GetModelCostMap.load_local_model_cost_map() + + # Validate using cached count (cheap int comparison, no file I/O) + if not GetModelCostMap.validate_model_cost_map( + fetched_map=content, + backup_model_count=GetModelCostMap._get_backup_model_count(), + ): + verbose_logger.warning( + "LiteLLM: Fetched model cost map failed integrity check. " + "Using local backup instead. url=%s", + url, + ) + return GetModelCostMap.load_local_model_cost_map() + + return content diff --git a/tests/llm_translation/test_model_cost_map_resilience.py b/tests/llm_translation/test_model_cost_map_resilience.py new file mode 100644 index 00000000000..61e375eabeb --- /dev/null +++ b/tests/llm_translation/test_model_cost_map_resilience.py @@ -0,0 +1,291 @@ +""" +Tests for model cost map resilience. + +Simulates: +- A bad (invalid JSON) model cost map upstream +- A bad (empty/missing) backup model cost map +- Verifies litellm.completion() still works even with a broken cost map +- Verifies litellm.get_model_info() raises the expected error for unmapped models +- Verifies the integrity validation helper catches corrupted maps +""" + +import json +import os +import sys +from unittest.mock import MagicMock, patch + +import pytest + +sys.path.insert( + 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) +) + +import litellm +from litellm.litellm_core_utils.get_model_cost_map import ( + GetModelCostMap, + get_model_cost_map, +) + + +class TestCheckIsValidDict: + """Unit tests for _check_is_valid_dict.""" + + def test_should_reject_non_dict(self): + """Non-dict should fail.""" + assert GetModelCostMap._check_is_valid_dict("not a dict") is False + + def test_should_reject_empty_dict(self): + """Empty dict should fail.""" + assert GetModelCostMap._check_is_valid_dict({}) is False + + def test_should_reject_list(self): + """List should fail.""" + assert GetModelCostMap._check_is_valid_dict([1, 2, 3]) is False + + def test_should_reject_none(self): + """None should fail.""" + assert GetModelCostMap._check_is_valid_dict(None) is False + + def test_should_accept_non_empty_dict(self): + """Non-empty dict should pass.""" + assert GetModelCostMap._check_is_valid_dict({"model": {}}) is True + + +class TestCheckModelCountNotReduced: + """Unit tests for _check_model_count_not_reduced.""" + + def test_should_reject_too_few_models(self): + """Fetched map with fewer models than min_model_count should fail.""" + small_map = {f"model-{i}": {} for i in range(5)} + assert ( + GetModelCostMap._check_model_count_not_reduced( + fetched_map=small_map, backup_model_count=0, min_model_count=10 + ) + is False + ) + + def test_should_reject_significant_shrinkage(self): + """Fetched map that shrunk >50% vs backup should fail.""" + fetched = {f"model-{i}": {} for i in range(40)} # 40% of 100 + assert ( + GetModelCostMap._check_model_count_not_reduced( + fetched_map=fetched, backup_model_count=100, min_model_count=10 + ) + is False + ) + + def test_should_accept_when_above_threshold(self): + """Fetched map at 60% of backup (above 50% threshold) should pass.""" + fetched = {f"model-{i}": {} for i in range(60)} + assert ( + GetModelCostMap._check_model_count_not_reduced( + fetched_map=fetched, backup_model_count=100, min_model_count=10 + ) + is True + ) + + def test_should_accept_growth(self): + """Fetched map larger than backup should pass.""" + fetched = {f"model-{i}": {} for i in range(120)} + assert ( + GetModelCostMap._check_model_count_not_reduced( + fetched_map=fetched, backup_model_count=100, min_model_count=10 + ) + is True + ) + + def test_should_accept_with_empty_backup(self): + """When backup is empty, only min_model_count matters.""" + fetched = {f"model-{i}": {} for i in range(15)} + assert ( + GetModelCostMap._check_model_count_not_reduced( + fetched_map=fetched, backup_model_count=0, min_model_count=10 + ) + is True + ) + + +class TestValidateModelCostMap: + """Unit tests for validate_model_cost_map (combines both checks).""" + + def test_should_reject_non_dict(self): + """Non-dict should fail at check 1.""" + assert GetModelCostMap.validate_model_cost_map(fetched_map="not a dict", backup_model_count=0) is False + + def test_should_reject_empty_map(self): + """Empty dict should fail at check 1.""" + assert GetModelCostMap.validate_model_cost_map(fetched_map={}, backup_model_count=0) is False + + def test_should_reject_significant_shrinkage(self): + """Should fail at check 2 (shrinkage).""" + fetched = {f"model-{i}": {} for i in range(40)} + assert ( + GetModelCostMap.validate_model_cost_map( + fetched_map=fetched, backup_model_count=100, min_model_count=10 + ) + is False + ) + + def test_should_accept_valid_map(self): + """Should pass both checks.""" + fetched = {f"model-{i}": {} for i in range(120)} + assert ( + GetModelCostMap.validate_model_cost_map( + fetched_map=fetched, backup_model_count=100, min_model_count=10 + ) + is True + ) + + def test_should_accept_equal_size_map(self): + """Equal size should pass both checks.""" + fetched = {f"model-{i}": {} for i in range(100)} + assert ( + GetModelCostMap.validate_model_cost_map( + fetched_map=fetched, backup_model_count=100, min_model_count=10 + ) + is True + ) + + +class TestGetModelCostMapFallback: + """Tests for get_model_cost_map fallback behavior with bad upstream.""" + + def test_should_fallback_to_backup_on_invalid_json(self): + """When upstream returns invalid JSON, should fall back to local backup.""" + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_response.json.side_effect = json.JSONDecodeError("bad json", "", 0) + + with patch("httpx.get", return_value=mock_response): + result = get_model_cost_map("https://fake-url.com/model_prices.json") + + # Should have fallen back to backup — backup always has models + assert isinstance(result, dict) + assert len(result) > 0 + + def test_should_fallback_to_backup_on_network_error(self): + """When upstream is unreachable, should fall back to local backup.""" + with patch("httpx.get", side_effect=Exception("Connection refused")): + result = get_model_cost_map("https://fake-url.com/model_prices.json") + + assert isinstance(result, dict) + assert len(result) > 0 + + def test_should_fallback_when_fetched_map_is_empty(self): + """When upstream returns valid JSON but empty dict, should fall back.""" + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_response.json.return_value = {} # empty map + + with patch("httpx.get", return_value=mock_response): + result = get_model_cost_map("https://fake-url.com/model_prices.json") + + # Should have fallen back to backup since empty map fails validation + assert isinstance(result, dict) + assert len(result) > 0 + + def test_should_fallback_when_fetched_map_shrinks_dramatically(self): + """When upstream returns far fewer models than backup, should fall back.""" + tiny_map = {f"model-{i}": {"litellm_provider": "test"} for i in range(11)} + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_response.json.return_value = tiny_map + + with patch("httpx.get", return_value=mock_response): + result = get_model_cost_map("https://fake-url.com/model_prices.json") + + # Backup has thousands of models; 11 is a massive shrinkage → fallback + assert len(result) > 11 + + def test_should_use_local_map_when_env_var_set(self): + """LITELLM_LOCAL_MODEL_COST_MAP=True should skip remote fetch entirely.""" + with patch.dict(os.environ, {"LITELLM_LOCAL_MODEL_COST_MAP": "True"}): + with patch("httpx.get") as mock_get: + result = get_model_cost_map( + "https://fake-url.com/model_prices.json" + ) + mock_get.assert_not_called() + + assert isinstance(result, dict) + assert len(result) > 0 + + +class TestBackupModelCostMapExists: + """Validates the local backup file is always present and valid.""" + + def test_should_have_backup_file(self): + """The backup model cost map must exist and be loadable.""" + backup = GetModelCostMap.load_local_model_cost_map() + assert isinstance(backup, dict) + assert len(backup) > 0, "Backup model cost map is empty" + + def test_should_have_minimum_models_in_backup(self): + """The backup must contain a reasonable number of models.""" + backup = GetModelCostMap.load_local_model_cost_map() + assert len(backup) > 100, ( + f"Backup has only {len(backup)} models, expected > 100" + ) + + +class TestBadHostedModelCostMap: + """ + Simulates the hosted model cost map being bad (invalid JSON / corrupted). + + When the hosted map is bad, get_model_cost_map() falls back to the local + backup. These tests verify that after fallback: + - get_model_info() still works for models in the backup + - litellm.completion() still works + """ + + def test_should_model_info_pass_after_bad_hosted_map(self): + """ + If the hosted map is bad, get_model_cost_map falls back to the local + backup. get_model_info should still work for models in the backup. + """ + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_response.json.side_effect = json.JSONDecodeError("bad json", "", 0) + + with patch("httpx.get", return_value=mock_response): + fallback_map = get_model_cost_map("https://fake-url.com/bad.json") + + original = litellm.model_cost + litellm.model_cost = fallback_map + try: + # gpt-4o is in every backup — should work fine + info = litellm.get_model_info("gpt-4o") + assert info is not None + assert info["input_cost_per_token"] > 0 + finally: + litellm.model_cost = original + + def test_should_completion_pass_after_bad_hosted_map(self): + """ + If the hosted map is bad, litellm.completion() should still work. + + Uses litellm's built-in mock_response param so the real completion + path is exercised (routing, cost calculator, logging) without + needing API credentials. + """ + # Simulate bad hosted map → fallback to backup + mock_http = MagicMock() + mock_http.raise_for_status = MagicMock() + mock_http.json.side_effect = json.JSONDecodeError("bad json", "", 0) + + with patch("httpx.get", return_value=mock_http): + fallback_map = get_model_cost_map("https://fake-url.com/bad.json") + + original = litellm.model_cost + litellm.model_cost = fallback_map + try: + # mock_response goes through the real completion path — + # routing, cost calculator, logging — but skips the HTTP call + response = litellm.completion( + model="gpt-4o-mini", + messages=[{"role": "user", "content": "say hi"}], + mock_response="hello from mock", + ) + assert response is not None + assert response.choices[0].message.content == "hello from mock" + finally: + litellm.model_cost = original From f311fba194fc42eeb9a7aa2738853d3232ec7a46 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Tue, 10 Feb 2026 15:24:46 -0800 Subject: [PATCH 10/11] fix --- docs/my-website/blog/model_cost_map_incident/index.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/my-website/blog/model_cost_map_incident/index.md b/docs/my-website/blog/model_cost_map_incident/index.md index 7f1324e5fab..b9ff20e4128 100644 --- a/docs/my-website/blog/model_cost_map_incident/index.md +++ b/docs/my-website/blog/model_cost_map_incident/index.md @@ -74,11 +74,11 @@ A contributor PR introduced an extra `{` bracket, producing invalid JSON. The re | # | Action | Status | Code | |---|---|---|---| -| 1 | CI validation on `model_prices_and_context_window.json` | ✅ Done | [PR #20605](https://github.com/BerriAI/litellm/pull/20605) | -| 2 | Warning log on fallback to local backup | ✅ Done | [`get_model_cost_map.py`](https://github.com/BerriAI/litellm/blob/main/litellm/litellm_core_utils/get_model_cost_map.py) | -| 3 | `GetModelCostMap` class with integrity validation helpers | ✅ Done | [`get_model_cost_map.py`](https://github.com/BerriAI/litellm/blob/main/litellm/litellm_core_utils/get_model_cost_map.py) | -| 4 | Resilience test suite (bad hosted map, bad backup, fallback, completion) | ✅ Done | [`test_model_cost_map_resilience.py`](https://github.com/BerriAI/litellm/blob/main/tests/llm_translation/test_model_cost_map_resilience.py) | -| 5 | Test that backup model cost map always exists and contains common models | ✅ Done | [`test_model_cost_map_resilience.py`](https://github.com/BerriAI/litellm/blob/main/tests/llm_translation/test_model_cost_map_resilience.py) | +| 1 | CI validation on `model_prices_and_context_window.json` | ✅ Done | [`test-model-map.yaml`](https://github.com/BerriAI/litellm/blob/main/.github/workflows/test-model-map.yaml) | +| 2 | Warning log on fallback to local backup | ✅ Done | [`get_model_cost_map.py#L57-L68`](https://github.com/BerriAI/litellm/blob/main/litellm/litellm_core_utils/get_model_cost_map.py#L57-L68) | +| 3 | `GetModelCostMap` class with integrity validation helpers | ✅ Done | [`get_model_cost_map.py#L24-L149`](https://github.com/BerriAI/litellm/blob/main/litellm/litellm_core_utils/get_model_cost_map.py#L24-L149) | +| 4 | Resilience test suite (bad hosted map, fallback, completion) | ✅ Done | [`test_model_cost_map_resilience.py#L150-L291`](https://github.com/BerriAI/litellm/blob/main/tests/llm_translation/test_model_cost_map_resilience.py#L150-L291) | +| 5 | Test that backup model cost map always exists and contains common models | ✅ Done | [`test_model_cost_map_resilience.py#L213-L228`](https://github.com/BerriAI/litellm/blob/main/tests/llm_translation/test_model_cost_map_resilience.py#L213-L228) | Enterprises that require zero external dependencies at import time can set `LITELLM_LOCAL_MODEL_COST_MAP=True` to skip the GitHub fetch entirely. From 8507df483c523c837e69b993ed97e6c87f087675 Mon Sep 17 00:00:00 2001 From: michelligabriele Date: Wed, 11 Feb 2026 00:52:52 +0100 Subject: [PATCH 11/11] fix(router): propagate model-level tags from config to SpendLogs (#20769) --- litellm/router.py | 11 ++++ tests/test_litellm/test_router.py | 91 +++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/litellm/router.py b/litellm/router.py index 42058c79c17..d9de7e7fc5a 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -1924,6 +1924,17 @@ class Router: "deployment_model_name": deployment_model_name, } ) + + ## DEPLOYMENT-LEVEL TAGS + deployment_tags = deployment.get("litellm_params", {}).get("tags") + if deployment_tags: + existing_tags = kwargs[metadata_variable_name].get("tags") or [] + merged_tags = list(existing_tags) + for tag in deployment_tags: + if tag not in merged_tags: + merged_tags.append(tag) + kwargs[metadata_variable_name]["tags"] = merged_tags + kwargs["model_info"] = model_info kwargs["timeout"] = self._get_timeout( diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index 75ec806ee17..9dcb16b545e 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -1990,3 +1990,94 @@ async def test_anthropic_messages_call_type_is_cached(): # This assertion will FAIL if anthropic_messages is filtered out assert cached_result is not None, "Model ID should be cached for anthropic_messages call type" assert cached_result["model_id"] == test_model_id, f"Expected {test_model_id}, got {cached_result['model_id']}" + + +def test_update_kwargs_with_deployment_propagates_model_tags(): + """ + Test that deployment-level tags from litellm_params are merged into + kwargs metadata when _update_kwargs_with_deployment is called. + + This ensures model-level tags defined in config.yaml appear in SpendLogs. + See: https://github.com/BerriAI/litellm/issues/XXXX + """ + router = litellm.Router( + model_list=[ + { + "model_name": "gpt-4o-mini", + "litellm_params": { + "model": "openai/gpt-4o-mini", + "api_key": "fake-key", + "tags": ["openai-account", "production"], + }, + }, + ], + ) + + kwargs: dict = {"metadata": {}} + deployment = router.get_deployment_by_model_group_name( + model_group_name="gpt-4o-mini" + ) + router._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs) + + # Deployment tags should be propagated to kwargs metadata + assert "tags" in kwargs["metadata"] + assert "openai-account" in kwargs["metadata"]["tags"] + assert "production" in kwargs["metadata"]["tags"] + + +def test_update_kwargs_with_deployment_merges_tags_without_duplicates(): + """ + Test that when both request-level and deployment-level tags exist, + they are merged without duplicates. + """ + router = litellm.Router( + model_list=[ + { + "model_name": "gpt-4o-mini", + "litellm_params": { + "model": "openai/gpt-4o-mini", + "api_key": "fake-key", + "tags": ["openai-account", "shared-tag"], + }, + }, + ], + ) + + # Simulate request that already has tags (from request body or key/team level) + kwargs: dict = {"metadata": {"tags": ["user-tag", "shared-tag"]}} + deployment = router.get_deployment_by_model_group_name( + model_group_name="gpt-4o-mini" + ) + router._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs) + + # Both sources should be merged, no duplicates + assert "user-tag" in kwargs["metadata"]["tags"] + assert "openai-account" in kwargs["metadata"]["tags"] + assert "shared-tag" in kwargs["metadata"]["tags"] + assert kwargs["metadata"]["tags"].count("shared-tag") == 1 + + +def test_update_kwargs_with_deployment_no_tags(): + """ + Test that when deployment has no tags, kwargs metadata is not affected. + """ + router = litellm.Router( + model_list=[ + { + "model_name": "gpt-4o-mini", + "litellm_params": { + "model": "openai/gpt-4o-mini", + "api_key": "fake-key", + }, + }, + ], + ) + + kwargs: dict = {"metadata": {}} + deployment = router.get_deployment_by_model_group_name( + model_group_name="gpt-4o-mini" + ) + router._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs) + + # No tags key should be added if deployment has no tags + assert "tags" not in kwargs["metadata"]