From 1fefd80925d9be821c10e6968b63caddbfb80200 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:01:37 -0700 Subject: [PATCH 01/11] fix(proxy): resolve pass-through credentials live from router deployments --- basedpyright-code-budget.json | 2 +- .../passthrough_endpoint_router.py | 137 +++++++++----- litellm/proxy/proxy_server.py | 26 +-- litellm/router.py | 75 +++----- litellm/types/router.py | 3 + ruff-strict-budget.json | 2 +- .../test_unit_test_passthrough_router.py | 72 +++++--- .../test_router_adding_deployments.py | 3 - .../test_passthrough_endpoint_router.py | 174 ++++++++++++++++++ tests/test_litellm/test_router.py | 10 +- type-discipline-budget.json | 4 +- ui/litellm-dashboard/src/lib/http/schema.d.ts | 8 + 12 files changed, 375 insertions(+), 141 deletions(-) create mode 100644 tests/test_litellm/proxy/pass_through_endpoints/test_passthrough_endpoint_router.py diff --git a/basedpyright-code-budget.json b/basedpyright-code-budget.json index 614a8e5d2c0..1d9192587bd 100644 --- a/basedpyright-code-budget.json +++ b/basedpyright-code-budget.json @@ -99,7 +99,7 @@ "limit": 0 }, "reportUnknownArgumentType": { - "limit": 45262 + "limit": 45260 }, "reportUnknownLambdaType": { "limit": 113 diff --git a/litellm/proxy/pass_through_endpoints/passthrough_endpoint_router.py b/litellm/proxy/pass_through_endpoints/passthrough_endpoint_router.py index 60b80120f6b..65729adb3e5 100644 --- a/litellm/proxy/pass_through_endpoints/passthrough_endpoint_router.py +++ b/litellm/proxy/pass_through_endpoints/passthrough_endpoint_router.py @@ -1,68 +1,124 @@ -from typing import Final +from collections.abc import Callable +from typing import TYPE_CHECKING, Final import litellm from litellm._logging import verbose_router_logger from litellm.integrations.vector_store_integrations.vector_store_pre_call_hook import ( LiteLLM_ManagedVectorStore, ) +from litellm.litellm_core_utils.credential_accessor import CredentialAccessor from litellm.secret_managers.main import get_secret_str from litellm.types.llms.vertex_ai import VERTEX_CREDENTIALS_TYPES from litellm.types.passthrough_endpoints.vertex_ai import VertexPassThroughCredentials +from litellm.types.router import LiteLLMParamsTypedDict + +if TYPE_CHECKING: + from litellm.router import Router + + +def _get_proxy_llm_router() -> "Router | None": + from litellm.proxy.proxy_server import llm_router + + return llm_router + + +def _get_str_value(values: dict | None, key: str) -> str | None: + value: Final = values.get(key) if values is not None else None + return value if isinstance(value, str) else None class PassthroughEndpointRouter: """ - Use this class to Set/Get credentials for pass-through endpoints + Use this class to Get credentials for pass-through endpoints """ - def __init__(self): - self.credentials: dict[str, str] = {} + def __init__( + self, + llm_router_getter: "Callable[[], Router | None]" = _get_proxy_llm_router, + ): + self.llm_router_getter: Final = llm_router_getter self.deployment_key_to_vertex_credentials: dict[str, VertexPassThroughCredentials] = {} self.default_vertex_config: VertexPassThroughCredentials | None = None - def set_pass_through_credentials( - self, - custom_llm_provider: str, - api_base: str | None, - api_key: str | None, - ): - """ - Set credentials for a pass-through endpoint. Used when a user adds a pass-through LLM endpoint on the UI. - - Args: - custom_llm_provider: The provider of the pass-through endpoint - api_base: The base URL of the pass-through endpoint - api_key: The API key for the pass-through endpoint - """ - credential_name: Final = self._get_credential_name_for_provider( - custom_llm_provider=custom_llm_provider, - region_name=self._get_region_name_from_api_base(api_base=api_base, custom_llm_provider=custom_llm_provider), - ) - if api_key is None: - raise ValueError("api_key is required for setting pass-through credentials") - self.credentials[credential_name] = api_key - def get_credentials( self, custom_llm_provider: str, region_name: str | None, ) -> str | None: - credential_name: Final = self._get_credential_name_for_provider( + deployment_api_key: Final = self._get_deployment_api_key( custom_llm_provider=custom_llm_provider, region_name=region_name, ) + if deployment_api_key is not None: + return deployment_api_key verbose_router_logger.debug( - "Pass-through llm endpoints router, looking for credentials for %s", credential_name + "No pass-through deployment credentials found for %s, looking for env variable", custom_llm_provider ) - if credential_name in self.credentials: - verbose_router_logger.debug("Found credentials for %s", credential_name) - return self.credentials[credential_name] - else: - verbose_router_logger.debug("No credentials found for %s, looking for env variable", credential_name) - _env_variable_name: Final = self._get_default_env_variable_name_passthrough_endpoint( - custom_llm_provider=custom_llm_provider, + _env_variable_name: Final = self._get_default_env_variable_name_passthrough_endpoint( + custom_llm_provider=custom_llm_provider, + ) + return get_secret_str(_env_variable_name) + + def _get_deployment_api_key( + self, + custom_llm_provider: str, + region_name: str | None, + ) -> str | None: + llm_router: Final = self.llm_router_getter() + if llm_router is None: + return None + deployments: Final = llm_router.get_model_list() or () + return next( + ( + api_key + for deployment in deployments + if ( + api_key := self._resolve_matching_deployment_api_key( + litellm_params=deployment["litellm_params"], + custom_llm_provider=custom_llm_provider, + region_name=region_name, + ) + ) + is not None + ), + None, + ) + + def _resolve_matching_deployment_api_key( + self, + litellm_params: LiteLLMParamsTypedDict, + custom_llm_provider: str, + region_name: str | None, + ) -> str | None: + if litellm_params.get("use_in_pass_through") is not True: + return None + if self._get_deployment_provider(litellm_params) != custom_llm_provider: + return None + credential_name: Final = litellm_params.get("litellm_credential_name") + credential_values: Final = ( + CredentialAccessor.get_credential_values(credential_name) if credential_name is not None else None + ) + api_base: Final = _get_str_value(credential_values, "api_base") or litellm_params.get("api_base") + deployment_region: Final = self._get_region_name_from_api_base( + custom_llm_provider=custom_llm_provider, + api_base=api_base, + ) + if deployment_region != region_name: + return None + return _get_str_value(credential_values, "api_key") or litellm_params.get("api_key") + + def _get_deployment_provider(self, litellm_params: LiteLLMParamsTypedDict) -> str | None: + model: Final = litellm_params.get("model") + if model is None: + return None + try: + _, provider, _, _ = litellm.get_llm_provider( + model=model, + custom_llm_provider=litellm_params.get("custom_llm_provider"), ) - return get_secret_str(_env_variable_name) + except Exception: + return None + return provider def _get_vertex_env_vars(self) -> VertexPassThroughCredentials: """ @@ -165,15 +221,6 @@ class PassthroughEndpointRouter: else: return self.default_vertex_config - def _get_credential_name_for_provider( - self, - custom_llm_provider: str, - region_name: str | None, - ) -> str: - if region_name is None: - return f"{custom_llm_provider.upper()}_API_KEY" - return f"{custom_llm_provider.upper()}_{region_name.upper()}_API_KEY" - def _get_region_name_from_api_base( self, custom_llm_provider: str, diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index e343d46f872..a158643a3c9 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -8251,6 +8251,19 @@ class ProxyStartupEvent: ) if store_model_in_db is True: + ### GET STORED CREDENTIALS ### + scheduler.add_job( + proxy_config.get_credentials, + "interval", + seconds=config_reload_interval_seconds, + # REMOVED jitter parameter - major cause of memory leak + args=[prisma_client], + id="get_credentials_job", + replace_existing=True, + misfire_grace_time=APSCHEDULER_MISFIRE_GRACE_TIME, + ) + await proxy_config.get_credentials(prisma_client=prisma_client) + # MEMORY LEAK FIX: Increase interval from 10s to 30s minimum # Frequent polling was causing excessive memory allocations scheduler.add_job( @@ -8267,19 +8280,6 @@ class ProxyStartupEvent: # this will load all existing models on proxy startup await proxy_config.add_deployment(prisma_client=prisma_client, proxy_logging_obj=proxy_logging_obj) - ### GET STORED CREDENTIALS ### - scheduler.add_job( - proxy_config.get_credentials, - "interval", - seconds=config_reload_interval_seconds, - # REMOVED jitter parameter - major cause of memory leak - args=[prisma_client], - id="get_credentials_job", - replace_existing=True, - misfire_grace_time=APSCHEDULER_MISFIRE_GRACE_TIME, - ) - await proxy_config.get_credentials(prisma_client=prisma_client) - proxy_config.start_config_sync_subscriber( prisma_client=prisma_client, proxy_logging_obj=proxy_logging_obj, diff --git a/litellm/router.py b/litellm/router.py index 9cde292657c..cbb03c571b3 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -8140,7 +8140,6 @@ class Router: self._initialize_deployment_for_pass_through( deployment=deployment, custom_llm_provider=custom_llm_provider, - model=deployment.litellm_params.model, ) ######################################################### @@ -8167,55 +8166,39 @@ class Router: return deployment - def _initialize_deployment_for_pass_through(self, deployment: Deployment, custom_llm_provider: str, model: str): + def _initialize_deployment_for_pass_through(self, deployment: Deployment, custom_llm_provider: str): """ - Optional: Initialize deployment for pass-through endpoints if `deployment.litellm_params.use_in_pass_through` is True + Optional: Register vertex credentials for pass-through endpoints if `deployment.litellm_params.use_in_pass_through` is True - Each provider uses diff .env vars for pass-through endpoints, this helper uses the deployment credentials to set the .env vars for pass-through endpoints + Other providers need no registration here: PassthroughEndpointRouter.get_credentials resolves their credentials per-request from the live router deployments """ - if deployment.litellm_params.use_in_pass_through is True: - from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( - passthrough_endpoint_router, + if deployment.litellm_params.use_in_pass_through is not True: + return + if custom_llm_provider != "vertex_ai": + return + from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( + passthrough_endpoint_router, + ) + + credential_name: Final = deployment.litellm_params.litellm_credential_name + credential_values: Final = ( + CredentialAccessor.get_credential_values(credential_name) if credential_name is not None else {} + ) + vertex_project: Final = credential_values.get("vertex_project") or deployment.litellm_params.vertex_project + vertex_location: Final = credential_values.get("vertex_location") or deployment.litellm_params.vertex_location + vertex_credentials: Final = ( + credential_values.get("vertex_credentials") or deployment.litellm_params.vertex_credentials + ) + + if vertex_project is None or vertex_location is None: + raise ValueError( + "vertex_project, and vertex_location must be set in litellm_params for pass-through endpoints." ) - - if deployment.litellm_params.litellm_credential_name is not None: - credential_values = CredentialAccessor.get_credential_values( - deployment.litellm_params.litellm_credential_name - ) - else: - credential_values = {} - - if custom_llm_provider == "vertex_ai": - vertex_project = credential_values.get("vertex_project") or deployment.litellm_params.vertex_project - vertex_location = credential_values.get("vertex_location") or deployment.litellm_params.vertex_location - vertex_credentials: Final = ( - credential_values.get("vertex_credentials") or deployment.litellm_params.vertex_credentials - ) - - if vertex_project is None or vertex_location is None: - raise ValueError( - "vertex_project, and vertex_location must be set in litellm_params for pass-through endpoints." - ) - passthrough_endpoint_router.add_vertex_credentials( - project_id=vertex_project, - location=vertex_location, - vertex_credentials=vertex_credentials, - ) - else: - api_base: Final = credential_values.get("api_base") or deployment.litellm_params.api_base - api_key: Final = credential_values.get("api_key") or deployment.litellm_params.api_key - if api_key is None: - verbose_router_logger.debug( - "Skipping pass-through credential setup for deployment model=%s, custom_llm_provider=%s; no api_key set. Providers like bedrock resolve credentials at request time.", - model, - custom_llm_provider, - ) - return - passthrough_endpoint_router.set_pass_through_credentials( - custom_llm_provider=custom_llm_provider, - api_base=api_base, - api_key=api_key, - ) + passthrough_endpoint_router.add_vertex_credentials( + project_id=vertex_project, + location=vertex_location, + vertex_credentials=vertex_credentials, + ) def add_deployment(self, deployment: Deployment) -> Deployment | None: """ diff --git a/litellm/types/router.py b/litellm/types/router.py index 21bed84a3a1..ddb575545ea 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -381,6 +381,9 @@ class LiteLLMParamsTypedDict(TypedDict, total=False): drop_params: Optional[bool] ## RESPONSES API → CHAT COMPLETIONS BRIDGE ## use_chat_completions_api: Optional[bool] + ## PASS-THROUGH ENDPOINTS ## + use_in_pass_through: Optional[bool] + litellm_credential_name: Optional[str] ## UNIFIED PROJECT/REGION ## region_name: Optional[str] ## VERTEX AI ## diff --git a/ruff-strict-budget.json b/ruff-strict-budget.json index 5d41835b9dc..b7b96a34c77 100644 --- a/ruff-strict-budget.json +++ b/ruff-strict-budget.json @@ -9,7 +9,7 @@ "limit": 831 }, "ANN201": { - "limit": 2137 + "limit": 2136 }, "ANN202": { "limit": 941 diff --git a/tests/pass_through_unit_tests/test_unit_test_passthrough_router.py b/tests/pass_through_unit_tests/test_unit_test_passthrough_router.py index 8e016b68d05..b133cc2d862 100644 --- a/tests/pass_through_unit_tests/test_unit_test_passthrough_router.py +++ b/tests/pass_through_unit_tests/test_unit_test_passthrough_router.py @@ -31,38 +31,60 @@ passthrough_endpoint_router = PassthroughEndpointRouter() class TestPassthroughEndpointRouter(unittest.TestCase): def setUp(self): - self.router = PassthroughEndpointRouter() + self.router = PassthroughEndpointRouter(llm_router_getter=lambda: None) - def test_set_and_get_credentials(self): + def test_deployment_and_get_credentials(self): """ 1. Basic Usage: - - Set credentials for OpenAI, AssemblyAI, Anthropic, Cohere - - GET credentials from passthrough_endpoint_router (from the memory store when available) + - Flag deployments for OpenAI, AssemblyAI, Anthropic, Cohere with use_in_pass_through + - GET credentials from passthrough_endpoint_router (resolved live from the llm router) """ + import litellm - # OpenAI: standard (no region-specific logic) - self.router.set_pass_through_credentials("openai", None, "openai_key") - self.assertEqual(self.router.get_credentials("openai", None), "openai_key") - - # AssemblyAI: using an API base that contains 'eu' should trigger regional logic. - api_base_eu = "https://api.eu.assemblyai.com" - self.router.set_pass_through_credentials( - "assemblyai", api_base_eu, "assemblyai_key" - ) - # When calling get_credentials, pass the region "eu" (extracted from the API base) - self.assertEqual( - self.router.get_credentials("assemblyai", "eu"), "assemblyai_key" + llm_router = litellm.Router( + model_list=[ + { + "model_name": "gpt-4o", + "litellm_params": { + "model": "openai/gpt-4o", + "api_key": "openai_key", + "use_in_pass_through": True, + }, + }, + { + "model_name": "best", + "litellm_params": { + "model": "assemblyai/best", + "api_key": "assemblyai_key", + "api_base": "https://api.eu.assemblyai.com", + "use_in_pass_through": True, + }, + }, + { + "model_name": "claude-sonnet-4-5", + "litellm_params": { + "model": "anthropic/claude-sonnet-4-5", + "api_key": "anthropic_key", + "use_in_pass_through": True, + }, + }, + { + "model_name": "embed-english-v3.0", + "litellm_params": { + "model": "cohere/embed-english-v3.0", + "api_key": "cohere_key", + "use_in_pass_through": True, + }, + }, + ] ) + router = PassthroughEndpointRouter(llm_router_getter=lambda: llm_router) - # Anthropic: no region set - self.router.set_pass_through_credentials("anthropic", None, "anthropic_key") - self.assertEqual( - self.router.get_credentials("anthropic", None), "anthropic_key" - ) - - # Cohere: no region set - self.router.set_pass_through_credentials("cohere", None, "cohere_key") - self.assertEqual(self.router.get_credentials("cohere", None), "cohere_key") + self.assertEqual(router.get_credentials("openai", None), "openai_key") + # AssemblyAI: an API base that contains 'eu' triggers regional matching + self.assertEqual(router.get_credentials("assemblyai", "eu"), "assemblyai_key") + self.assertEqual(router.get_credentials("anthropic", None), "anthropic_key") + self.assertEqual(router.get_credentials("cohere", None), "cohere_key") def test_get_credentials_from_env(self): """ diff --git a/tests/router_unit_tests/test_router_adding_deployments.py b/tests/router_unit_tests/test_router_adding_deployments.py index 06bb2226bc5..6200cc6ebcc 100644 --- a/tests/router_unit_tests/test_router_adding_deployments.py +++ b/tests/router_unit_tests/test_router_adding_deployments.py @@ -60,7 +60,6 @@ def test_initialize_deployment_for_pass_through_success(reusable_credentials): router._initialize_deployment_for_pass_through( deployment=deployment, custom_llm_provider="vertex_ai", - model="vertex_ai/test-model", ) # Verify the credentials were properly set @@ -100,7 +99,6 @@ def test_initialize_deployment_for_pass_through_missing_params(): router._initialize_deployment_for_pass_through( deployment=deployment, custom_llm_provider="vertex_ai", - model="vertex_ai/test-model", ) @@ -120,7 +118,6 @@ def test_initialize_deployment_when_pass_through_disabled(): router._initialize_deployment_for_pass_through( deployment=deployment, custom_llm_provider="vertex_ai", - model="vertex_ai/test-model", ) # If we reach this point, the test passes as the method exited without raising any errors diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_passthrough_endpoint_router.py b/tests/test_litellm/proxy/pass_through_endpoints/test_passthrough_endpoint_router.py new file mode 100644 index 00000000000..d7266ecd9ed --- /dev/null +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_passthrough_endpoint_router.py @@ -0,0 +1,174 @@ +import pytest + +import litellm +from litellm.litellm_core_utils.credential_accessor import CredentialAccessor +from litellm.proxy.pass_through_endpoints.passthrough_endpoint_router import ( + PassthroughEndpointRouter, +) +from litellm.types.utils import CredentialItem + + +@pytest.fixture(autouse=True) +def isolated_credential_list(monkeypatch): + monkeypatch.setattr(litellm, "credential_list", []) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("ASSEMBLYAI_API_KEY", raising=False) + + +def _credential(name: str, api_key: str) -> CredentialItem: + return CredentialItem( + credential_name=name, + credential_values={"api_key": api_key}, + credential_info={}, + ) + + +def _flagged_deployment(model: str, **litellm_params) -> dict: + return { + "model_name": model.split("/", 1)[-1], + "litellm_params": {"model": model, "use_in_pass_through": True, **litellm_params}, + } + + +def _passthrough_router(llm_router: litellm.Router | None) -> PassthroughEndpointRouter: + return PassthroughEndpointRouter(llm_router_getter=lambda: llm_router) + + +def test_credential_loaded_after_deployment_registration_still_resolves(): + llm_router = litellm.Router( + model_list=[_flagged_deployment("openai/gpt-4o", litellm_credential_name="cred_openai")] + ) + passthrough_router = _passthrough_router(llm_router) + + assert passthrough_router.get_credentials(custom_llm_provider="openai", region_name=None) is None + + CredentialAccessor.upsert_credentials([_credential("cred_openai", "sk-loaded-after-boot")]) + + assert ( + passthrough_router.get_credentials(custom_llm_provider="openai", region_name=None) + == "sk-loaded-after-boot" + ) + + +def test_credential_rotation_is_reflected_without_deployment_update(): + CredentialAccessor.upsert_credentials([_credential("cred_openai", "sk-before-rotation")]) + llm_router = litellm.Router( + model_list=[_flagged_deployment("openai/gpt-4o", litellm_credential_name="cred_openai")] + ) + passthrough_router = _passthrough_router(llm_router) + + assert ( + passthrough_router.get_credentials(custom_llm_provider="openai", region_name=None) + == "sk-before-rotation" + ) + + CredentialAccessor.upsert_credentials([_credential("cred_openai", "sk-after-rotation")]) + + assert ( + passthrough_router.get_credentials(custom_llm_provider="openai", region_name=None) + == "sk-after-rotation" + ) + + +def test_deleted_deployment_stops_serving_its_key(monkeypatch): + llm_router = litellm.Router(model_list=[_flagged_deployment("openai/gpt-4o", api_key="sk-inline")]) + passthrough_router = _passthrough_router(llm_router) + + assert passthrough_router.get_credentials(custom_llm_provider="openai", region_name=None) == "sk-inline" + + llm_router.set_model_list([]) + monkeypatch.setenv("OPENAI_API_KEY", "sk-from-env") + + assert ( + passthrough_router.get_credentials(custom_llm_provider="openai", region_name=None) == "sk-from-env" + ) + + +def test_inline_api_key_resolves_without_credential_name(): + llm_router = litellm.Router( + model_list=[_flagged_deployment("anthropic/claude-sonnet-4-5", api_key="sk-ant-inline")] + ) + passthrough_router = _passthrough_router(llm_router) + + assert ( + passthrough_router.get_credentials(custom_llm_provider="anthropic", region_name=None) + == "sk-ant-inline" + ) + + +def test_missing_credential_and_no_inline_key_falls_back_to_env(monkeypatch): + llm_router = litellm.Router( + model_list=[_flagged_deployment("openai/gpt-4o", litellm_credential_name="cred_deleted")] + ) + passthrough_router = _passthrough_router(llm_router) + monkeypatch.setenv("OPENAI_API_KEY", "sk-from-env") + + assert ( + passthrough_router.get_credentials(custom_llm_provider="openai", region_name=None) == "sk-from-env" + ) + + +def test_deployment_for_other_provider_does_not_match(): + llm_router = litellm.Router( + model_list=[_flagged_deployment("anthropic/claude-sonnet-4-5", api_key="sk-ant-inline")] + ) + passthrough_router = _passthrough_router(llm_router) + + assert passthrough_router.get_credentials(custom_llm_provider="openai", region_name=None) is None + + +def test_unflagged_deployment_does_not_match(): + llm_router = litellm.Router( + model_list=[ + { + "model_name": "gpt-4o", + "litellm_params": {"model": "openai/gpt-4o", "api_key": "sk-not-flagged"}, + } + ] + ) + passthrough_router = _passthrough_router(llm_router) + + assert passthrough_router.get_credentials(custom_llm_provider="openai", region_name=None) is None + + +def test_first_matching_deployment_wins(): + llm_router = litellm.Router( + model_list=[ + _flagged_deployment("openai/gpt-4o", api_key="sk-first"), + _flagged_deployment("openai/gpt-4o-mini", api_key="sk-second"), + ] + ) + passthrough_router = _passthrough_router(llm_router) + + assert passthrough_router.get_credentials(custom_llm_provider="openai", region_name=None) == "sk-first" + + +def test_assemblyai_region_matching(): + llm_router = litellm.Router( + model_list=[ + _flagged_deployment( + "assemblyai/best", api_key="sk-eu", api_base="https://api.eu.assemblyai.com" + ), + _flagged_deployment("assemblyai/best", api_key="sk-us", api_base="https://api.assemblyai.com"), + ] + ) + passthrough_router = _passthrough_router(llm_router) + + assert passthrough_router.get_credentials(custom_llm_provider="assemblyai", region_name="eu") == "sk-eu" + assert passthrough_router.get_credentials(custom_llm_provider="assemblyai", region_name=None) == "sk-us" + + +def test_env_fallback_when_no_router(monkeypatch): + passthrough_router = _passthrough_router(None) + monkeypatch.setenv("OPENAI_API_KEY", "sk-from-env") + + assert ( + passthrough_router.get_credentials(custom_llm_provider="openai", region_name=None) == "sk-from-env" + ) + + +def test_returns_none_when_no_router_and_no_env(): + passthrough_router = _passthrough_router(None) + + assert passthrough_router.get_credentials(custom_llm_provider="openai", region_name=None) is None diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index 33aab1cf708..fc7df5e9545 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -6021,16 +6021,16 @@ def test_initialize_deployment_for_pass_through_keeps_bedrock_iam_deployment(): ] -def test_initialize_deployment_for_pass_through_sets_credentials_with_api_key(): - from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( - passthrough_endpoint_router, +def test_pass_through_deployment_api_key_resolves_via_get_credentials(): + from litellm.proxy.pass_through_endpoints.passthrough_endpoint_router import ( + PassthroughEndpointRouter, ) - passthrough_endpoint_router.credentials.clear() router = _router_with_two_pass_through_deployments([False, False]) + passthrough_router = PassthroughEndpointRouter(llm_router_getter=lambda: router) assert len(router.get_model_list()) == 2 assert ( - passthrough_endpoint_router.get_credentials( + passthrough_router.get_credentials( custom_llm_provider="openai", region_name=None ) == "sk-fake-for-tests" diff --git a/type-discipline-budget.json b/type-discipline-budget.json index 582c0d662e6..b13c9bbf564 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -3,7 +3,7 @@ "limit": 23348 }, "LIT002": { - "limit": 27227 + "limit": 27226 }, "LIT003": { "limit": 292 @@ -27,7 +27,7 @@ "limit": 2460 }, "LIT010": { - "limit": 25327 + "limit": 25323 }, "LIT011": { "limit": 8406 diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 864ef80d39d..ee73151c734 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -31329,6 +31329,14 @@ export interface components { * @description Keywords indicating reasoning-required content */ reasoning_keywords?: string[] | null; + /** + * Reminder Markers + * @description Override the (open, close) marker pair used to recognize and strip harness-injected reminder blocks before classification. Defaults to Claude Code's convention, ('', ''), when unset. Matching is case-insensitive. + */ + reminder_markers?: [ + string, + string + ] | null; /** * Return Raw Model Name * @description Return the resolved raw model name in the response model field instead of the client-requested complexity-router alias From 5d7bfbc9509e6b347e7c6db007f99c11fe753cf6 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:12:31 -0700 Subject: [PATCH 02/11] refactor(proxy): tighten pass-through credential type annotations --- .../pass_through_endpoints/passthrough_endpoint_router.py | 2 +- litellm/types/router.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/passthrough_endpoint_router.py b/litellm/proxy/pass_through_endpoints/passthrough_endpoint_router.py index 65729adb3e5..dc53686b3cc 100644 --- a/litellm/proxy/pass_through_endpoints/passthrough_endpoint_router.py +++ b/litellm/proxy/pass_through_endpoints/passthrough_endpoint_router.py @@ -22,7 +22,7 @@ def _get_proxy_llm_router() -> "Router | None": return llm_router -def _get_str_value(values: dict | None, key: str) -> str | None: +def _get_str_value(values: dict[str, object] | None, key: str) -> str | None: value: Final = values.get(key) if values is not None else None return value if isinstance(value, str) else None diff --git a/litellm/types/router.py b/litellm/types/router.py index ddb575545ea..86811dc5b3c 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -382,8 +382,8 @@ class LiteLLMParamsTypedDict(TypedDict, total=False): ## RESPONSES API → CHAT COMPLETIONS BRIDGE ## use_chat_completions_api: Optional[bool] ## PASS-THROUGH ENDPOINTS ## - use_in_pass_through: Optional[bool] - litellm_credential_name: Optional[str] + use_in_pass_through: bool | None + litellm_credential_name: str | None ## UNIFIED PROJECT/REGION ## region_name: Optional[str] ## VERTEX AI ## From f2049a7d9b63a3d44736950dd1635da5635fc6f5 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:37:12 -0700 Subject: [PATCH 03/11] fix(proxy): narrow pass-through provider resolution to BadRequestError --- .../proxy/pass_through_endpoints/passthrough_endpoint_router.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/proxy/pass_through_endpoints/passthrough_endpoint_router.py b/litellm/proxy/pass_through_endpoints/passthrough_endpoint_router.py index dc53686b3cc..1d2b4504d61 100644 --- a/litellm/proxy/pass_through_endpoints/passthrough_endpoint_router.py +++ b/litellm/proxy/pass_through_endpoints/passthrough_endpoint_router.py @@ -116,7 +116,7 @@ class PassthroughEndpointRouter: model=model, custom_llm_provider=litellm_params.get("custom_llm_provider"), ) - except Exception: + except litellm.exceptions.BadRequestError: return None return provider From 09dd167b5a744c72e8f1699ab5d1ee770095d97b Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 5 Aug 2026 12:40:47 -0700 Subject: [PATCH 04/11] feat(sgr): make the gateway middleware the source of truth for successful requests (#35717) SGR has had two independent definitions. The admin UI derived it from SpendLogs, so it counted what litellm's logging callbacks observed and could attribute and price. BillableRequestMetricsMiddleware counted what the proxy actually answered at the ASGI edge, but only exported to OTLP for enterprise metering. The two disagree by design in places, and the SpendLogs figure goes quiet whenever spend logging is disabled or the callbacks are bypassed. This adds LiteLLM_DailyGatewayRequests, written by the middleware, and points the dashboard's Successful Requests tile at it. Requests fold into an in-memory map at record time rather than going through a queue like the spend path. A count is a pure aggregate, and every dimension of the key is chosen by the proxy from a closed set: the date, the category, and a route that the classifier maps to one of a fixed list of strings rather than passing the raw path through. Nothing a caller sends can add a key, so the fold and the table are bounded by (days x categories x routes) however much traffic arrives; the spend queue blocks once full, which is not acceptable in the response path. A scheduler job drains it on the existing batch interval, and a failed flush merges its counts back so a database blip undercounts nothing. The middleware previously returned early when no billing recorder was injected, which is the unlicensed case. The new sink is not license-gated, so that early return now requires both sinks to be absent. The billing recorder keeps its 2xx-only gate; the sink takes every status so failed_requests is real. The sink is not told which deployment served the request, unlike the billing recorder. That id is a sha256 over litellm_params, credentials included, so a caller who puts a credential in the request body mints a fresh one per distinct value. No configuration is needed for that: api_base and base_url are on _BANNED_REQUEST_BODY_PARAMS and need allow_client_side_ credentials, but api_key is not on that list, and both reach the same _handle_clientside_credential branch. The read endpoint aggregates the dimension away regardless, so the key is better off without it. The new table carries no key, user or team dimension, so /gateway/daily/activity is restricted to proxy admin roles and the per-key and per-model breakdowns keep reading the daily spend tables. The old path is left running and marked with TODOs. A fetched result carries the range key it was fetched for, and the render selects it only when that key matches the range on screen. Both the gateway counts and the spend aggregate go through that rule: the request tiles read the first and fall through to the second, so stamping only one of them would leave the tile showing a superseded range by the other route. The paginated pages behind that aggregate are reached through a failure flag, so the flag is stamped too. A flag left over from the previous range would let those pages through while a new range is in flight, which is the same defect one fallback further down. --- backend/routes/allowlist.py | 3 + .../migration.sql | 15 + .../litellm_proxy_extras/schema.prisma | 20 ++ litellm/proxy/_types.py | 3 + litellm/proxy/db/db_spend_update_writer.py | 6 + litellm/proxy/db/gateway_request_tracking.py | 133 ++++++++ .../common_daily_activity.py | 5 + .../gateway_request_endpoints.py | 139 ++++++++ .../billable_request_metrics_middleware.py | 72 ++++- litellm/proxy/proxy_server.py | 33 ++ litellm/proxy/schema.prisma | 20 ++ litellm/types/proxy/gateway_requests.py | 51 +++ schema.prisma | 20 ++ .../proxy/db/test_gateway_request_tracking.py | 227 +++++++++++++ .../test_gateway_request_endpoints.py | 303 ++++++++++++++++++ ...est_billable_request_metrics_middleware.py | 126 ++++++++ .../proxy/proxy_server/test_lifecycle.py | 60 ++++ .../components/UsagePageView.test.tsx | 181 ++++++++++- .../_components/components/UsagePageView.tsx | 134 +++++++- .../components/gatewayActivity.test.ts | 108 +++++++ .../_components/components/gatewayActivity.ts | 82 +++++ .../src/components/networking.tsx | 25 ++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 115 +++++++ 23 files changed, 1846 insertions(+), 35 deletions(-) create mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20260803000000_add_daily_gateway_requests/migration.sql create mode 100644 litellm/proxy/db/gateway_request_tracking.py create mode 100644 litellm/proxy/management_endpoints/gateway_request_endpoints.py create mode 100644 litellm/types/proxy/gateway_requests.py create mode 100644 tests/test_litellm/proxy/db/test_gateway_request_tracking.py create mode 100644 tests/test_litellm/proxy/management_endpoints/test_gateway_request_endpoints.py create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/gatewayActivity.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/gatewayActivity.ts diff --git a/backend/routes/allowlist.py b/backend/routes/allowlist.py index 96e224a7dc6..8ccd439979b 100644 --- a/backend/routes/allowlist.py +++ b/backend/routes/allowlist.py @@ -82,6 +82,9 @@ BACKEND_PATH_PREFIXES: tuple[str, ...] = ( "/user_agent", "/usage/", "/daily/", + # Deployment-wide gateway request counts. Scoped to the analytics read rather + # than all of /gateway/, which stays free for data-plane routes. + "/gateway/daily/", # CloudZero cost-export admin (init / settings / export / dry-run / delete) "/cloudzero/", # Caching admin diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260803000000_add_daily_gateway_requests/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260803000000_add_daily_gateway_requests/migration.sql new file mode 100644 index 00000000000..0885cebeaf5 --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260803000000_add_daily_gateway_requests/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE IF NOT EXISTS "LiteLLM_DailyGatewayRequests" ( + "date" TEXT NOT NULL, + "category" TEXT NOT NULL, + "route" TEXT NOT NULL, + "successful_requests" BIGINT NOT NULL DEFAULT 0, + "failed_requests" BIGINT NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "LiteLLM_DailyGatewayRequests_pkey" PRIMARY KEY ("date","category","route") +); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "LiteLLM_DailyGatewayRequests_date_idx" ON "LiteLLM_DailyGatewayRequests"("date"); diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index 17339541fd9..3a7882b6d2b 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -1118,6 +1118,26 @@ model LiteLLM_DailyToolSpend { @@id([date, tool_name]) } +// Gateway request counts recorded at the ASGI edge by +// BillableRequestMetricsMiddleware. This is the source of truth for SGR +// (successful gateway requests): it counts what the proxy actually answered, +// independent of whether the request reached litellm's logging callbacks. +// The key carries no deployment or caller dimension. Every part of it is +// chosen by the proxy and drawn from a closed set, so the table is bounded by +// (days x categories x routes) rather than by anything a caller can vary. +model LiteLLM_DailyGatewayRequests { + date String + category String + route String + successful_requests BigInt @default(0) + failed_requests BigInt @default(0) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@id([date, category, route]) + @@index([date]) +} + // Prompt table for storing prompt configurations model LiteLLM_PromptTable { id String @id @default(uuid()) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 4e5782c862a..7a68fb24f43 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -622,6 +622,8 @@ class LiteLLMRoutes(enum.Enum): "/team/permissions_update", "/team/permissions_bulk_update", "/team/daily/activity", + # gateway request counts (SGR); deployment-wide, admin-only + "/gateway/daily/activity", # model "/model/new", "/model/update", @@ -715,6 +717,7 @@ class LiteLLMRoutes(enum.Enum): "/global/spend/tags", "/global/predict/spend/logs", "/global/activity", + "/gateway/daily/activity", "/health/services", ] + info_routes diff --git a/litellm/proxy/db/db_spend_update_writer.py b/litellm/proxy/db/db_spend_update_writer.py index acc8c71b84b..bb37989d129 100644 --- a/litellm/proxy/db/db_spend_update_writer.py +++ b/litellm/proxy/db/db_spend_update_writer.py @@ -1860,6 +1860,12 @@ class DBSpendUpdateWriter: ) return None + # TODO: remove the successful_requests/failed_requests counters below once the + # admin UI has fully migrated to LiteLLM_DailyGatewayRequests, which is now the + # source of truth for SGR. This path derives the counts from spend-log metadata + # rather than from what the gateway answered, so the two intentionally disagree + # (see litellm/proxy/middleware/billable_request_metrics_middleware.py). The + # spend, token and per-entity columns written here stay either way. request_status: Final = prisma_client.get_request_status(payload) verbose_proxy_logger.debug("Logged request status: %s", request_status) _metadata: Final[SpendLogsMetadata] = json.loads(payload["metadata"]) diff --git a/litellm/proxy/db/gateway_request_tracking.py b/litellm/proxy/db/gateway_request_tracking.py new file mode 100644 index 00000000000..bebd74e877c --- /dev/null +++ b/litellm/proxy/db/gateway_request_tracking.py @@ -0,0 +1,133 @@ +""" +Accumulates gateway request counts (SGR) recorded at the ASGI edge and commits +them to ``LiteLLM_DailyGatewayRequests``. + +Unlike the spend queues this keeps no per-request item. A count is a pure +aggregate, so requests fold into an in-memory map as they finish. Every +dimension of the key is server-chosen and drawn from a fixed set: the date, the +category, and a route that the classifier maps to one of a closed list of +strings rather than passing the raw path through. Nothing a caller sends can +add a key, so the fold and the table it commits to are bounded by (days x +routes) however much traffic arrives, and the response path carries no +unbounded queue that would block once full. +""" + +from dataclasses import asdict +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Final + +from litellm._logging import verbose_proxy_logger +from litellm.proxy.middleware.billable_request_metrics_middleware import BillableCategory +from litellm.types.proxy.gateway_requests import ( + GatewayRequestCounts, + GatewayRequestKey, + GatewayRequestSnapshot, +) + +if TYPE_CHECKING: + from litellm.proxy.utils import PrismaClient + +_EMPTY: Final = GatewayRequestCounts(successful_requests=0, failed_requests=0) + + +def _utc_date() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%d") + + +class GatewayRequestAccumulator: + """Sink for the request-metrics middleware. ``record`` is sync and never awaits.""" + + def __init__(self) -> None: + self._counts: dict[GatewayRequestKey, GatewayRequestCounts] = {} # mutable-ok: bounded fold, drained per flush + + def record(self, *, category: BillableCategory, route: str, status_code: int) -> None: + key: Final = GatewayRequestKey(date=_utc_date(), category=category.value, route=route) + self._counts[key] = self._counts.get(key, _EMPTY).plus(succeeded=200 <= status_code < 300) + + def drain(self) -> GatewayRequestSnapshot: + drained: Final = self._counts + self._counts = {} # mutable-ok: the fold restarts empty; the drained map is handed off whole + return drained + + def restore(self, snapshot: GatewayRequestSnapshot) -> None: + """ + Merge un-committed counts back so the next flush retries them. + + A dropped flush would silently undercount the metric the dashboard now + treats as the source of truth. Merging cannot grow without bound: keys + collapse on collision, so the fold stays bounded by (date x category x + route) however long the database is unreachable. + + This buys at-least-once, not exactly-once, and the cost is worth stating. + The batch commits inside its context manager's ``__aexit__``, so a failure + raised after the transaction committed (a connection dropped while reading + the acknowledgement) restores counts that are already persisted, and the + next flush increments them a second time. Exactly-once would need a dedup + key the upserts could ignore on replay. For a traffic-volume metric a rare + overcount on a dropped acknowledgement beats losing a whole interval to + every database blip, so the trade is deliberate. + """ + for key, counts in snapshot.items(): + existing = self._counts.get(key, _EMPTY) + self._counts[key] = GatewayRequestCounts( + successful_requests=existing.successful_requests + counts.successful_requests, + failed_requests=existing.failed_requests + counts.failed_requests, + ) + + +async def commit_gateway_requests_to_db( + *, + prisma_client: "PrismaClient", + snapshot: GatewayRequestSnapshot, +) -> None: + """Upsert one incrementing row per (date, category, route).""" + if not snapshot: + return + + ordered: Final = sorted(snapshot.items(), key=lambda item: (item[0].date, item[0].category, item[0].route)) + + # pyright: ignore[reportAny] on both lines -- prisma's generated client is untyped, + # so .db and every table action off it resolve to Any at this boundary. The dict + # literals below are the shape prisma's generated inputs require. + async with prisma_client.db.batch_() as batcher: # pyright: ignore[reportAny] # untyped prisma client + for key, counts in ordered: + columns = asdict(key) + batcher.litellm_dailygatewayrequests.upsert( # pyright: ignore[reportAny] # untyped prisma client + where={"date_category_route": columns}, # mutable-ok: prisma input is dict-shaped + data={ # mutable-ok: prisma input is dict-shaped + "create": { # mutable-ok: prisma input is dict-shaped + **columns, + "successful_requests": counts.successful_requests, + "failed_requests": counts.failed_requests, + }, + "update": { # mutable-ok: prisma input is dict-shaped + "successful_requests": {"increment": counts.successful_requests}, # mutable-ok: as above + "failed_requests": {"increment": counts.failed_requests}, # mutable-ok: as above + }, + }, + ) + + verbose_proxy_logger.debug("Gateway request tracking - committed %d aggregated rows", len(ordered)) + + +async def flush_gateway_requests( + prisma_client: "PrismaClient", + accumulator: GatewayRequestAccumulator, +) -> None: + """ + Scheduler entrypoint. Never raises: a metering failure must not kill the job. + + ``CancelledError`` is deliberately not caught, so a flush cancelled during + shutdown drops its snapshot rather than restoring counts onto an accumulator + the process is about to discard. + """ + snapshot: Final = accumulator.drain() + try: + await commit_gateway_requests_to_db(prisma_client=prisma_client, snapshot=snapshot) + except Exception: # noqa: BLE001 -- a failed flush must not stop the scheduler + accumulator.restore(snapshot) + verbose_proxy_logger.warning( + "Gateway request tracking - failed to commit %d rows, retrying on the next flush", + len(snapshot), + exc_info=True, + ) diff --git a/litellm/proxy/management_endpoints/common_daily_activity.py b/litellm/proxy/management_endpoints/common_daily_activity.py index 96f2465ebb9..9af65b50c7f 100644 --- a/litellm/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/proxy/management_endpoints/common_daily_activity.py @@ -571,6 +571,11 @@ def _build_aggregated_sql_query( # straight into their buckets without re-summing. The leaf grouping # is omitted on purpose: nothing in the response shape needs it once # all the rollups are present. + # + # TODO: drop the successful_requests/failed_requests aggregates (and the + # total_successful_requests metadata they feed) once the admin UI reads SGR + # only from LiteLLM_DailyGatewayRequests. The remaining spend, token and + # api_requests rollups are still served from here. sql_query: Final = f""" SELECT date, diff --git a/litellm/proxy/management_endpoints/gateway_request_endpoints.py b/litellm/proxy/management_endpoints/gateway_request_endpoints.py new file mode 100644 index 00000000000..33c078274fb --- /dev/null +++ b/litellm/proxy/management_endpoints/gateway_request_endpoints.py @@ -0,0 +1,139 @@ +""" +GATEWAY REQUEST COUNTS (SGR) + +GET /gateway/daily/activity - successful/failed gateway requests by date and route + +Source of truth is LiteLLM_DailyGatewayRequests, written at the ASGI edge by +BillableRequestMetricsMiddleware. This counts what the proxy answered, so it is +independent of whether a request reached litellm's logging callbacks. + +The table carries no key/user/team dimension, so these totals are deployment-wide +and the endpoint is restricted to proxy admin roles. +""" + +from collections.abc import Sequence +from datetime import datetime, timedelta, timezone +from typing import Annotated, Final + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, TypeAdapter + +from litellm._logging import verbose_proxy_logger +from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.types.proxy.gateway_requests import ( + GatewayRequestActivityResponse, + GatewayRequestBreakdownEntry, + GatewayRequestDailyEntry, +) + +router: Final = APIRouter() + +_DEFAULT_LOOKBACK_DAYS: Final = 30 + +_AGGREGATE_SQL: Final = """ + SELECT + date, + category, + route, + SUM(successful_requests)::bigint AS successful_requests, + SUM(failed_requests)::bigint AS failed_requests + FROM "LiteLLM_DailyGatewayRequests" + WHERE date >= $1 AND date <= $2 + GROUP BY date, category, route +""" + + +class _AggregateRow(BaseModel): + """Validates one query_raw row so the handler works with typed values, not Any.""" + + date: str + category: str + route: str + successful_requests: int + failed_requests: int + + +_ROWS_ADAPTER: Final = TypeAdapter(tuple[_AggregateRow, ...]) + + +def _default_range() -> tuple[str, str]: + end: Final = datetime.now(timezone.utc) + start: Final = end - timedelta(days=_DEFAULT_LOOKBACK_DAYS) + return start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d") + + +def _fold_by_date(rows: Sequence[_AggregateRow]) -> tuple[GatewayRequestDailyEntry, ...]: + dates: Final = sorted(frozenset(row.date for row in rows)) + return tuple( + GatewayRequestDailyEntry( + date=date, + successful_requests=sum(row.successful_requests for row in rows if row.date == date), + failed_requests=sum(row.failed_requests for row in rows if row.date == date), + ) + for date in dates + ) + + +def _fold_by_route(rows: Sequence[_AggregateRow]) -> tuple[GatewayRequestBreakdownEntry, ...]: + pairs: Final = sorted(frozenset((row.category, row.route) for row in rows)) + entries: Final = tuple( + GatewayRequestBreakdownEntry( + category=category, + route=route, + successful_requests=sum( + row.successful_requests for row in rows if row.category == category and row.route == route + ), + failed_requests=sum(row.failed_requests for row in rows if row.category == category and row.route == route), + ) + for category, route in pairs + ) + return tuple(sorted(entries, key=lambda entry: entry.successful_requests, reverse=True)) + + +@router.get( + "/gateway/daily/activity", + tags=["Budget & Spend Tracking"], # mutable-ok: fastapi's decorator signature types tags as a list + response_model=GatewayRequestActivityResponse, +) +async def get_gateway_daily_activity( + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], + start_date: str | None = Query(default=None, description="Start date in YYYY-MM-DD format"), + end_date: str | None = Query(default=None, description="End date in YYYY-MM-DD format"), +) -> GatewayRequestActivityResponse: + """ + Successful and failed gateway requests, counted at the ASGI edge. + + Deployment-wide: the underlying table has no per-key or per-user dimension, + so this is admin-only. + """ + from litellm.proxy.proxy_server import prisma_client + + if user_api_key_dict.user_role not in ( + LitellmUserRoles.PROXY_ADMIN, + LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + ): + raise HTTPException( + status_code=403, + detail="Only proxy admin roles can view gateway request counts across the deployment", + ) + + if prisma_client is None: + raise HTTPException(status_code=500, detail=CommonProxyErrors.db_not_connected_error.value) + + default_start, default_end = _default_range() + raw_rows: Final = await prisma_client.db.query_raw( # pyright: ignore[reportAny] # untyped prisma client + _AGGREGATE_SQL, + start_date or default_start, + end_date or default_end, + ) + # Every downstream use is typed: the adapter returns _AggregateRow or raises. + rows: Final = _ROWS_ADAPTER.validate_python(raw_rows or ()) + verbose_proxy_logger.debug("/gateway/daily/activity - aggregated %d rows", len(rows)) + + return GatewayRequestActivityResponse( + total_successful_requests=sum(row.successful_requests for row in rows), + total_failed_requests=sum(row.failed_requests for row in rows), + by_date=_fold_by_date(rows), + by_route=_fold_by_route(rows), + ) diff --git a/litellm/proxy/middleware/billable_request_metrics_middleware.py b/litellm/proxy/middleware/billable_request_metrics_middleware.py index 2708698f71c..9824f33797c 100644 --- a/litellm/proxy/middleware/billable_request_metrics_middleware.py +++ b/litellm/proxy/middleware/billable_request_metrics_middleware.py @@ -1,11 +1,18 @@ """ -Counts billable HTTP requests on enterprise deployments. +Counts HTTP requests to LLM inference, MCP, and A2A endpoints. -A billable request is an inbound request to an LLM inference, MCP, or A2A -endpoint that returns a 2xx status. The actual export happens in an injected -recorder (see litellm.proxy.enterprise_billing.billing_metrics); when no -recorder is injected (non-enterprise, or metering misconfigured) this -middleware is a transparent pass-through. +Feeds two independent sinks off one classification: + +- ``GatewayRequestSink`` receives every classified request with its status and + is the source of truth for SGR (successful gateway requests) on the admin UI. + Not license-gated (see litellm.proxy.db.gateway_request_tracking). It is not + told which deployment served the request: it persists its counts, so every + dimension it takes has to be one the proxy chooses. +- ``BillingRecorder`` receives 2xx requests only and exports them for + enterprise metering (see litellm.proxy.enterprise_billing.billing_metrics). + +Both are injected. When neither is present the middleware is a transparent +pass-through. """ import re @@ -31,6 +38,21 @@ class BillingRecorder(Protocol): def record(self, *, category: BillableCategory, route: str, status_code: int, model_id: str | None) -> None: ... +@runtime_checkable +class GatewayRequestSink(Protocol): + """ + Records every classified request, 2xx or not, for the SGR dashboard. + + Distinct from BillingRecorder on three counts: this is not license-gated, + it is not restricted to 2xx, and it takes no model id. The deployment that + served a request is deliberately not part of what it records, because the + dashboard aggregates by route and a per-deployment dimension would only + multiply the rows it has to sum back together. + """ + + def record(self, *, category: BillableCategory, route: str, status_code: int) -> None: ... + + _MODEL_ID_HEADER: Final = b"x-litellm-model-id" # Ordered: a longer suffix that shares an ending with a shorter one must come @@ -165,10 +187,11 @@ def _extract_model_id(headers: Sequence[tuple[bytes, bytes]]) -> str | None: class BillableRequestMetricsMiddleware: """ - Pure ASGI middleware that records one billable request per 2xx response to a - billable endpoint. Modeled on InFlightRequestsMiddleware: it wraps `send`, - reads the final status and the x-litellm-model-id header off the - `http.response.start` message, and never blocks or fails the request path. + Pure ASGI middleware that classifies each request once and fans the result + out to the SGR sink (any status) and the billing recorder (2xx only). + Modeled on InFlightRequestsMiddleware: it wraps `send`, reads the final + status and the x-litellm-model-id header off the `http.response.start` + message, and never blocks or fails the request path. """ def __init__( @@ -176,6 +199,8 @@ class BillableRequestMetricsMiddleware: app: ASGIApp, recorder: BillingRecorder | None = None, recorder_factory: Callable[[], BillingRecorder | None] | None = None, + sink: GatewayRequestSink | None = None, + sink_factory: Callable[[], GatewayRequestSink | None] | None = None, ) -> None: self.app = app self.recorder = recorder @@ -187,6 +212,12 @@ class BillableRequestMetricsMiddleware: self._recorder_factory = recorder_factory self._resolved = recorder_factory is None self._resolve_lock = threading.Lock() + # Resolved on the same schedule and for the same reason: the DB is not + # connected at import time, so the sink cannot be built there either. + self.sink = sink + self._sink_factory = sink_factory + self._sink_resolved = sink_factory is None + self._sink_resolve_lock = threading.Lock() def _resolve_recorder(self) -> BillingRecorder | None: if self._resolved: @@ -200,13 +231,24 @@ class BillableRequestMetricsMiddleware: self._resolved = True return self.recorder + def _resolve_sink(self) -> GatewayRequestSink | None: + if self._sink_resolved: + return self.sink + with self._sink_resolve_lock: + if not self._sink_resolved: + factory: Final = self._sink_factory + self.sink = factory() if factory is not None else self.sink + self._sink_resolved = True + return self.sink + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if scope["type"] != "http": await self.app(scope, receive, send) return recorder: Final = self._resolve_recorder() - if recorder is None: + sink: Final = self._resolve_sink() + if recorder is None and sink is None: await self.app(scope, receive, send) return @@ -228,7 +270,13 @@ class BillableRequestMetricsMiddleware: await self.app(scope, receive, send_wrapper) - if 200 <= status_code < 300: + if sink is not None: + try: + sink.record(category=category, route=route, status_code=status_code) + except Exception: # noqa: BLE001 -- metering must never fail a request that was already served + verbose_proxy_logger.warning("gateway request metering failed for %s", route, exc_info=True) + + if recorder is not None and 200 <= status_code < 300: try: recorder.record(category=category, route=route, status_code=status_code, model_id=model_id) except Exception: # noqa: BLE001 -- metering must never fail a request that was already served diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 43e37686f07..d4b3aae0d82 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -353,6 +353,10 @@ from litellm.proxy.db.exception_handler import ( PrismaDBExceptionHandler, call_with_db_reconnect_retry, ) +from litellm.proxy.db.gateway_request_tracking import ( + GatewayRequestAccumulator, + flush_gateway_requests, +) from litellm.proxy.db.spend_counter_reseed import SpendCounterReseed from litellm.proxy.discovery_endpoints import ui_discovery_endpoints_router from litellm.proxy.fine_tuning_endpoints.endpoints import router as fine_tuning_router @@ -411,6 +415,9 @@ from litellm.proxy.management_endpoints.customer_endpoints import ( from litellm.proxy.management_endpoints.fallback_management_endpoints import ( router as fallback_management_router, ) +from litellm.proxy.management_endpoints.gateway_request_endpoints import ( + router as gateway_request_router, +) from litellm.proxy.management_endpoints.internal_user_endpoints import ( router as internal_user_router, ) @@ -820,6 +827,11 @@ async def proxy_shutdown_event(): global prisma_client, master_key, user_custom_auth, user_custom_key_generate, user_custom_key_update verbose_proxy_logger.info("Shutting down LiteLLM Proxy Server") if prisma_client: + # Drain the SGR fold first: it lives in memory, so an un-drained interval + # is lost, and a write attempted after disconnect raises + # ClientNotConnectedError rather than persisting anything. Ordering this + # inside the same guard is what keeps the two from drifting apart. + await flush_gateway_requests(prisma_client, gateway_request_accumulator) verbose_proxy_logger.debug("Disconnecting from Prisma") await prisma_client.disconnect() @@ -1901,6 +1913,11 @@ app.add_middleware( if build_billing_metrics_recorder is not None else None ), + # Unlike the billing recorder this is not license-gated: the admin UI must + # report SGR on any deployment. Gated only on a database being configured, + # since without one the fold would never be drained. Read at call time, so + # it sees prisma_client as of the first request rather than import time. + sink_factory=lambda: gateway_request_accumulator if prisma_client is not None else None, ) app.add_middleware(InFlightRequestsMiddleware) app.add_middleware(SecurityHeadersMiddleware) @@ -2068,6 +2085,10 @@ jwt_handler: Final = JWTHandler() prompt_injection_detection_obj: _OPTIONAL_PromptInjectionDetection | None = None store_model_in_db: bool = False open_telemetry_logger: OpenTelemetry | None = None +### GATEWAY REQUEST COUNTS (SGR) ### +# Folded in memory by BillableRequestMetricsMiddleware, drained to +# LiteLLM_DailyGatewayRequests by the update_gateway_requests scheduler job. +gateway_request_accumulator: Final = GatewayRequestAccumulator() ### INITIALIZE GLOBAL LOGGING OBJECT ### proxy_logging_obj: ProxyLogging = ProxyLogging(user_api_key_cache=user_api_key_cache, premium_user=premium_user) ### REDIS QUEUE ### @@ -8198,6 +8219,17 @@ class ProxyStartupEvent: f"({tag_spend_update_interval / batch_writing_interval:.1f}x main job interval)" ) + ### UPDATE GATEWAY REQUEST COUNTS (SGR) ### + scheduler.add_job( + flush_gateway_requests, + "interval", + seconds=batch_writing_interval, + args=(prisma_client, gateway_request_accumulator), + id="update_gateway_requests_job", + replace_existing=True, + misfire_grace_time=APSCHEDULER_MISFIRE_GRACE_TIME, + ) + ### MONITOR SPEND LOGS QUEUE (queue-size-based job) ### if general_settings.get("disable_spend_logs", False) is False: from litellm.proxy.utils import _monitor_spend_logs_queue @@ -16478,6 +16510,7 @@ app.include_router(fallback_management_router) app.include_router(cache_settings_router) app.include_router(coordination_redis_settings_router) app.include_router(user_agent_analytics_router) +app.include_router(gateway_request_router) app.include_router(enterprise_router) app.include_router(ui_discovery_endpoints_router) # Eager: /models/{name}:method overlaps with the OpenAI /models endpoint. diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 17339541fd9..3a7882b6d2b 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -1118,6 +1118,26 @@ model LiteLLM_DailyToolSpend { @@id([date, tool_name]) } +// Gateway request counts recorded at the ASGI edge by +// BillableRequestMetricsMiddleware. This is the source of truth for SGR +// (successful gateway requests): it counts what the proxy actually answered, +// independent of whether the request reached litellm's logging callbacks. +// The key carries no deployment or caller dimension. Every part of it is +// chosen by the proxy and drawn from a closed set, so the table is bounded by +// (days x categories x routes) rather than by anything a caller can vary. +model LiteLLM_DailyGatewayRequests { + date String + category String + route String + successful_requests BigInt @default(0) + failed_requests BigInt @default(0) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@id([date, category, route]) + @@index([date]) +} + // Prompt table for storing prompt configurations model LiteLLM_PromptTable { id String @id @default(uuid()) diff --git a/litellm/types/proxy/gateway_requests.py b/litellm/types/proxy/gateway_requests.py new file mode 100644 index 00000000000..f0abeb3c950 --- /dev/null +++ b/litellm/types/proxy/gateway_requests.py @@ -0,0 +1,51 @@ +"""Types for gateway request counts (SGR), recorded at the ASGI edge.""" + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import TypeAlias + +from pydantic import BaseModel + + +@dataclass(frozen=True, slots=True) +class GatewayRequestKey: + date: str + category: str + route: str + + +@dataclass(frozen=True, slots=True) +class GatewayRequestCounts: + successful_requests: int + failed_requests: int + + def plus(self, *, succeeded: bool) -> "GatewayRequestCounts": + return GatewayRequestCounts( + successful_requests=self.successful_requests + (1 if succeeded else 0), + failed_requests=self.failed_requests + (0 if succeeded else 1), + ) + + +GatewayRequestSnapshot: TypeAlias = Mapping[GatewayRequestKey, GatewayRequestCounts] + + +class GatewayRequestBreakdownEntry(BaseModel): + category: str + route: str + successful_requests: int = 0 + failed_requests: int = 0 + + +class GatewayRequestDailyEntry(BaseModel): + date: str + successful_requests: int = 0 + failed_requests: int = 0 + + +class GatewayRequestActivityResponse(BaseModel): + """Response for GET /gateway/daily/activity.""" + + total_successful_requests: int = 0 + total_failed_requests: int = 0 + by_date: tuple[GatewayRequestDailyEntry, ...] = () + by_route: tuple[GatewayRequestBreakdownEntry, ...] = () diff --git a/schema.prisma b/schema.prisma index 17339541fd9..3a7882b6d2b 100644 --- a/schema.prisma +++ b/schema.prisma @@ -1118,6 +1118,26 @@ model LiteLLM_DailyToolSpend { @@id([date, tool_name]) } +// Gateway request counts recorded at the ASGI edge by +// BillableRequestMetricsMiddleware. This is the source of truth for SGR +// (successful gateway requests): it counts what the proxy actually answered, +// independent of whether the request reached litellm's logging callbacks. +// The key carries no deployment or caller dimension. Every part of it is +// chosen by the proxy and drawn from a closed set, so the table is bounded by +// (days x categories x routes) rather than by anything a caller can vary. +model LiteLLM_DailyGatewayRequests { + date String + category String + route String + successful_requests BigInt @default(0) + failed_requests BigInt @default(0) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@id([date, category, route]) + @@index([date]) +} + // Prompt table for storing prompt configurations model LiteLLM_PromptTable { id String @id @default(uuid()) diff --git a/tests/test_litellm/proxy/db/test_gateway_request_tracking.py b/tests/test_litellm/proxy/db/test_gateway_request_tracking.py new file mode 100644 index 00000000000..93a11a914cb --- /dev/null +++ b/tests/test_litellm/proxy/db/test_gateway_request_tracking.py @@ -0,0 +1,227 @@ +""" +Tests for the gateway request (SGR) fold and its commit to +LiteLLM_DailyGatewayRequests. +""" + +import asyncio +from datetime import datetime, timezone + +import pytest + +from litellm.proxy.db.gateway_request_tracking import ( + GatewayRequestAccumulator, + commit_gateway_requests_to_db, + flush_gateway_requests, +) +from litellm.proxy.middleware.billable_request_metrics_middleware import BillableCategory +from litellm.types.proxy.gateway_requests import GatewayRequestCounts, GatewayRequestKey + + +def _today() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%d") + + +def _record(accumulator: GatewayRequestAccumulator, status_code: int, **overrides) -> None: + accumulator.record( + category=overrides.get("category", BillableCategory.LLM), + route=overrides.get("route", "/chat/completions"), + status_code=status_code, + ) + + +# ── fold ────────────────────────────────────────────────────────────────────── + + +def test_folds_repeated_requests_into_one_key(): + acc = GatewayRequestAccumulator() + for _ in range(3): + _record(acc, 200) + _record(acc, 500) + + snapshot = acc.drain() + assert snapshot == { + GatewayRequestKey(date=_today(), category="llm", route="/chat/completions"): ( + GatewayRequestCounts(successful_requests=3, failed_requests=1) + ) + } + + +@pytest.mark.parametrize( + "status_code, expected_successful, expected_failed", + [(200, 1, 0), (201, 1, 0), (204, 1, 0), (299, 1, 0), (300, 0, 1), (400, 0, 1), (500, 0, 1)], +) +def test_success_boundary_is_2xx(status_code: int, expected_successful: int, expected_failed: int): + acc = GatewayRequestAccumulator() + _record(acc, status_code) + counts = next(iter(acc.drain().values())) + assert (counts.successful_requests, counts.failed_requests) == (expected_successful, expected_failed) + + +def test_distinct_dimensions_do_not_merge(): + acc = GatewayRequestAccumulator() + _record(acc, 200, route="/chat/completions") + _record(acc, 200, route="/embeddings") + _record(acc, 200, category=BillableCategory.MCP, route="/mcp") + assert len(acc.drain()) == 3 + + +def test_drain_empties_the_fold(): + acc = GatewayRequestAccumulator() + _record(acc, 200) + assert len(acc.drain()) == 1 + assert acc.drain() == {} + + +def test_drain_snapshot_is_not_mutated_by_later_records(): + acc = GatewayRequestAccumulator() + _record(acc, 200) + snapshot = acc.drain() + _record(acc, 200) + assert next(iter(snapshot.values())).successful_requests == 1 + + +# ── commit ──────────────────────────────────────────────────────────────────── + + +class FakeTable: + def __init__(self) -> None: + self.upserts: list[dict] = [] + + def upsert(self, *, where: dict, data: dict) -> None: + self.upserts.append({"where": where, "data": data}) + + +class FakeBatcher: + def __init__(self, table: FakeTable) -> None: + self.litellm_dailygatewayrequests = table + + async def __aenter__(self) -> "FakeBatcher": + return self + + async def __aexit__(self, *args: object) -> bool: + return False + + +class FakeDB: + def __init__(self, table: FakeTable) -> None: + self._table = table + + def batch_(self) -> FakeBatcher: + return FakeBatcher(self._table) + + +class FakePrismaClient: + def __init__(self) -> None: + self.table = FakeTable() + self.db = FakeDB(self.table) + + +def test_commit_upserts_one_incrementing_row_per_key(): + client = FakePrismaClient() + snapshot = { + GatewayRequestKey(date="2026-08-01", category="llm", route="/chat/completions"): ( + GatewayRequestCounts(successful_requests=7, failed_requests=2) + ) + } + + asyncio.run(commit_gateway_requests_to_db(prisma_client=client, snapshot=snapshot)) + + assert len(client.table.upserts) == 1 + written = client.table.upserts[0] + assert written["where"] == { + "date_category_route": { + "date": "2026-08-01", + "category": "llm", + "route": "/chat/completions", + } + } + assert written["data"]["update"] == { + "successful_requests": {"increment": 7}, + "failed_requests": {"increment": 2}, + } + assert written["data"]["create"]["successful_requests"] == 7 + + +def test_commit_is_deterministically_ordered(): + """Concurrent writers must touch rows in the same order or they deadlock.""" + client = FakePrismaClient() + keys = [ + GatewayRequestKey(date="2026-08-02", category="llm", route="/embeddings"), + GatewayRequestKey(date="2026-08-01", category="mcp", route="/mcp"), + GatewayRequestKey(date="2026-08-01", category="llm", route="/chat/completions"), + ] + snapshot = {key: GatewayRequestCounts(successful_requests=1, failed_requests=0) for key in keys} + + asyncio.run(commit_gateway_requests_to_db(prisma_client=client, snapshot=snapshot)) + + written_order = [ + (row["where"]["date_category_route"]["date"], row["where"]["date_category_route"]["category"]) + for row in client.table.upserts + ] + assert written_order == [("2026-08-01", "llm"), ("2026-08-01", "mcp"), ("2026-08-02", "llm")] + + +def test_commit_skips_the_database_entirely_when_nothing_accumulated(): + client = FakePrismaClient() + asyncio.run(commit_gateway_requests_to_db(prisma_client=client, snapshot={})) + assert client.table.upserts == [] + + +# ── flush ───────────────────────────────────────────────────────────────────── + + +def test_flush_drains_and_commits(): + client = FakePrismaClient() + acc = GatewayRequestAccumulator() + _record(acc, 200) + + asyncio.run(flush_gateway_requests(client, acc)) + + assert len(client.table.upserts) == 1 + assert acc.drain() == {} + + +class ExplodingDB: + def batch_(self): + raise RuntimeError("db gone") + + +class ExplodingClient: + db = ExplodingDB() + + +def test_flush_swallows_commit_failure_so_the_scheduler_survives(): + acc = GatewayRequestAccumulator() + _record(acc, 200) + + asyncio.run(flush_gateway_requests(ExplodingClient(), acc)) + + +def test_failed_flush_keeps_counts_for_the_next_attempt(): + """A dropped flush would silently undercount the SGR source of truth.""" + acc = GatewayRequestAccumulator() + _record(acc, 200) + _record(acc, 500) + + asyncio.run(flush_gateway_requests(ExplodingClient(), acc)) + + client = FakePrismaClient() + asyncio.run(flush_gateway_requests(client, acc)) + + assert client.table.upserts[0]["data"]["update"] == { + "successful_requests": {"increment": 1}, + "failed_requests": {"increment": 1}, + } + + +def test_restored_counts_merge_with_requests_recorded_meanwhile(): + acc = GatewayRequestAccumulator() + _record(acc, 200) + asyncio.run(flush_gateway_requests(ExplodingClient(), acc)) + + _record(acc, 200) + client = FakePrismaClient() + asyncio.run(flush_gateway_requests(client, acc)) + + assert len(client.table.upserts) == 1 + assert client.table.upserts[0]["data"]["update"]["successful_requests"] == {"increment": 2} diff --git a/tests/test_litellm/proxy/management_endpoints/test_gateway_request_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_gateway_request_endpoints.py new file mode 100644 index 00000000000..4f4e378bae4 --- /dev/null +++ b/tests/test_litellm/proxy/management_endpoints/test_gateway_request_endpoints.py @@ -0,0 +1,303 @@ +import os +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +# Patching ``litellm.proxy.proxy_server.prisma_client`` imports that module, whose +# module-level setup reads DATABASE_URL and LITELLM_MASTER_KEY. Tier-zero runners +# set neither, so pin throwaways first, as test_component_allowlists.py does. The +# prior values are restored below so a non-postgres URL cannot leak into sibling +# tests sharing the xdist worker and make them treat a phantom database as live. +_THROWAWAY_ENV = { + "DATABASE_URL": "sqlite:///:memory:", + "LITELLM_MASTER_KEY": "sk-test-gateway-request-endpoints", +} +_PRE_EXISTING_ENV = {key: os.environ.get(key) for key in _THROWAWAY_ENV} +for _key, _value in _THROWAWAY_ENV.items(): + os.environ.setdefault(_key, _value) + +import pytest +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient + +from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.management_endpoints.gateway_request_endpoints import ( + _AggregateRow, + _default_range, + _fold_by_date, + _fold_by_route, + get_gateway_daily_activity, + router, +) + +for _key, _previous in _PRE_EXISTING_ENV.items(): + if _previous is None: + os.environ.pop(_key, None) + else: + os.environ[_key] = _previous + +# The handler stamps "today" from the wall clock, so any assertion that names a +# date has to pin it. Recomputing the expected range in the assertion instead +# would disagree with the request's own range whenever a run crosses UTC +# midnight between the two evaluations. +# A date in the past on purpose. Pinning "today" would let these assertions pass +# on a day the fixture silently failed to patch, which is the same vacuous pass a +# mutation check exists to catch. +_FROZEN_NOW = datetime(2023, 3, 15, 12, 0, tzinfo=timezone.utc) +_FROZEN_RANGE = ("2023-02-13", "2023-03-15") + + +@pytest.fixture +def frozen_clock(): + with patch("litellm.proxy.management_endpoints.gateway_request_endpoints.datetime") as clock: + clock.now.return_value = _FROZEN_NOW + yield + + +def _row( + date: str = "2026-08-04", + category: str = "llm", + route: str = "/chat/completions", + successful: int = 0, + failed: int = 0, +) -> _AggregateRow: + return _AggregateRow( + date=date, + category=category, + route=route, + successful_requests=successful, + failed_requests=failed, + ) + + +def _admin() -> UserAPIKeyAuth: + return UserAPIKeyAuth(api_key="sk-test", user_role=LitellmUserRoles.PROXY_ADMIN) + + +def _prisma_returning(rows: list) -> MagicMock: + client = MagicMock() + client.db = MagicMock() + client.db.query_raw = AsyncMock(return_value=rows) + return client + + +class TestDefaultRange: + def test_spans_the_documented_lookback(self): + start, end = _default_range() + span = datetime.strptime(end, "%Y-%m-%d") - datetime.strptime(start, "%Y-%m-%d") + assert span == timedelta(days=30) + + def test_ends_today_in_utc(self, frozen_clock): + assert _default_range() == _FROZEN_RANGE + + +class TestFoldByDate: + def test_sums_every_route_into_one_entry_per_date(self): + folded = _fold_by_date( + ( + _row(date="2026-08-03", route="/chat/completions", successful=5, failed=1), + _row(date="2026-08-03", route="/embeddings", successful=2, failed=0), + _row(date="2026-08-04", route="/chat/completions", successful=7, failed=3), + ) + ) + assert [(entry.date, entry.successful_requests, entry.failed_requests) for entry in folded] == [ + ("2026-08-03", 7, 1), + ("2026-08-04", 7, 3), + ] + + def test_orders_oldest_first_regardless_of_row_order(self): + rows = (_row(date="2026-08-09"), _row(date="2026-08-01"), _row(date="2026-08-05")) + assert [entry.date for entry in _fold_by_date(rows)] == ["2026-08-01", "2026-08-05", "2026-08-09"] + assert [entry.date for entry in _fold_by_date(tuple(reversed(rows)))] == [ + "2026-08-01", + "2026-08-05", + "2026-08-09", + ] + + def test_no_rows_yields_no_entries(self): + assert _fold_by_date(()) == () + + +class TestFoldByRoute: + def test_sums_across_dates_for_one_route(self): + folded = _fold_by_route( + ( + _row(date="2026-08-03", route="/chat/completions", successful=5, failed=1), + _row(date="2026-08-04", route="/chat/completions", successful=7, failed=3), + ) + ) + assert len(folded) == 1 + assert (folded[0].route, folded[0].successful_requests, folded[0].failed_requests) == ( + "/chat/completions", + 12, + 4, + ) + + def test_keeps_same_route_under_different_categories_apart(self): + folded = _fold_by_route( + ( + _row(category="mcp", route="/tools/call", successful=2), + _row(category="a2a", route="/tools/call", successful=1), + ) + ) + assert {(entry.category, entry.successful_requests) for entry in folded} == {("mcp", 2), ("a2a", 1)} + + def test_orders_busiest_route_first_whatever_the_row_order(self): + rows = ( + _row(route="/embeddings", successful=4), + _row(route="/chat/completions", successful=11), + _row(route="/rerank", successful=7), + ) + expected = ["/chat/completions", "/rerank", "/embeddings"] + assert [entry.route for entry in _fold_by_route(rows)] == expected + assert [entry.route for entry in _fold_by_route(tuple(reversed(rows)))] == expected + + +class TestGatewayDailyActivityEndpoint: + @pytest.mark.asyncio + @pytest.mark.parametrize( + "role", + [ + LitellmUserRoles.INTERNAL_USER, + LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, + LitellmUserRoles.TEAM, + LitellmUserRoles.ORG_ADMIN, + ], + ) + async def test_refuses_every_non_admin_role(self, role): + with patch("litellm.proxy.proxy_server.prisma_client", _prisma_returning([])): + with pytest.raises(HTTPException) as exc: + await get_gateway_daily_activity( + user_api_key_dict=UserAPIKeyAuth(api_key="sk-test", user_role=role), + ) + assert exc.value.status_code == 403 + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "role", + [LitellmUserRoles.PROXY_ADMIN, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY], + ) + async def test_serves_both_admin_roles(self, role): + with patch("litellm.proxy.proxy_server.prisma_client", _prisma_returning([])): + response = await get_gateway_daily_activity( + user_api_key_dict=UserAPIKeyAuth(api_key="sk-test", user_role=role), + ) + assert response.total_successful_requests == 0 + + @pytest.mark.asyncio + async def test_reports_db_not_connected_rather_than_crashing(self): + with patch("litellm.proxy.proxy_server.prisma_client", None): + with pytest.raises(HTTPException) as exc: + await get_gateway_daily_activity(user_api_key_dict=_admin()) + assert exc.value.status_code == 500 + + @pytest.mark.asyncio + async def test_totals_and_breakdowns_come_from_the_same_rows(self): + rows = [ + { + "date": "2026-08-03", + "category": "llm", + "route": "/chat/completions", + "successful_requests": 5, + "failed_requests": 1, + }, + { + "date": "2026-08-04", + "category": "llm", + "route": "/chat/completions", + "successful_requests": 7, + "failed_requests": 3, + }, + { + "date": "2026-08-04", + "category": "llm", + "route": "/embeddings", + "successful_requests": 4, + "failed_requests": 0, + }, + ] + with patch("litellm.proxy.proxy_server.prisma_client", _prisma_returning(rows)): + response = await get_gateway_daily_activity(user_api_key_dict=_admin()) + + assert response.total_successful_requests == 16 + assert response.total_failed_requests == 4 + assert sum(entry.successful_requests for entry in response.by_date) == 16 + assert sum(entry.successful_requests for entry in response.by_route) == 16 + assert [entry.date for entry in response.by_date] == ["2026-08-03", "2026-08-04"] + assert [entry.route for entry in response.by_route] == ["/chat/completions", "/embeddings"] + + @pytest.mark.asyncio + async def test_a_null_result_set_is_not_an_error(self): + client = _prisma_returning(None) + with patch("litellm.proxy.proxy_server.prisma_client", client): + response = await get_gateway_daily_activity(user_api_key_dict=_admin()) + assert response.total_successful_requests == 0 + assert response.by_date == () + assert response.by_route == () + +class TestGatewayDailyActivityRoute: + """ + Driven through the mounted route rather than by calling the handler. + + The date parameters carry FastAPI ``Query`` defaults, which only resolve to + None when the framework builds the call; invoking the handler directly hands + it the Query object instead, so a direct call cannot check what an omitted + date does. + """ + + def test_caller_dates_are_passed_through_verbatim(self): + prisma = _prisma_returning([]) + app = FastAPI() + app.include_router(router) + app.dependency_overrides[user_api_key_auth] = _admin + with patch("litellm.proxy.proxy_server.prisma_client", prisma): + response = TestClient(app).get( + "/gateway/daily/activity", + params={"start_date": "2026-01-01", "end_date": "2026-01-31"}, + ) + assert response.status_code == 200 + _, start, end = prisma.db.query_raw.call_args.args + assert (start, end) == ("2026-01-01", "2026-01-31") + + def test_omitted_dates_fall_back_to_the_default_window(self, frozen_clock): + prisma = _prisma_returning([]) + app = FastAPI() + app.include_router(router) + app.dependency_overrides[user_api_key_auth] = _admin + with patch("litellm.proxy.proxy_server.prisma_client", prisma): + response = TestClient(app).get("/gateway/daily/activity") + assert response.status_code == 200 + _, start, end = prisma.db.query_raw.call_args.args + assert (start, end) == _FROZEN_RANGE + + def test_serialized_response_carries_the_documented_shape(self): + prisma = _prisma_returning( + [ + { + "date": "2026-08-04", + "category": "llm", + "route": "/chat/completions", + "successful_requests": 7, + "failed_requests": 3, + } + ] + ) + app = FastAPI() + app.include_router(router) + app.dependency_overrides[user_api_key_auth] = _admin + with patch("litellm.proxy.proxy_server.prisma_client", prisma): + body = TestClient(app).get("/gateway/daily/activity").json() + + assert body == { + "total_successful_requests": 7, + "total_failed_requests": 3, + "by_date": [{"date": "2026-08-04", "successful_requests": 7, "failed_requests": 3}], + "by_route": [ + { + "category": "llm", + "route": "/chat/completions", + "successful_requests": 7, + "failed_requests": 3, + } + ], + } diff --git a/tests/test_litellm/proxy/middleware/test_billable_request_metrics_middleware.py b/tests/test_litellm/proxy/middleware/test_billable_request_metrics_middleware.py index 9363c50407d..ff7a24db832 100644 --- a/tests/test_litellm/proxy/middleware/test_billable_request_metrics_middleware.py +++ b/tests/test_litellm/proxy/middleware/test_billable_request_metrics_middleware.py @@ -18,6 +18,7 @@ from starlette.responses import JSONResponse, Response from starlette.routing import Route from starlette.testclient import TestClient +from litellm.proxy.db.gateway_request_tracking import GatewayRequestAccumulator from litellm.proxy.middleware.billable_request_metrics_middleware import ( BillableCategory, BillableRequestMetricsMiddleware, @@ -456,3 +457,128 @@ def test_billable_middleware_is_registered_inside_the_in_flight_tracker(): classes = [middleware.cls for middleware in proxy_app.user_middleware] assert classes.index(InFlightRequestsMiddleware) < classes.index(BillableRequestMetricsMiddleware) + + +# ── gateway request sink (SGR) ──────────────────────────────────────────────── + + +class FakeSink: + def __init__(self) -> None: + self.calls: List[dict] = [] + + def record(self, *, category: BillableCategory, route: str, status_code: int) -> None: + self.calls.append({"category": category, "route": route, "status_code": status_code}) + + +def _make_sink_app( + recorder: Optional[FakeRecorder], + sink: Optional[FakeSink], + status_code: int = 200, + model_id: Optional[str] = None, +) -> Starlette: + app = _make_app(None, status_code=status_code, model_id=model_id) + app.user_middleware.clear() + app.add_middleware(BillableRequestMetricsMiddleware, recorder=recorder, sink=sink) + return app + + +def test_sink_records_on_2xx(): + sink = FakeSink() + TestClient(_make_sink_app(None, sink, status_code=200, model_id="m-1")).post("/v1/chat/completions") + assert sink.calls == [{"category": BillableCategory.LLM, "route": "/chat/completions", "status_code": 200}] + + +def test_varying_model_ids_fold_into_a_single_persisted_key(): + """ + The deployment that served a request reaches the middleware as the + x-litellm-model-id header, and a caller has some say in which deployment + that is. The SGR key is persisted, so it must not carry that dimension: a + caller who could vary it could mint an unbounded number of table rows. + """ + accumulator = GatewayRequestAccumulator() + for model_id in ("deploy-1", "deploy-2", "deploy-3"): + client = TestClient(_make_sink_app(None, accumulator, status_code=200, model_id=model_id)) + client.post("/v1/chat/completions") + + snapshot = accumulator.drain() + assert len(snapshot) == 1 + assert next(iter(snapshot.values())).successful_requests == 3 + + +@pytest.mark.parametrize("status_code", [400, 429, 500, 503]) +def test_sink_records_failures_that_billing_ignores(status_code: int): + """SGR needs failed_requests, so the sink sees non-2xx. Billing must not.""" + sink, recorder = FakeSink(), FakeRecorder() + TestClient(_make_sink_app(recorder, sink, status_code=status_code)).post("/v1/chat/completions") + assert [call["status_code"] for call in sink.calls] == [status_code] + assert recorder.calls == [] + + +def test_sink_runs_when_billing_recorder_is_absent(): + """The OSS case. Billing is license-gated; the SGR dashboard is not, so an + absent recorder must not switch off the sink.""" + sink = FakeSink() + TestClient(_make_sink_app(None, sink, status_code=200)).post("/v1/chat/completions") + assert len(sink.calls) == 1 + + +def test_billing_recorder_still_2xx_only_when_sink_present(): + sink, recorder = FakeSink(), FakeRecorder() + client = TestClient(_make_sink_app(recorder, sink, status_code=200)) + client.post("/v1/chat/completions") + assert len(recorder.calls) == 1 + assert len(sink.calls) == 1 + + +def test_sink_ignores_non_billable_paths(): + sink = FakeSink() + TestClient(_make_sink_app(None, sink, status_code=200)).post("/health") + assert sink.calls == [] + + +def test_sink_raising_does_not_fail_the_request_or_block_billing(): + class ExplodingSink: + def record(self, *, category, route, status_code): + raise RuntimeError("db gone") + + recorder = FakeRecorder() + app = _make_app(None, status_code=200) + app.user_middleware.clear() + app.add_middleware(BillableRequestMetricsMiddleware, recorder=recorder, sink=ExplodingSink()) + response = TestClient(app).post("/v1/chat/completions") + assert response.status_code == 200 + assert len(recorder.calls) == 1 + + +def test_passthrough_only_when_both_recorder_and_sink_are_none(): + response = TestClient(_make_sink_app(None, None, status_code=200)).post("/v1/chat/completions") + assert response.status_code == 200 + + +def test_sink_factory_not_called_at_init(): + calls = [] + + def factory(): + calls.append(1) + return FakeSink() + + BillableRequestMetricsMiddleware(_make_app(None), sink_factory=factory) + assert calls == [] + + +def test_sink_factory_resolved_once_across_requests(): + sink = FakeSink() + calls = [] + + def factory(): + calls.append(1) + return sink + + app = _make_app(None, status_code=200) + app.user_middleware.clear() + app.add_middleware(BillableRequestMetricsMiddleware, sink_factory=factory) + client = TestClient(app) + client.post("/v1/chat/completions") + client.post("/v1/chat/completions") + assert calls == [1] + assert len(sink.calls) == 2 diff --git a/tests/test_litellm/proxy/proxy_server/test_lifecycle.py b/tests/test_litellm/proxy/proxy_server/test_lifecycle.py index a3f5049ef1d..cf83300ab3b 100644 --- a/tests/test_litellm/proxy/proxy_server/test_lifecycle.py +++ b/tests/test_litellm/proxy/proxy_server/test_lifecycle.py @@ -123,6 +123,66 @@ async def test_proxy_shutdown_event_disconnects_prisma_and_resets(monkeypatch): } +@pytest.mark.asyncio +async def test_proxy_shutdown_drains_gateway_requests_before_disconnecting(monkeypatch): + """ + The gateway request fold lives in memory, so shutdown drains it to the database. + + That drain has to happen while prisma is still connected: a write attempted + after ``disconnect()`` raises ClientNotConnectedError, the flush swallows it + and merges the counts back onto an accumulator the process is about to + discard, and the final interval is lost silently on every restart. Ordering is + the whole behavior here, so assert the order rather than that both ran. + """ + calls: list = [] # mutable-ok: records call order, which is the assertion + + fake_prisma = MagicMock() + fake_prisma.disconnect = AsyncMock(side_effect=lambda: calls.append("disconnect")) + monkeypatch.setattr(ps, "prisma_client", fake_prisma, raising=False) + + async def _record_flush(client, accumulator): + calls.append("flush") + assert client is fake_prisma + + monkeypatch.setattr(ps, "flush_gateway_requests", _record_flush, raising=False) + + fake_jwt = MagicMock() + fake_jwt.close = AsyncMock() + monkeypatch.setattr(ps, "jwt_handler", fake_jwt, raising=False) + monkeypatch.setattr(ps, "db_writer_client", None, raising=False) + + import litellm + + monkeypatch.setattr(litellm, "cache", None, raising=False) + monkeypatch.setattr(litellm, "success_callback", [], raising=False) + + await proxy_shutdown_event() + + assert calls == ["flush", "disconnect"] + + +@pytest.mark.asyncio +async def test_proxy_shutdown_skips_gateway_flush_without_a_database(monkeypatch): + """No prisma client means nothing to drain to, and no attempt is made.""" + flush = AsyncMock() + monkeypatch.setattr(ps, "flush_gateway_requests", flush, raising=False) + monkeypatch.setattr(ps, "prisma_client", None, raising=False) + + fake_jwt = MagicMock() + fake_jwt.close = AsyncMock() + monkeypatch.setattr(ps, "jwt_handler", fake_jwt, raising=False) + monkeypatch.setattr(ps, "db_writer_client", None, raising=False) + + import litellm + + monkeypatch.setattr(litellm, "cache", None, raising=False) + monkeypatch.setattr(litellm, "success_callback", [], raising=False) + + await proxy_shutdown_event() + + assert flush.await_count == 0 + + @pytest.mark.asyncio async def test_proxy_shutdown_event_prisma_disconnect_raises_error(monkeypatch): fake_prisma = MagicMock() diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx index 98dae51fa37..0ded7d195d0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx @@ -25,6 +25,7 @@ beforeAll(() => { vi.mock("@/components/networking", () => ({ userDailyActivityCall: vi.fn(), userDailyActivityAggregatedCall: vi.fn(), + gatewayDailyActivityCall: vi.fn(), tagListCall: vi.fn(), })); @@ -84,9 +85,23 @@ vi.mock("./UsageViewSelect/UsageViewSelect", async () => { vi.mock("@/components/shared/advanced_date_picker", async () => { const React = await import("react"); - const AdvancedDatePicker = () => { - return React.createElement("div", { "data-testid": "advanced-date-picker" }, "Date Picker"); - }; + // The button is how a test drives a range change; the real picker's own UI is + // not what any test here is asserting on. + const AdvancedDatePicker = ({ onValueChange }: { onValueChange?: (value: unknown) => void }) => + React.createElement( + "div", + { "data-testid": "advanced-date-picker" }, + "Date Picker", + React.createElement( + "button", + { + "data-testid": "pick-a-different-range", + onClick: () => + onValueChange?.({ from: new Date("2024-01-01T00:00:00Z"), to: new Date("2024-01-08T00:00:00Z") }), + }, + "pick", + ), + ); AdvancedDatePicker.displayName = "AdvancedDatePicker"; return { default: AdvancedDatePicker }; }); @@ -333,6 +348,7 @@ describe("UsagePage", () => { const mockUserDailyActivityAggregatedCall = vi.mocked(networking.userDailyActivityAggregatedCall); const mockUserDailyActivityCall = vi.mocked(networking.userDailyActivityCall); const mockTagListCall = vi.mocked(networking.tagListCall); + const mockGatewayDailyActivityCall = vi.mocked(networking.gatewayDailyActivityCall); const mockUseCustomers = vi.mocked(useCustomers); const mockUseAgents = vi.mocked(useAgents); const mockUseAuthorized = vi.mocked(useAuthorized); @@ -476,6 +492,30 @@ describe("UsagePage", () => { }, ]; + // The same session the suite runs as, minus the admin role. Named rather than + // inlined so the test reads as "this session, but not an admin". + const nonAdminSession = { + isLoading: false, + isAuthorized: true, + token: "mock-token", + accessToken: "test-token", + userId: "user-123", + userEmail: "test@example.com", + userRole: "Internal User", + premiumUser: true, + disabledPersonalKeyCreation: false, + showSSOBanner: false, + }; + + // Counts deliberately unlike anything in mockSpendData: the gateway tile must be + // readable as coming from /gateway/daily/activity and from nothing else. + const mockGatewayActivity = { + total_successful_requests: 424242, + total_failed_requests: 909, + by_date: [{ date: "2025-01-01", successful_requests: 424242, failed_requests: 909 }], + by_route: [{ category: "llm", route: "/chat/completions", successful_requests: 424242, failed_requests: 909 }], + }; + const defaultProps = { teams: [ { @@ -522,7 +562,9 @@ describe("UsagePage", () => { mockUserDailyActivityAggregatedCall.mockClear(); mockUserDailyActivityCall.mockClear(); mockTagListCall.mockClear(); + mockGatewayDailyActivityCall.mockClear(); mockUserDailyActivityAggregatedCall.mockResolvedValue(mockSpendData); + mockGatewayDailyActivityCall.mockResolvedValue(mockGatewayActivity); mockUseInfiniteUsers.mockReturnValue({ data: { pages: [ @@ -571,9 +613,80 @@ describe("UsagePage", () => { expect(screen.getByText("1,500")).toBeInTheDocument(); const successfulRequestLabelElements = screen.getAllByText("Successful Requests"); expect(successfulRequestLabelElements.length).toBeGreaterThan(0); - // Use getAllByText since this value appears in multiple places (metrics card + table) - const successfulRequestElements = screen.getAllByText("1,450"); - expect(successfulRequestElements.length).toBeGreaterThan(0); + // Successful and Failed Requests both read the gateway counter, not the + // spend-derived 1,450 / 50 that the same payload carries for the per-key and + // per-model breakdowns. They must share a source, or the tiles contradict the + // endpoint breakdown chart below them. + await waitFor(() => { + expect(screen.getAllByText("424,242").length).toBeGreaterThan(0); + }); + expect(screen.getAllByText("909").length).toBeGreaterThan(0); + expect(screen.queryByText("1,450")).not.toBeInTheDocument(); + }); + + it("should stop showing the previous range's totals while a new range is in flight", async () => { + // The request tiles read the gateway counts and fall through to the + // spend-derived ones. Withholding a superseded gateway result is only worth + // something if the fallback is withheld too, otherwise the tile keeps + // showing the previous range's number by the other route. + let releaseSecondFetch: () => void = () => {}; + mockUserDailyActivityAggregatedCall.mockReset(); + mockUserDailyActivityAggregatedCall.mockResolvedValueOnce(mockSpendData).mockImplementationOnce( + () => + new Promise((resolve) => { + releaseSecondFetch = () => resolve(mockSpendData); + }), + ); + + renderWithProviders(); + await waitFor(() => { + expect(screen.getAllByText("1,500").length).toBeGreaterThan(0); + }); + + await act(async () => { + fireEvent.click(screen.getByTestId("pick-a-different-range")); + }); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledTimes(2); + }); + expect(screen.queryByText("1,500")).not.toBeInTheDocument(); + + await act(async () => { + releaseSecondFetch(); + }); + await waitFor(() => { + expect(screen.getAllByText("1,500").length).toBeGreaterThan(0); + }); + }); + + it("should fall back to the spend-derived count when the gateway endpoint is unavailable", async () => { + mockGatewayDailyActivityCall.mockRejectedValue(new Error("gateway activity unavailable")); + + renderWithProviders(); + + await waitFor(() => { + expect(mockGatewayDailyActivityCall).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(screen.getAllByText("1,450").length).toBeGreaterThan(0); + }); + expect(screen.queryByText("424,242")).not.toBeInTheDocument(); + expect(screen.queryByText("909")).not.toBeInTheDocument(); + expect(screen.queryByTestId("gateway-requests-by-endpoint")).not.toBeInTheDocument(); + }); + + it("should not request deployment-wide gateway counts for a non-admin", async () => { + mockUseAuthorized.mockReturnValue(nonAdminSession); + + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + expect(mockGatewayDailyActivityCall).not.toHaveBeenCalled(); + expect(screen.queryByText("424,242")).not.toBeInTheDocument(); + expect(screen.queryByTestId("gateway-requests-by-endpoint")).not.toBeInTheDocument(); }); it("should display usage metrics and charts", async () => { @@ -605,13 +718,20 @@ describe("UsagePage", () => { expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); }); + // The gateway endpoint breakdown is a separate chart with its own palette, + // so it is excluded rather than allowed to widen the expected fill set. + const spendBars = () => { + const gatewayCard = container.querySelector('[data-testid="gateway-requests-by-endpoint"]'); + return Array.from(container.querySelectorAll("path.recharts-rectangle")).filter( + (rect) => !gatewayCard?.contains(rect), + ); + }; + await waitFor(() => { - expect(container.querySelectorAll("path.recharts-rectangle")).toHaveLength(2); + expect(spendBars()).toHaveLength(2); }); - const fills = new Set( - Array.from(container.querySelectorAll("path.recharts-rectangle")).map((rect) => rect.getAttribute("fill")), - ); + const fills = new Set(spendBars().map((rect) => rect.getAttribute("fill"))); expect(fills).toEqual(new Set(["var(--color-cyan-500, #06b6d4)"])); expect(screen.getAllByText("2025-01-01").length).toBeGreaterThan(0); @@ -916,6 +1036,47 @@ describe("UsagePage", () => { expect(screen.getByText("1,500")).toBeInTheDocument(); }); + it("should stop showing the previous range's paginated pages while a new range is in flight", async () => { + // Same rule as the aggregate, one fallback further down. The flag that + // decides whether these pages are read belongs to the range the failure + // happened on, or the previous range's pages reach the tile through it. + let releaseSecondAggregated: () => void = () => {}; + mockUserDailyActivityAggregatedCall.mockReset(); + mockUserDailyActivityAggregatedCall + .mockRejectedValueOnce(new Error("Aggregated endpoint not available")) + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + releaseSecondAggregated = () => reject(new Error("Aggregated endpoint not available")); + }), + ); + mockUserDailyActivityCall.mockResolvedValue({ + ...mockSpendData, + metadata: { ...mockSpendData.metadata, total_pages: 1, page: 1 }, + }); + + renderWithProviders(); + await waitFor(() => { + expect(screen.getAllByText("1,500").length).toBeGreaterThan(0); + }); + + await act(async () => { + fireEvent.click(screen.getByTestId("pick-a-different-range")); + }); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledTimes(2); + }); + expect(screen.queryByText("1,500")).not.toBeInTheDocument(); + + await act(async () => { + releaseSecondAggregated(); + }); + await waitFor(() => { + expect(screen.getAllByText("1,500").length).toBeGreaterThan(0); + }); + }); + it("should aggregate multiple pages when paginated endpoint has more than 1 page", async () => { mockUserDailyActivityAggregatedCall.mockRejectedValue(new Error("Not available")); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx index 46a17017d39..e73dddd9788 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx @@ -40,6 +40,7 @@ import CloudZeroExportModal from "@/components/cloudzero_export_modal"; import EntityUsageExportModal from "@/components/EntityUsageExport"; import { Team } from "@/components/key_team_helpers/key_list"; import { + gatewayDailyActivityCall, Organization, tagListCall, userDailyActivityAggregatedCall, @@ -53,6 +54,15 @@ import ViewUserSpend from "@/components/view_user_spend"; import { usePaginatedDailyActivity } from "../hooks/usePaginatedDailyActivity"; import { DailyData, KeyMetricWithMetadata, MetricWithMetadata } from "@/components/UsagePage/types"; import { valueFormatterSpend } from "@/components/UsagePage/utils/value_formatters"; +import { + fetchedRangeKey, + selectForRange, + selectGatewayActivity, + topGatewayRoutes, + type FetchedForRange, + type FetchedGatewayActivity, + type GatewayActivity, +} from "./gatewayActivity"; import EndpointUsage from "./EndpointUsage/EndpointUsage"; import EntityUsage, { EntityList } from "./EntityUsage/EntityUsage"; import ModelViewToggle, { ModelViewType } from "./ModelViewToggle"; @@ -69,9 +79,16 @@ interface UsagePageProps { const UsagePage: React.FC = ({ teams, organizations }) => { const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized(); // Aggregated endpoint: try first, fall back to paginated if unavailable - const [aggregatedData, setAggregatedData] = useState<{ results: DailyData[]; metadata: any } | null>(null); - const [aggregatedFailed, setAggregatedFailed] = useState(false); + const [aggregatedData, setAggregatedData] = useState | null>(null); + // Stamped like the data itself: the flag decides whether the paginated + // fallback is read, and a flag left over from the previous range would let + // that fallback's own leftover rows through. + const [aggregatedFailure, setAggregatedFailure] = useState | null>(null); const [aggregatedLoading, setAggregatedLoading] = useState(false); + const [gatewayActivityData, setGatewayActivityData] = useState(null); // Separate loading states for better UX const [isDateChanging, setIsDateChanging] = useState(false); @@ -190,28 +207,65 @@ const UsagePage: React.FC = ({ teams, organizations }) => { }; }, [accessToken, startTime, endTime]); + // Everything the request tiles read is stamped with the range it answers and + // selected during render, rather than cleared in an effect. An effect runs + // after the render that follows a date change, so state cleared there is one + // render too late: that render still holds the previous range's numbers and + // can paint them. One source is not enough, since the tiles read the gateway + // counts, fall through to the aggregate, and fall through again to the + // paginated pages, so a stamp on any one of them is escaped by the next. + const currentAggregatedRangeKey = fetchedRangeKey(startTime, endTime, effectiveUserId); + const currentGatewayRangeKey = fetchedRangeKey(startTime, endTime); + // Try aggregated endpoint first, fall back to paginated on failure const aggregatedFetchIdRef = useRef(0); useEffect(() => { if (!accessToken || !startTime || !endTime) return; const fetchId = ++aggregatedFetchIdRef.current; + const rangeKey = currentAggregatedRangeKey; setAggregatedLoading(true); - setAggregatedFailed(false); - setAggregatedData(null); userDailyActivityAggregatedCall(accessToken, startTime, endTime, effectiveUserId) .then((data) => { if (aggregatedFetchIdRef.current !== fetchId) return; - setAggregatedData(data); + setAggregatedData({ rangeKey, value: data }); setAggregatedLoading(false); setIsDateChanging(false); }) .catch(() => { if (aggregatedFetchIdRef.current !== fetchId) return; - setAggregatedFailed(true); + setAggregatedFailure({ rangeKey, value: true }); setAggregatedLoading(false); }); - }, [accessToken, startTime, endTime, effectiveUserId]); + }, [accessToken, startTime, endTime, effectiveUserId, currentAggregatedRangeKey]); + + // Gateway request counts (SGR). Admin-only: the source table is + // deployment-wide, so a non-admin must not see it. + const gatewayRequest = useMemo( + () => (accessToken && startTime && endTime ? { accessToken, startTime, endTime } : null), + [accessToken, startTime, endTime], + ); + const gatewayFetchIdRef = useRef(0); + useEffect(() => { + if (!isAdmin || !gatewayRequest) return; + const fetchId = ++gatewayFetchIdRef.current; + gatewayDailyActivityCall(gatewayRequest.accessToken, gatewayRequest.startTime, gatewayRequest.endTime) + .then((data) => { + if (gatewayFetchIdRef.current !== fetchId) return; + setGatewayActivityData({ rangeKey: currentGatewayRangeKey, value: data as GatewayActivity }); + }) + .catch(() => { + if (gatewayFetchIdRef.current !== fetchId) return; + setGatewayActivityData(null); + }); + }, [isAdmin, gatewayRequest, currentGatewayRangeKey]); + + const gatewayActivity = selectGatewayActivity(isAdmin, gatewayActivityData, currentGatewayRangeKey); + const activeAggregated = selectForRange(aggregatedData, currentAggregatedRangeKey); + // A failure belongs to the range it happened on. Reading it through the same + // rule keeps the paginated hook disabled while a new range is in flight, and + // disabled is what empties it, so its previous rows never reach a tile. + const aggregatedFailed = selectForRange(aggregatedFailure, currentAggregatedRangeKey) === true; // Paginated fallback — only enabled when aggregated endpoint fails const paginatedResult = usePaginatedDailyActivity({ @@ -222,10 +276,10 @@ const UsagePage: React.FC = ({ teams, organizations }) => { // Derive userSpendData from whichever source is active const userSpendData = useMemo(() => { - if (aggregatedData) return aggregatedData; + if (activeAggregated) return activeAggregated; if (aggregatedFailed) return paginatedResult.data; return { results: [] as DailyData[], metadata: {} as any }; - }, [aggregatedData, aggregatedFailed, paginatedResult.data]); + }, [activeAggregated, aggregatedFailed, paginatedResult.data]); const loading = aggregatedLoading || paginatedResult.loading; @@ -439,6 +493,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { () => [...userSpendData.results].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()), [userSpendData.results], ); + const gatewayRequestsByRoute = useMemo(() => topGatewayRoutes(gatewayActivity), [gatewayActivity]); const modelMetrics = useMemo( () => processActivityData(userSpendData, modelViewType === "groups" ? "model_groups" : "models", teams), [userSpendData, modelViewType, teams], @@ -616,20 +671,47 @@ const UsagePage: React.FC = ({ teams, organizations }) => { - Successful Requests +
+ Successful Requests + {gatewayActivity && ( + + + + )} +
+ {/* + TODO: drop the userSpendData fallback once every deployment + is writing LiteLLM_DailyGatewayRequests. It covers two cases + today: a non-admin (who may not read deployment-wide counts) + and an admin on a proxy whose table is still backfilling. + */} - {userSpendData.metadata?.total_successful_requests?.toLocaleString() || 0} + {( + gatewayActivity?.total_successful_requests ?? + userSpendData.metadata?.total_successful_requests + )?.toLocaleString() || 0}
Failed Requests - +
+ {/* Same source as Successful Requests: the two must agree, or the + tile disagrees with the endpoint breakdown chart below it. */} - {userSpendData.metadata?.total_failed_requests?.toLocaleString() || 0} + {( + gatewayActivity?.total_failed_requests ?? + userSpendData.metadata?.total_failed_requests + )?.toLocaleString() || 0}
@@ -729,6 +811,32 @@ const UsagePage: React.FC = ({ teams, organizations }) => { + {/* Gateway Requests by Endpoint (SGR) */} + {gatewayActivity && gatewayActivity.by_route.length > 0 && ( + + + + + Gateway Requests by Endpoint + + + + + + + value.toLocaleString()} + /> + + + + )} {/* Top API Keys */} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/gatewayActivity.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/gatewayActivity.test.ts new file mode 100644 index 00000000000..75177b98a41 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/gatewayActivity.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { + GATEWAY_TOP_ROUTES, + fetchedRangeKey, + selectForRange, + selectGatewayActivity, + topGatewayRoutes, + type GatewayActivity, +} from "./gatewayActivity"; + +const activity = (total: number): GatewayActivity => ({ + total_successful_requests: total, + total_failed_requests: 0, + by_date: [{ date: "2025-01-01", successful_requests: total, failed_requests: 0 }], + by_route: [{ category: "llm", route: "/chat/completions", successful_requests: total, failed_requests: 0 }], +}); + +const JANUARY = fetchedRangeKey(new Date("2025-01-01T00:00:00Z"), new Date("2025-01-31T00:00:00Z")); +const FEBRUARY = fetchedRangeKey(new Date("2025-02-01T00:00:00Z"), new Date("2025-02-28T00:00:00Z")); + +describe("fetchedRangeKey", () => { + it("distinguishes ranges that differ only in their end", () => { + const start = new Date("2025-01-01T00:00:00Z"); + expect(fetchedRangeKey(start, new Date("2025-01-31T00:00:00Z"))).not.toEqual( + fetchedRangeKey(start, new Date("2025-02-28T00:00:00Z")), + ); + }); + + it("distinguishes the same range fetched for two different users", () => { + const start = new Date("2025-01-01T00:00:00Z"); + const end = new Date("2025-01-31T00:00:00Z"); + expect(fetchedRangeKey(start, end, "user-a")).not.toEqual(fetchedRangeKey(start, end, "user-b")); + }); + + it("is stable for equal instants held in different Date objects", () => { + expect(fetchedRangeKey(new Date("2025-01-01T00:00:00Z"), new Date("2025-01-31T00:00:00Z"))).toEqual(JANUARY); + }); + + it("tolerates a range that has not been picked yet", () => { + expect(fetchedRangeKey(null, null)).toEqual("||"); + }); +}); + +describe("selectForRange", () => { + it("returns the value when it was fetched for the selected range", () => { + expect(selectForRange({ rangeKey: JANUARY, value: 7 }, JANUARY)).toEqual(7); + }); + + it("withholds the previous range's value while a new range is in flight", () => { + expect(selectForRange({ rangeKey: JANUARY, value: 7 }, FEBRUARY)).toBeNull(); + }); + + it("returns null before anything has been fetched", () => { + expect(selectForRange(null, JANUARY)).toBeNull(); + }); +}); + +describe("selectGatewayActivity", () => { + it("returns the counts when an admin's result matches the selected range", () => { + expect(selectGatewayActivity(true, { rangeKey: JANUARY, value: activity(7) }, JANUARY)).toEqual(activity(7)); + }); + + it("withholds the previous range's counts while a new range is in flight", () => { + expect(selectGatewayActivity(true, { rangeKey: JANUARY, value: activity(7) }, FEBRUARY)).toBeNull(); + }); + + it("withholds deployment-wide counts from a non-admin", () => { + expect(selectGatewayActivity(false, { rangeKey: JANUARY, value: activity(7) }, JANUARY)).toBeNull(); + }); + + it("returns null before anything has been fetched", () => { + expect(selectGatewayActivity(true, null, JANUARY)).toBeNull(); + }); +}); + +describe("topGatewayRoutes", () => { + it("leaves an llm route unprefixed and prefixes the others so they stay distinguishable", () => { + const bars = topGatewayRoutes({ + ...activity(0), + by_route: [ + { category: "llm", route: "/chat/completions", successful_requests: 3, failed_requests: 1 }, + { category: "mcp", route: "/tools/call", successful_requests: 2, failed_requests: 0 }, + { category: "a2a", route: "/tools/call", successful_requests: 1, failed_requests: 0 }, + ], + }); + expect(bars.map((bar) => bar.route)).toEqual(["/chat/completions", "mcp/tools/call", "a2a/tools/call"]); + expect(bars[0]).toEqual({ route: "/chat/completions", successful_requests: 3, failed_requests: 1 }); + }); + + it("caps the bars at the top N so a wide deployment stays readable", () => { + const many = Array.from({ length: GATEWAY_TOP_ROUTES + 5 }, (_, i) => ({ + category: "llm", + route: `/route-${i}`, + successful_requests: 100 - i, + failed_requests: 0, + })); + const bars = topGatewayRoutes({ ...activity(0), by_route: many }); + expect(bars).toHaveLength(GATEWAY_TOP_ROUTES); + // The cap keeps the busiest endpoints, which is only true because it slices + // the server's descending order rather than re-sorting. + expect(bars[0].route).toEqual("/route-0"); + expect(bars[GATEWAY_TOP_ROUTES - 1].route).toEqual(`/route-${GATEWAY_TOP_ROUTES - 1}`); + }); + + it("renders no bars when there is nothing to show", () => { + expect(topGatewayRoutes(null)).toEqual([]); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/gatewayActivity.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/gatewayActivity.ts new file mode 100644 index 00000000000..d527e8717f0 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/gatewayActivity.ts @@ -0,0 +1,82 @@ +/** + * Gateway request counts (SGR) from `/gateway/daily/activity`. + * + * Recorded by the proxy's request-metrics middleware rather than derived from + * spend logs, so it counts what the gateway actually answered. Deployment-wide + * with no per-key or per-user dimension, which is why it is admin-only and why + * the per-key and per-model breakdowns on the usage page still come from the + * spend tables. + */ + +export const GATEWAY_TOP_ROUTES = 15; + +export interface GatewayActivity { + total_successful_requests: number; + total_failed_requests: number; + by_date: { date: string; successful_requests: number; failed_requests: number }[]; + by_route: { category: string; route: string; successful_requests: number; failed_requests: number }[]; +} + +/** A fetched result carrying the range key it was fetched for. */ +export interface FetchedForRange { + rangeKey: string; + value: T; +} + +export type FetchedGatewayActivity = FetchedForRange; + +/** Extends Record so it satisfies the chart component's row constraint. */ +export interface GatewayRouteBar extends Record { + route: string; + successful_requests: number; + failed_requests: number; +} + +/** + * Identifies what a result was fetched for: the date range, plus any other + * input that changes the answer. The usage aggregate is scoped to a user, so + * two results covering the same dates still describe different numbers. + */ +export const fetchedRangeKey = ( + startTime: Date | null | undefined, + endTime: Date | null | undefined, + scope: string | null | undefined = null, +): string => `${startTime?.toISOString() ?? ""}|${endTime?.toISOString() ?? ""}|${scope ?? ""}`; + +/** + * The value safe to render right now, or null to fall back. + * + * Clearing the state inside the fetch effect is one render too late: the render + * that follows a date change still holds the previous range's value and can + * paint before effects run. Comparing the stamp during render is what makes a + * superseded range unrepresentable rather than merely brief. + */ +export const selectForRange = (fetched: FetchedForRange | null, currentRangeKey: string): T | null => + fetched != null && fetched.rangeKey === currentRangeKey ? fetched.value : null; + +/** + * As `selectForRange`, and additionally withholds the counts from a non-admin: + * they are deployment-wide, so they are not a non-admin's to read. + */ +export const selectGatewayActivity = ( + isAdmin: boolean, + fetched: FetchedGatewayActivity | null, + currentRangeKey: string, +): GatewayActivity | null => (isAdmin ? selectForRange(fetched, currentRangeKey) : null); + +/** + * Bars for the endpoint breakdown chart, capped so a deployment exercising many + * endpoints does not render an unreadable axis. `by_route` arrives sorted by + * successful_requests descending, so the cap keeps the busiest endpoints. + */ +export const topGatewayRoutes = ( + activity: GatewayActivity | null, + limit: number = GATEWAY_TOP_ROUTES, +): GatewayRouteBar[] => + (activity?.by_route ?? []).slice(0, limit).map((entry) => ({ + // The llm routes are already fully qualified; mcp and a2a routes are not, so + // their category prefix is what keeps "/mcp" apart from "/a2a". + route: entry.category === "llm" ? entry.route : `${entry.category}${entry.route}`, + successful_requests: entry.successful_requests, + failed_requests: entry.failed_requests, + })); diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 4a9a41d1cbe..65e347ede43 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -2471,6 +2471,31 @@ export const userDailyActivityAggregatedCall = async ( } }; +export const gatewayDailyActivityCall = async (accessToken: string, startTime: Date, endTime: Date) => { + /** + * Get gateway request counts (SGR) recorded by the proxy middleware. + * Deployment-wide and admin-only; carries no per-key or per-user dimension. + */ + try { + const formatDate = (date: Date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; + }; + return await apiClient.get(`/gateway/daily/activity`, { + accessToken, + query: { + start_date: formatDate(startTime), + end_date: formatDate(endTime), + }, + }); + } catch (error) { + console.error("Failed to fetch gateway daily activity:", error); + throw error; + } +}; + export const getPossibleUserRoles = async (accessToken: string) => { try { const data = (await apiClient.get(`/user/available_roles`, { accessToken })) as Record< diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 9408a32198a..4cdc14f9ee3 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -4180,6 +4180,29 @@ export interface paths { patch?: never; trace?: never; }; + "/gateway/daily/activity": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Gateway Daily Activity + * @description Successful and failed gateway requests, counted at the ASGI edge. + * + * Deployment-wide: the underlying table has no per-key or per-user dimension, + * so this is admin-only. + */ + get: operations["get_gateway_daily_activity_gateway_daily_activity_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/gemini/{endpoint}": { parameters: { query?: never; @@ -24543,6 +24566,64 @@ export interface components { * @enum {string} */ GUARDRAIL_DEFINITION_LOCATION: "db" | "config"; + /** + * GatewayRequestActivityResponse + * @description Response for GET /gateway/daily/activity. + */ + GatewayRequestActivityResponse: { + /** + * By Date + * @default [] + */ + by_date: components["schemas"]["GatewayRequestDailyEntry"][]; + /** + * By Route + * @default [] + */ + by_route: components["schemas"]["GatewayRequestBreakdownEntry"][]; + /** + * Total Failed Requests + * @default 0 + */ + total_failed_requests: number; + /** + * Total Successful Requests + * @default 0 + */ + total_successful_requests: number; + }; + /** GatewayRequestBreakdownEntry */ + GatewayRequestBreakdownEntry: { + /** Category */ + category: string; + /** + * Failed Requests + * @default 0 + */ + failed_requests: number; + /** Route */ + route: string; + /** + * Successful Requests + * @default 0 + */ + successful_requests: number; + }; + /** GatewayRequestDailyEntry */ + GatewayRequestDailyEntry: { + /** Date */ + date: string; + /** + * Failed Requests + * @default 0 + */ + failed_requests: number; + /** + * Successful Requests + * @default 0 + */ + successful_requests: number; + }; /** GenerateKeyRequest */ GenerateKeyRequest: { /** Access Group Ids */ @@ -41384,6 +41465,40 @@ export interface operations { }; }; }; + get_gateway_daily_activity_gateway_daily_activity_get: { + parameters: { + query?: { + /** @description Start date in YYYY-MM-DD format */ + start_date?: string | null; + /** @description End date in YYYY-MM-DD format */ + end_date?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GatewayRequestActivityResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; gemini_proxy_route_gemini__endpoint__get: { parameters: { query?: never; From b8df48cd7f4439e4076d7ea0f28e47fe3b009836 Mon Sep 17 00:00:00 2001 From: Abhimanyu Kapur <38531241+akapur99@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:48:11 -0700 Subject: [PATCH 05/11] feat(auto-router): let operators replace the LLM classifier's system prompt (#35855) * feat(auto-router): let operators replace the LLM classifier's system prompt The complexity router's LLM classifier has always sent one built-in rubric, so the router could only ever grade difficulty. Operators can now supply their own system prompt, which replaces the rubric outright and repurposes the same tier machinery for whatever taxonomy the prompt defines, data sensitivity being the obvious case. Replacement is total: neither the rubric nor its closing line is appended, since both describe grading difficulty over a "current message" and a prompt grading something else is entitled to contradict them. That closing paragraph is also the classifier's prompt-injection defense, so the config field and the dashboard editor both warn that a replacement omitting it lets a caller ask for a tier and get it. The heuristic fallback still scores complexity, which is meaningless for a repurposed taxonomy, so classifier_fallback now chooses between the heuristic scorer and routing straight to default_model. The default_model path bypasses tier pools, the adaptive bandit, and escalation, because no tier was decided and the point of that fallback is a known destination. It reports itself as default_model_fallback in the spend logs. The dashboard's prompt editor prefills from a new /auto_router/classifier/default_prompt endpoint rather than a copy of the rubric in the frontend, and stores no override when the draft matches the default, so later rubric improvements still reach every router that never customized it. Tier names stay SIMPLE/MEDIUM/COMPLEX/REASONING; a custom prompt redefines what they mean, not what they are called. * fix(complexity-router): don't let the default_model classifier fallback bypass routing plugins * fix(complexity-router): don't pin a session to the default model after a classifier failure * fix(complexity-router): omit the tier from a default-model-fallback routing decision The classifier never answered, so no tier was decided. The record reported the tier whose pool happens to hold default_model, which reads in the spend log and the UI as if the request was classified. Matches how default_fallback already records a route that no tier produced. * fix(proxy): allowlist /auto_router/ on the UI backend component The new GET /auto_router/classifier/default_prompt is a UI-consumed management route, so it belongs on the control plane. Without the prefix it was exposed by neither component and test_gateway_plus_backend_covers_full_app failed. * docs(ui): reword the classifier prompt disclaimer Frames the closing paragraph as a strong recommendation rather than a description of what gets dropped, names prompt injection explicitly, and notes the tier names stay fixed regardless of their display names. * fix(complexity-router): stop logging a fabricated tier on the plugin fallback path The classifier-failed fallback resolves a tier so the routing-plugin pipeline has a pool to filter, but nothing about the request produced that tier. The non-plugin short-circuit already dropped it from the logged decision; the plugin path still reported it, so a spend log claimed a classification the request never received. Record the pool as a plugin-filtered-pool signal instead. Also name the real problem when the resolved tier has no models at all: that raised "No candidate models left after routing-plugin filtering" and sent operators hunting for a policy plugin that never narrowed anything. --- .../model_management_endpoints.py | 74 +++- .../complexity_router/__init__.py | 8 +- .../complexity_router/complexity_router.py | 144 ++++++- .../complexity_router/config.py | 37 ++ .../model_management_endpoints.py | 10 + litellm/types/utils.py | 4 + .../test_model_management_endpoints.py | 82 ++++ .../router_strategy/test_complexity_router.py | 381 +++++++++++++++++- .../add_model/ClassificationMethodConfig.tsx | 99 ++++- ...lassifierPromptEditor.integration.test.tsx | 93 +++++ .../add_model/ClassifierPromptEditor.tsx | 138 +++++++ .../add_model/ComplexityRouterConfig.test.tsx | 87 ++++ .../add_model/ComplexityRouterConfig.tsx | 13 + .../add_model/add_auto_router_tab.tsx | 1 + .../build_complexity_router_config.test.ts | 55 +++ .../build_complexity_router_config.ts | 16 +- .../classifierPromptEditorState.test.ts | 51 +++ .../add_model/classifierPromptEditorState.ts | 37 ++ .../edit_auto_router_modal.test.tsx | 74 ++++ .../edit_auto_router_modal.tsx | 12 +- .../src/components/networking.test.ts | 42 ++ .../src/components/networking.tsx | 27 ++ .../RoutingDecisionCard.test.tsx | 18 + .../LogDetailsDrawer/RoutingDecisionCard.tsx | 2 + ui/litellm-dashboard/src/lib/http/schema.d.ts | 77 +++- 25 files changed, 1547 insertions(+), 35 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/add_model/ClassifierPromptEditor.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/components/add_model/ClassifierPromptEditor.tsx create mode 100644 ui/litellm-dashboard/src/components/add_model/classifierPromptEditorState.test.ts create mode 100644 ui/litellm-dashboard/src/components/add_model/classifierPromptEditorState.ts diff --git a/litellm/proxy/management_endpoints/model_management_endpoints.py b/litellm/proxy/management_endpoints/model_management_endpoints.py index a31687692d3..71407c89813 100644 --- a/litellm/proxy/management_endpoints/model_management_endpoints.py +++ b/litellm/proxy/management_endpoints/model_management_endpoints.py @@ -14,10 +14,11 @@ import asyncio import datetime import json from collections.abc import Mapping, Sequence +from json import JSONDecodeError from typing import Any, Final, Literal, cast from fastapi import APIRouter, Depends, Header, HTTPException, Request, status -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, ValidationError from litellm._logging import verbose_proxy_logger from litellm._uuid import uuid @@ -59,12 +60,19 @@ from litellm.repositories.model_repository import ModelRepository from litellm.repositories.table_repositories import ModelTableRepository from litellm.repositories.team_repository import TeamRepository from litellm.router import Router +from litellm.router_strategy.complexity_router import ( + DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE, + ComplexityRouterConfig, + ComplexityTier, + classification_system_prompt, +) from litellm.router_utils.auto_router_model_naming import ( STRATEGY_ROUTER_PARAM_FIELDS, validate_complexity_router_config_write, validate_strategy_router_model_write, ) from litellm.types.proxy.management_endpoints.model_management_endpoints import ( + AutoRouterClassifierDefaultPromptResponse, UpdateUsefulLinksRequest, ) from litellm.types.router import ( @@ -1760,6 +1768,70 @@ async def update_useful_links( ) +def _labeled_tiers_from_query(tier_labels: str | None) -> tuple[tuple[ComplexityTier, str], ...] | None: + """Resolve the tier_labels query param into the labeled tiers the rubric is built from. + + Validated through ComplexityRouterConfig so the editor prefills what the router would send: the + same field validators that reject a blank, duplicated, or canonical-name-stealing label on the + write path reject it here, rather than this returning a rubric no router could be configured to + use. A malformed value is the caller's error, so it surfaces as a 400. + + None when unset, letting classification_system_prompt apply its own default names. + """ + if not tier_labels: + return None + try: + return ComplexityRouterConfig(tier_labels=json.loads(tier_labels)).labeled_tiers() + except (JSONDecodeError, ValidationError) as e: + raise ProxyException( + message=f"tier_labels must be a JSON object of tier name to display name: {e}", + type=ProxyErrorTypes.bad_request_error, + code=status.HTTP_400_BAD_REQUEST, + param="tier_labels", + ) from e + + +@router.get( + "/auto_router/classifier/default_prompt", + description="Get the built-in system prompt used by an auto-router's LLM classifier", + tags=["model management"], # mutable-ok: fastapi's decorator signature types tags as a list + dependencies=[Depends(user_api_key_auth)], # mutable-ok: fastapi's decorator signature types dependencies as a list +) +async def get_auto_router_classifier_default_prompt( + context_window_size: int = DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE, + tier_labels: str | None = None, +) -> AutoRouterClassifierDefaultPromptResponse: + """ + Get the default classifier system prompt, so the dashboard's prompt editor can prefill it. + + The prompt's closing line depends on whether prior conversation turns are quoted to the + classifier, and its tier bullets are named by the router's tier_labels, so the caller passes both + to get the text that router would actually send rather than a rubric it does not use. + + Parameters: + - context_window_size: int - The router's classifier_context_window_size. Defaults to the + built-in default. + - tier_labels: str | None - The router's tier_labels as a JSON object of canonical tier name to + display name, e.g. `{"SIMPLE": "Cheap"}`. Omit or pass an empty object for the default names. + """ + if context_window_size < 0: + raise ProxyException( + message="context_window_size must be non-negative", + type=ProxyErrorTypes.bad_request_error, + code=status.HTTP_400_BAD_REQUEST, + param="context_window_size", + ) + + labeled_tiers: Final = _labeled_tiers_from_query(tier_labels) + return AutoRouterClassifierDefaultPromptResponse( + system_prompt=( + classification_system_prompt(context_window_size) + if labeled_tiers is None + else classification_system_prompt(context_window_size, labeled_tiers=labeled_tiers) + ) + ) + + def _deduplicate_litellm_router_models(models: list[dict]) -> list[dict]: """ Deduplicate models based on their model_info.id field. diff --git a/litellm/router_strategy/complexity_router/__init__.py b/litellm/router_strategy/complexity_router/__init__.py index 98f6ce399a8..1830ff506e9 100644 --- a/litellm/router_strategy/complexity_router/__init__.py +++ b/litellm/router_strategy/complexity_router/__init__.py @@ -7,16 +7,22 @@ to classify requests by complexity and route them to appropriate models. No external API calls - all scoring is local and <1ms. """ -from litellm.router_strategy.complexity_router.complexity_router import ComplexityRouter +from litellm.router_strategy.complexity_router.complexity_router import ( + ComplexityRouter, + classification_system_prompt, +) from litellm.router_strategy.complexity_router.config import ( + DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE, DEFAULT_COMPLEXITY_CONFIG, ComplexityRouterConfig, ComplexityTier, ) __all__ = [ + "DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE", "DEFAULT_COMPLEXITY_CONFIG", "ComplexityRouter", "ComplexityRouterConfig", "ComplexityTier", + "classification_system_prompt", ] diff --git a/litellm/router_strategy/complexity_router/complexity_router.py b/litellm/router_strategy/complexity_router/complexity_router.py index 642f60644ba..06b2fb53cb5 100644 --- a/litellm/router_strategy/complexity_router/complexity_router.py +++ b/litellm/router_strategy/complexity_router/complexity_router.py @@ -129,8 +129,9 @@ _CLASSIFICATION_CURRENT_MESSAGE_ONLY: Final = ( _CLASSIFICATION_WITH_CONVERSATION = """Classify the current message, using the earlier turns quoted above it as context: when it is a short reply such as "yes" or "continue", rate the work it approves rather than the reply itself.""" -def _classification_system_prompt( +def classification_system_prompt( context_window_size: int, + custom_prompt: str | None = None, labeled_tiers: Sequence[tuple[ComplexityTier, str]] = TIER_SEVERITY_ORDER_LABELED, ) -> str: """The classifier's system role, closing on the line that matches the payload it will be sent. @@ -144,7 +145,21 @@ def _classification_system_prompt( It keys on the operator's configuration and never on the individual request, so the system role stays prompt-cacheable across a session, and it does not key on which roles the window holds: that the turns exist is what the model needs told, and whose they are is already on the turns. + + A custom prompt is returned verbatim, with neither the rubric nor a closing line appended. Both + describe grading difficulty over a "current message", which an operator classifying something else + is entitled to contradict: appending either would have the system role argue with itself, and the + closing line in particular would name sections a replacement prompt need not lay out that way. The + injection-defense sentence goes with the rubric it belongs to, so a replacement that wants it must + say so itself; the config field and the UI editor both warn about exactly that. + + `labeled_tiers` therefore only reaches the built-in rubric. A custom prompt names the tiers itself, + so renaming them cannot edit prose the operator wrote, and it is the operator's job to use their own + labels. The response format's enum is built from those same labels either way, so a custom prompt + still has to return them, whatever it calls the tiers in its own text. """ + if custom_prompt is not None: + return custom_prompt closing = _CLASSIFICATION_WITH_CONVERSATION if context_window_size > 0 else _CLASSIFICATION_CURRENT_MESSAGE_ONLY return f"{_classification_system_rubric(labeled_tiers)} {closing}" @@ -412,6 +427,16 @@ def _extract_prior_turns( return tuple((role, _truncate(text, per_turn_chars)) for role, text in reversed(tuple(prior))) +def _decision_is_pinnable(decision: StandardLoggingRoutingDecision | None) -> bool: + """Whether a first-turn decision is worth pinning for the rest of the session. + + A classifier that timed out did not decide anything, so pinning where its fallback landed + would let one transient failure hold the session on default_model for the whole TTL. Those + turns stay unpinned and the next one classifies again. + """ + return decision is None or decision.get("cause") != "default_model_fallback" + + class DimensionScore: """Represents a score for a single dimension with optional signal.""" @@ -434,14 +459,15 @@ class ClassificationOutcome(NamedTuple): """What the classifier decided and which mechanism actually produced it. `cause` reflects the path that ran, not the configured classifier_type: an LLM - classifier that fails falls back to the heuristic scorer and reports it. - `score` is None on the LLM path, which produces a tier label and no score. + classifier that fails falls back to whichever path classifier_fallback names and + reports that one. `score` is None on the LLM path, which produces a tier label and + no score, and on the default_model path, which produces neither. """ tier: ComplexityTier score: float | None signals: tuple[str, ...] - cause: Literal["heuristic_scorer", "reasoning_override", "llm_classifier"] + cause: Literal["heuristic_scorer", "reasoning_override", "llm_classifier", "default_model_fallback"] class ComplexityRouter(CustomLogger): @@ -493,6 +519,17 @@ class ComplexityRouter(CustomLogger): if default_model: self.config.default_model = default_model + # Checked here rather than on the config model because the deployment's + # complexity_router_default_model arrives outside complexity_router_config and is + # applied just above, so a validator on the model would reject a deployment that + # does have a default model, just not in that dict. + if self.config.classifier_fallback == "default_model" and not self.config.default_model: + raise ValueError( + "classifier_fallback='default_model' requires a default model: set " + "complexity_router_default_model on the deployment or default_model in " + "complexity_router_config" + ) + # Build effective keyword lists (use config overrides or defaults) self.code_keywords = self.config.code_keywords or DEFAULT_CODE_KEYWORDS self.reasoning_keywords = self.config.reasoning_keywords or DEFAULT_REASONING_KEYWORDS @@ -846,9 +883,9 @@ class ComplexityRouter(CustomLogger): """ Classify a prompt by complexity, using the LLM classifier when configured. - Falls back to the local heuristic scorer if classifier_type is "heuristic", - or if the LLM call fails, times out, or returns an unparseable response. - The outcome's `cause` reports which path actually classified the request. + Falls back to the local heuristic scorer if classifier_type is "heuristic". If the LLM call + fails, times out, or returns an unparseable response, classifier_fallback decides between the + heuristic scorer and default_model. The outcome's `cause` reports which path actually ran. """ if self.config.classifier_type != "llm" or self.config.classifier_llm_config is None: tier, score, signals, cause = self._score_and_classify(prompt, system_prompt) @@ -859,13 +896,44 @@ class ComplexityRouter(CustomLogger): return ClassificationOutcome( tier=tier, score=None, signals=(f"llm-classifier:{tier.value}",), cause="llm_classifier" ) - except Exception as e: # noqa: BLE001 -- external LLM call can fail in many distinct ways (timeout, provider error, validation, parse error); any failure must fall back to the heuristic scorer + except Exception as e: # noqa: BLE001 -- external LLM call can fail in many distinct ways (timeout, provider error, validation, parse error); any failure must fall back to the configured fallback path verbose_router_logger.warning( - "ComplexityRouter: LLM classifier failed (%s), falling back to heuristic scoring", e + "ComplexityRouter: LLM classifier failed (%s), falling back to %s", + e, + self.config.classifier_fallback, ) + if self.config.classifier_fallback == "default_model": + return self._default_model_fallback_outcome() tier, score, signals, cause = self._score_and_classify(prompt, system_prompt) return ClassificationOutcome(tier=tier, score=score, signals=signals, cause=cause) + def _default_model_fallback_outcome(self) -> ClassificationOutcome: + """The classifier-failed outcome for classifier_fallback='default_model'. + + The outcome still carries a tier because ClassificationOutcome requires one, so it reports + the tier whose pool holds default_model, and MEDIUM when no pool does. Nothing about the + request produced that tier, so the pre-routing hook never logs it as the request's tier: it + routes this cause straight to default_model rather than picking from the tier's pool, since + a pool with several models would otherwise land somewhere else and the point of this + fallback is a known destination when classification failed. + + On a router with routing plugins the hook does not short-circuit, because default_model was + never checked against the plugin pipeline and routing to it directly would let a failed + classifier bypass a policy plugin. There the tier is load-bearing, but only as the pool the + plugins filter: resolving it to default_model's own pool keeps the destination as close to + the configured one as a plugin-filtered pick allows, and the hook records it as a + plugin-filtered-pool signal rather than as a classification the request never received. + """ + default_model: Final = self.config.default_model + pools: Final = self._tier_pools() + tier: Final = next( + (candidate for candidate in TIER_SEVERITY_ORDER if default_model in pools.get(candidate.value, ())), + ComplexityTier.MEDIUM, + ) + return ClassificationOutcome( + tier=tier, score=None, signals=("classifier-failed:default-model",), cause="default_model_fallback" + ) + async def _classify_with_llm( self, prompt: str, @@ -937,8 +1005,10 @@ class ComplexityRouter(CustomLogger): messages_for_call: Final = [ { "role": "system", - "content": _classification_system_prompt( - self.config.classifier_context_window_size, labeled_tiers=labeled_tiers + "content": classification_system_prompt( + self.config.classifier_context_window_size, + llm_config.system_prompt, + labeled_tiers=labeled_tiers, ), }, {"role": "user", "content": user_payload}, @@ -1083,10 +1153,16 @@ class ComplexityRouter(CustomLogger): tier_key: Final = tier.value metadata_key: Final = "litellm_metadata" if "litellm_metadata" in request_kwargs else "metadata" + pool: Final = tuple(self._tier_pools().get(tier_key, ())) + if not pool: + # Nothing for the plugins to filter. Falling through would raise the + # plugin-filtering error below and send the operator hunting for a policy + # plugin that never ran, so name the real problem: the tier has no models. + raise ValueError(f"No models configured for tier {tier_key}") context = RoutingContext( raw_messages=raw_messages or [], structured_messages=resolved_messages or [], - candidate_models=list(self._tier_pools().get(tier_key, [])), + candidate_models=list(pool), metadata=request_kwargs.get(metadata_key) or {}, ) for plugin in self.config.plugins: @@ -1624,7 +1700,7 @@ class ComplexityRouter(CustomLogger): conversation_continuing=conversation_continuing, resolved_messages=resolved_messages, ) - if cache_key is not None and response is not None: + if cache_key is not None and response is not None and _decision_is_pinnable(response.routing_decision): await self.litellm_router_instance.cache.async_set_cache( key=cache_key, value=response.model, @@ -1739,6 +1815,35 @@ class ComplexityRouter(CustomLogger): if escalated: signals = (*signals, "escalation") score_repr: Final = f"{score:.3f}" if score is not None else "n/a" + fallback_model: Final = self.config.default_model if not self.config.plugins else None + if outcome.cause == "default_model_fallback" and fallback_model is not None: + # Classification failed and the operator asked for default_model, so route there + # directly. Neither the tier pool nor the adaptive bandit gets a say: both answer + # "which model suits this tier", and no tier was decided. Escalation is skipped for + # the same reason, since there is no classified tier to bump away from. + # + # Skipped when plugins are configured, matching the no-user-message path above: + # default_model is never checked against the plugin pipeline, so routing to it + # here would let a failed classifier silently bypass a policy plugin. Those + # routers fall through to the tier pool below, which does run the plugins. + verbose_router_logger.info( + "ComplexityRouter: routing decision cause=%s, tier=n/a, score=n/a, signals=%s, routed_model=%s", + outcome.cause, + outcome.signals, + fallback_model, + ) + return PreRoutingHookResponse( + model=fallback_model, + messages=messages if has_original_messages else None, + routing_decision=self._build_routing_decision( + routed_model=fallback_model, + conversation_continuing=conversation_continuing, + cause=outcome.cause, + signals=outcome.signals, + escalation_keyword=escalation_keyword, + escalated=False, + ), + ) if self.config.adaptive: routed_model = self._soft_floor_pick(tier, user_message, request_kwargs) adaptive: Final = self._ensure_adaptive_router() @@ -1771,6 +1876,15 @@ class ComplexityRouter(CustomLogger): if outcome.cause == "llm_classifier" and self.config.classifier_llm_config is not None else None ) + # cause=default_model_fallback means no tier was decided: the classifier failed and the + # operator asked for default_model. Only the plugin path reaches here (the non-plugin one + # short-circuited above), and there `tier` exists solely to name a pool for the plugins to + # filter. Reporting it as the request's tier would attribute a classification to a request + # that never got one, so the record names the pool in its signals instead. + classified_pool_tier: Final = None if outcome.cause == "default_model_fallback" else tier + decision_signals: Final = ( + (*signals, f"plugin-filtered-pool:{tier.value}") if outcome.cause == "default_model_fallback" else signals + ) return PreRoutingHookResponse( model=routed_model, messages=messages if has_original_messages else None, @@ -1778,9 +1892,9 @@ class ComplexityRouter(CustomLogger): routed_model=routed_model, conversation_continuing=conversation_continuing, cause=outcome.cause, - tier=tier, + tier=classified_pool_tier, score=score, - signals=signals, + signals=decision_signals, escalation_keyword=escalation_keyword, escalated=escalated, classifier_model=classifier_model, diff --git a/litellm/router_strategy/complexity_router/config.py b/litellm/router_strategy/complexity_router/config.py index 719637c48b9..f9d3bd9ae67 100644 --- a/litellm/router_strategy/complexity_router/config.py +++ b/litellm/router_strategy/complexity_router/config.py @@ -249,6 +249,30 @@ class ClassifierLLMConfig(BaseModel): default=3000, description="Timeout budget for the classification call, in milliseconds", ) + system_prompt: str | None = Field( + default=None, + description=( + "Replaces the built-in complexity rubric as the classifier's entire system role. When set, " + "neither the default rubric nor the context-window closing line is appended, so the prompt " + "owns the whole taxonomy and the tier names SIMPLE/MEDIUM/COMPLEX/REASONING become whatever " + "buckets it defines: a prompt that classifies data sensitivity routes on that instead of on " + "difficulty. Two consequences of full replacement. The default rubric's closing paragraph is " + "the classifier's prompt-injection defense, telling it that the caller's quoted system prompt " + "and prior turns are material to judge and never instructions; a replacement that omits it " + "lets a caller ask for a tier and get it. And the heuristic fallback still scores complexity, " + "so a router on some other taxonomy wants classifier_fallback='default_model'. Leave unset " + "for the built-in rubric. Only applies when classifier_type is 'llm'." + ), + ) + + @field_validator("system_prompt") + @classmethod + def _reject_blank_system_prompt(cls, value: str | None) -> str | None: + # A blank string is a misconfiguration, not a request for the default: it would send an + # empty system role and leave the classifier with no rubric at all. None means default. + if value is not None and not value.strip(): + raise ValueError("classifier_llm_config.system_prompt must be non-empty; omit it to use the default rubric") + return value class ComplexityRouterConfig(BaseModel): @@ -347,6 +371,19 @@ class ComplexityRouterConfig(BaseModel): description="Configuration for the LLM classifier; required when classifier_type is 'llm'", ) + classifier_fallback: Literal["heuristic", "default_model"] = Field( + default="heuristic", + description=( + "What classifies the request when the LLM classifier errors, times out, or returns an " + "unparseable response. 'heuristic' runs the local complexity scorer, which is right when the " + "classifier grades complexity too. 'default_model' skips scoring and routes to default_model, " + "which is what a classifier on some other taxonomy wants: a prompt that grades data " + "sensitivity has no use for a complexity score, and scoring one produces a tier unrelated to " + "what the operator configured. Requires default_model when set to 'default_model'. Only " + "applies when classifier_type is 'llm'." + ), + ) + classifier_context_window_size: int = Field( default=DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE, ge=0, diff --git a/litellm/types/proxy/management_endpoints/model_management_endpoints.py b/litellm/types/proxy/management_endpoints/model_management_endpoints.py index 1366c62c75f..6e18787a224 100644 --- a/litellm/types/proxy/management_endpoints/model_management_endpoints.py +++ b/litellm/types/proxy/management_endpoints/model_management_endpoints.py @@ -19,6 +19,16 @@ class UpdateUsefulLinksRequest(BaseModel): useful_links: dict[str, str | dict[str, Any]] +class AutoRouterClassifierDefaultPromptResponse(BaseModel): + """The built-in system prompt an auto-router's LLM classifier uses when none is configured. + + Served so the dashboard's prompt editor prefills the rubric the proxy actually sends, rather than + a copy in the frontend that drifts the moment the rubric is edited. + """ + + system_prompt: str + + class NewModelGroupRequest(BaseModel): access_group: str # The access group name (e.g., "production-models") model_names: list[str] | None = None # Existing model groups to include - tags ALL deployments for each name diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 0d34ca21cef..8371f98222b 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -2764,6 +2764,10 @@ RoutingDecisionCause = Literal[ # meant anything that filtered `signals` silently changed what the row claimed. "reasoning_override", "llm_classifier", + # The LLM classifier failed and classifier_fallback is 'default_model', so the request + # went to default_model without being classified. Distinct from "default_fallback", + # which is a tier having no model configured rather than classification not happening. + "default_model_fallback", "literal_keyword_match", "semantic_keyword_match", "session_affinity_pin", diff --git a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py index 95405a3b016..454849d6430 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py @@ -3743,3 +3743,85 @@ class TestStrategyRouterWriteValidation: ) assert "does not start with" in str(exc_info.value.message) mock_prisma.db.litellm_proxymodeltable.update.assert_not_awaited() + + +class TestAutoRouterClassifierDefaultPrompt: + """The dashboard's prompt editor prefills from this endpoint, so it must serve the rubric the + router actually sends rather than a frontend copy that drifts.""" + + @pytest.mark.asyncio + async def test_returns_the_prompt_the_router_would_send(self): + from litellm.proxy.management_endpoints.model_management_endpoints import ( + get_auto_router_classifier_default_prompt, + ) + from litellm.router_strategy.complexity_router import classification_system_prompt + + response = await get_auto_router_classifier_default_prompt(context_window_size=5) + assert response.system_prompt == classification_system_prompt(5) + assert "Tiers:" in response.system_prompt + + @pytest.mark.asyncio + async def test_context_window_size_changes_the_closing_line(self): + """The editor must prefill the prompt matching the configured window, not a fixed one.""" + from litellm.proxy.management_endpoints.model_management_endpoints import ( + get_auto_router_classifier_default_prompt, + ) + + with_conversation = await get_auto_router_classifier_default_prompt(context_window_size=5) + single_message = await get_auto_router_classifier_default_prompt(context_window_size=0) + assert with_conversation.system_prompt != single_message.system_prompt + assert "earlier turns" in with_conversation.system_prompt + assert "earlier turns" not in single_message.system_prompt + + @pytest.mark.asyncio + async def test_negative_context_window_size_is_rejected(self): + from litellm.proxy._types import ProxyException + from litellm.proxy.management_endpoints.model_management_endpoints import ( + get_auto_router_classifier_default_prompt, + ) + + with pytest.raises(ProxyException) as exc_info: + await get_auto_router_classifier_default_prompt(context_window_size=-1) + assert "non-negative" in str(exc_info.value.message) + + @pytest.mark.asyncio + async def test_renamed_tiers_prefill_the_rubric_the_router_actually_sends(self): + """A router with tier_labels sends a rubric naming those labels, and the classifier must + return them, so prefilling the canonical names would hand the operator a prompt whose tier + names their router rejects.""" + from litellm.proxy.management_endpoints.model_management_endpoints import ( + get_auto_router_classifier_default_prompt, + ) + + renamed = await get_auto_router_classifier_default_prompt( + context_window_size=5, tier_labels='{"SIMPLE": "Cheap", "REASONING": "Deep"}' + ) + assert "- Cheap:" in renamed.system_prompt + assert "- Deep:" in renamed.system_prompt + assert "- SIMPLE:" not in renamed.system_prompt + assert "- MEDIUM:" in renamed.system_prompt + + @pytest.mark.asyncio + async def test_malformed_tier_labels_are_rejected_rather_than_silently_ignored(self): + """An unparseable or invalid rename must not fall back to the canonical rubric: that would + prefill tier names the router does not accept while looking like it worked.""" + from litellm.proxy._types import ProxyException + from litellm.proxy.management_endpoints.model_management_endpoints import ( + get_auto_router_classifier_default_prompt, + ) + + for bad in ("not-json", '{"SIMPLE": " "}', '{"SIMPLE": "MEDIUM"}', '{"SIMPLE": "X", "MEDIUM": "X"}'): + with pytest.raises(ProxyException) as exc_info: + await get_auto_router_classifier_default_prompt(context_window_size=5, tier_labels=bad) + assert "tier_labels" in str(exc_info.value.message) + + @pytest.mark.asyncio + async def test_omitted_tier_labels_are_byte_identical_to_the_default_rubric(self): + from litellm.proxy.management_endpoints.model_management_endpoints import ( + get_auto_router_classifier_default_prompt, + ) + from litellm.router_strategy.complexity_router import classification_system_prompt + + for empty in (None, "", "{}"): + response = await get_auto_router_classifier_default_prompt(context_window_size=5, tier_labels=empty) + assert response.system_prompt == classification_system_prompt(5) diff --git a/tests/test_litellm/router_strategy/test_complexity_router.py b/tests/test_litellm/router_strategy/test_complexity_router.py index 2b7d3b3e20d..ff2d0aec39a 100644 --- a/tests/test_litellm/router_strategy/test_complexity_router.py +++ b/tests/test_litellm/router_strategy/test_complexity_router.py @@ -22,9 +22,14 @@ from litellm._logging import verbose_router_logger from litellm.caching.dual_cache import DualCache from litellm.constants import RETURN_RAW_MODEL_NAME_METADATA_KEY from litellm.router_strategy.complexity_router.complexity_router import ( + _CLASSIFICATION_CURRENT_MESSAGE_ONLY, + _CLASSIFICATION_WITH_CONVERSATION, + TIER_SEVERITY_ORDER_LABELED, ComplexityRouter, DimensionScore, KeywordOverride, + _classification_system_rubric, + classification_system_prompt, ) from litellm.router_strategy.complexity_router.config import ( DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE, @@ -5279,7 +5284,7 @@ class TestClassifierTrustBoundary: how the LLM-as-a-judge guardrail assembles its call: a static system constant, all caller content quoted in the user turn. """ - from litellm.router_strategy.complexity_router.complexity_router import _classification_system_prompt + from litellm.router_strategy.complexity_router.complexity_router import classification_system_prompt router = ComplexityRouter( model_name="test-router", @@ -5300,7 +5305,7 @@ class TestClassifierTrustBoundary: ) system_message, user_message = mock_router_instance.acompletion.call_args.kwargs["messages"] - assert system_message["content"] == _classification_system_prompt(router.config.classifier_context_window_size) + assert system_message["content"] == classification_system_prompt(router.config.classifier_context_window_size) assert hostile not in system_message["content"] assert hostile in user_message["content"] @@ -5322,9 +5327,9 @@ class TestClassifierTrustBoundary: invites it to guess high. Above 0 the window is quoted but nothing otherwise tells the model it exists or that its view is bounded. """ - from litellm.router_strategy.complexity_router.complexity_router import _classification_system_prompt + from litellm.router_strategy.complexity_router.complexity_router import classification_system_prompt - system_prompt = _classification_system_prompt(window_size) + system_prompt = classification_system_prompt(window_size) assert ("using the earlier turns quoted above it as context" in system_prompt) is conversation_is_quoted assert ('short reply such as "yes" or "continue"' in system_prompt) is conversation_is_quoted @@ -5341,7 +5346,7 @@ class TestClassifierTrustBoundary: pre-context sentence, which is the exact configuration the reported misclassification was raised against: window at its default, assistant turns off. """ - from litellm.router_strategy.complexity_router.complexity_router import _classification_system_prompt + from litellm.router_strategy.complexity_router.complexity_router import classification_system_prompt router = ComplexityRouter( model_name="test-complexity-router", @@ -5356,7 +5361,7 @@ class TestClassifierTrustBoundary: await router.aclassify("yes.", messages=[{"role": "user", "content": "yes."}]) system_content = mock_router_instance.acompletion.call_args.kwargs["messages"][0]["content"] - assert system_content == _classification_system_prompt(DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE) + assert system_content == classification_system_prompt(DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE) def test_a_window_of_zero_still_sends_the_original_wording(self): """With no conversation quoted, the original line is the correct one and must stay reachable. @@ -5365,9 +5370,9 @@ class TestClassifierTrustBoundary: was handed a window and told in the same breath to disregard it, so a request whose difficulty was established earlier came back SIMPLE on the word "yes". """ - from litellm.router_strategy.complexity_router.complexity_router import _classification_system_prompt + from litellm.router_strategy.complexity_router.complexity_router import classification_system_prompt - assert _classification_system_prompt(0).endswith( + assert classification_system_prompt(0).endswith( "Classify only the current message; use the other sections to disambiguate its difficulty." ) @@ -5379,9 +5384,9 @@ class TestClassifierTrustBoundary: the model to disregard buys nothing, so the replacement is pinned here rather than left to be rediscovered. """ - from litellm.router_strategy.complexity_router.complexity_router import _classification_system_prompt + from litellm.router_strategy.complexity_router.complexity_router import classification_system_prompt - system_prompt = _classification_system_prompt(DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE) + system_prompt = classification_system_prompt(DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE) assert "Classify only the current message" not in system_prompt assert "using the earlier turns quoted above it as context" in system_prompt @@ -5534,6 +5539,362 @@ class TestConversationShapeDiscriminator: assert not missing, f"routing decisions {missing} do not carry the conversation shape" +class TestCustomClassifierSystemPrompt: + """An operator-supplied classifier prompt replaces the built-in rubric entirely.""" + + def test_default_prompt_carries_rubric_and_conversation_closing(self): + prompt = classification_system_prompt(5) + assert _classification_system_rubric(TIER_SEVERITY_ORDER_LABELED) in prompt + assert _CLASSIFICATION_WITH_CONVERSATION in prompt + assert _CLASSIFICATION_CURRENT_MESSAGE_ONLY not in prompt + + def test_default_prompt_uses_single_message_closing_without_context_window(self): + prompt = classification_system_prompt(0) + assert _classification_system_rubric(TIER_SEVERITY_ORDER_LABELED) in prompt + assert _CLASSIFICATION_CURRENT_MESSAGE_ONLY in prompt + assert _CLASSIFICATION_WITH_CONVERSATION not in prompt + + def test_explicit_none_is_byte_identical_to_omitting_the_argument(self): + assert classification_system_prompt(5, None) == classification_system_prompt(5) + + @pytest.mark.parametrize("context_window_size", [0, 5]) + def test_custom_prompt_replaces_rubric_and_closing_at_any_window_size(self, context_window_size): + """Full replacement: neither the rubric nor either closing line may be appended, or the + system role would argue with itself about what it is grading.""" + custom = "Grade the data sensitivity of the request." + prompt = classification_system_prompt(context_window_size, custom) + assert prompt == custom + assert _classification_system_rubric(TIER_SEVERITY_ORDER_LABELED) not in prompt + assert _CLASSIFICATION_WITH_CONVERSATION not in prompt + assert _CLASSIFICATION_CURRENT_MESSAGE_ONLY not in prompt + + @pytest.mark.parametrize("blank", ["", " ", "\n\t "]) + def test_blank_system_prompt_is_rejected(self, blank): + """A blank string would send an empty system role, leaving the classifier no rubric at + all; omitting the field is how you ask for the default.""" + with pytest.raises(ValidationError): + ComplexityRouterConfig( + classifier_type="llm", + classifier_llm_config={"model": "haiku-classifier", "timeout_ms": 400, "system_prompt": blank}, + ) + + def test_unset_system_prompt_defaults_to_none(self): + config = ComplexityRouterConfig( + classifier_type="llm", classifier_llm_config={"model": "haiku-classifier", "timeout_ms": 400} + ) + assert config.classifier_llm_config is not None + assert config.classifier_llm_config.system_prompt is None + + @pytest.mark.asyncio + async def test_custom_prompt_is_sent_verbatim_as_the_system_role(self, mock_router_instance, llm_classifier_config): + custom = "Classify the data sensitivity: SIMPLE=public, MEDIUM=internal, COMPLEX=confidential, REASONING=regulated." + router = ComplexityRouter( + model_name="test-complexity-router", + litellm_router_instance=mock_router_instance, + complexity_router_config={ + **llm_classifier_config, + "classifier_llm_config": { + **llm_classifier_config["classifier_llm_config"], + "system_prompt": custom, + }, + }, + ) + mock_router_instance.acompletion = AsyncMock(return_value=_llm_response('{"tier": "COMPLEX"}')) + outcome = await router.aclassify("my ssn is 000-00-0000") + assert outcome.tier == ComplexityTier.COMPLEX + messages = mock_router_instance.acompletion.call_args.kwargs["messages"] + assert messages[0] == {"role": "system", "content": custom} + assert "Tiers:" not in messages[0]["content"] + # The user role still carries the request being classified. + assert "000-00-0000" in messages[1]["content"] + + @pytest.mark.asyncio + async def test_a_prompt_that_invents_tier_names_falls_back_instead_of_raising( + self, mock_router_instance, llm_classifier_config + ): + """The most likely custom-prompt mistake: renaming the buckets. The four names are pinned by + the structured-output schema, so an off-schema tier has to land on the configured fallback + rather than escaping as an exception to the caller's request.""" + router = ComplexityRouter( + model_name="test-complexity-router", + litellm_router_instance=mock_router_instance, + complexity_router_config={ + **llm_classifier_config, + "classifier_llm_config": { + **llm_classifier_config["classifier_llm_config"], + "system_prompt": "Answer with PUBLIC, INTERNAL, or SECRET.", + }, + "classifier_fallback": "default_model", + "default_model": "gpt-4o", + }, + ) + mock_router_instance.acompletion = AsyncMock(return_value=_llm_response('{"tier": "SECRET"}')) + outcome = await router.aclassify("my ssn is 000-00-0000") + assert outcome.cause == "default_model_fallback" + + @pytest.mark.asyncio + async def test_no_custom_prompt_keeps_the_built_in_rubric_on_the_wire( + self, llm_complexity_router, mock_router_instance + ): + mock_router_instance.acompletion = AsyncMock(return_value=_llm_response('{"tier": "SIMPLE"}')) + await llm_complexity_router.aclassify("hi") + messages = mock_router_instance.acompletion.call_args.kwargs["messages"] + assert messages[0]["content"] == classification_system_prompt( + llm_complexity_router.config.classifier_context_window_size + ) + + +class TestClassifierFallbackChoice: + """classifier_fallback decides what runs when the LLM classifier fails.""" + + @pytest.fixture + def default_model_fallback_router(self, mock_router_instance, llm_classifier_config): + return ComplexityRouter( + model_name="test-complexity-router", + litellm_router_instance=mock_router_instance, + complexity_router_config={ + **llm_classifier_config, + "classifier_fallback": "default_model", + "default_model": "gpt-4o", + }, + ) + + def test_fallback_defaults_to_heuristic(self): + assert ComplexityRouterConfig().classifier_fallback == "heuristic" + + def test_default_model_fallback_requires_a_default_model(self, mock_router_instance, llm_classifier_config): + """Without one there is nowhere to route, so this must fail at config time rather than + at the first classifier timeout in production.""" + with pytest.raises(ValueError, match="requires a default model"): + ComplexityRouter( + model_name="test-complexity-router", + litellm_router_instance=mock_router_instance, + complexity_router_config={**llm_classifier_config, "classifier_fallback": "default_model"}, + ) + + def test_deployment_level_default_model_satisfies_the_requirement( + self, mock_router_instance, llm_classifier_config + ): + """complexity_router_default_model arrives outside complexity_router_config, so a config-model + validator would have rejected this valid deployment.""" + router = ComplexityRouter( + model_name="test-complexity-router", + litellm_router_instance=mock_router_instance, + complexity_router_config={**llm_classifier_config, "classifier_fallback": "default_model"}, + default_model="gpt-4o", + ) + assert router.config.default_model == "gpt-4o" + + @pytest.mark.asyncio + async def test_classifier_failure_routes_to_default_model_without_scoring( + self, default_model_fallback_router, mock_router_instance + ): + """A classifier on some other taxonomy has no use for a complexity score, so the heuristic + scorer must not run at all.""" + mock_router_instance.acompletion = AsyncMock(side_effect=TimeoutError("classifier timed out")) + with patch.object( + ComplexityRouter, "_score_and_classify", side_effect=AssertionError("heuristic scorer must not run") + ): + outcome = await default_model_fallback_router.aclassify("Hello!") + assert outcome.cause == "default_model_fallback" + assert outcome.score is None + + @pytest.mark.asyncio + async def test_heuristic_fallback_still_scores(self, llm_complexity_router, mock_router_instance): + """The pre-existing default must be unchanged by the new option.""" + mock_router_instance.acompletion = AsyncMock(side_effect=TimeoutError("classifier timed out")) + outcome = await llm_complexity_router.aclassify("Hello!") + assert outcome.cause == "heuristic_scorer" + assert outcome.score is not None + + @pytest.mark.asyncio + async def test_pre_routing_hook_routes_to_default_model_on_classifier_failure( + self, default_model_fallback_router, mock_router_instance + ): + """The tier pool for the resolved tier must not get a say: a multi-model pool would + otherwise land somewhere other than the known destination the operator asked for.""" + mock_router_instance.acompletion = AsyncMock(side_effect=TimeoutError("classifier timed out")) + response = await default_model_fallback_router.async_pre_routing_hook( + model="test-model", + request_kwargs={}, + messages=[{"role": "user", "content": "prove the Riemann hypothesis step by step"}], + ) + assert response is not None + assert response.model == "gpt-4o" + assert response.routing_decision is not None + assert response.routing_decision["cause"] == "default_model_fallback" + # No tier was decided, so the provenance record must not claim one. The internal + # outcome carries a tier only because the plugin path needs a pool to pick from. + assert "tier" not in response.routing_decision + + @pytest.mark.asyncio + async def test_a_classifier_failure_does_not_pin_the_session_to_the_default_model(self, mock_router_instance): + """One transient timeout must not hold a session on default_model for the whole affinity TTL: + that turn was never classified, so there is nothing worth pinning and the next turn retries.""" + router = ComplexityRouter( + model_name="test-complexity-router", + litellm_router_instance=mock_router_instance, + complexity_router_config={ + "tiers": { + "SIMPLE": "gpt-4o-mini", + "MEDIUM": "gpt-4o", + "COMPLEX": "claude-sonnet-4-20250514", + "REASONING": "o1-preview", + }, + "classifier_type": "llm", + "classifier_llm_config": {"model": "haiku-classifier", "timeout_ms": 400}, + "classifier_fallback": "default_model", + "default_model": "gpt-4o", + "session_affinity": True, + }, + ) + mock_router_instance.cache = DualCache() + request_kwargs: Dict = {"metadata": {"session_id": "session-flaky"}} + + mock_router_instance.acompletion = AsyncMock(side_effect=TimeoutError("classifier timed out")) + first = await router.async_pre_routing_hook( + model="test-model", + request_kwargs=request_kwargs, + messages=[{"role": "user", "content": "Hello!"}], + ) + assert first is not None + assert first.model == "gpt-4o" + + mock_router_instance.acompletion = AsyncMock(return_value=_llm_response('{"tier": "REASONING"}')) + second = await router.async_pre_routing_hook( + model="test-model", + request_kwargs=request_kwargs, + messages=[{"role": "user", "content": "prove the Riemann hypothesis"}], + ) + assert second is not None + assert second.model == "o1-preview" + assert second.routing_decision is not None + assert second.routing_decision["cause"] == "llm_classifier" + + @pytest.mark.asyncio + async def test_a_successful_classification_still_pins_the_session(self, mock_router_instance): + """Guard on the fix above: only the failed-classifier cause is unpinnable, so an ordinary + turn on a default_model-fallback router must still pin exactly as it did before.""" + router = ComplexityRouter( + model_name="test-complexity-router", + litellm_router_instance=mock_router_instance, + complexity_router_config={ + "tiers": {"SIMPLE": "gpt-4o-mini", "REASONING": "o1-preview"}, + "classifier_type": "llm", + "classifier_llm_config": {"model": "haiku-classifier", "timeout_ms": 400}, + "classifier_fallback": "default_model", + "default_model": "gpt-4o", + "session_affinity": True, + }, + ) + mock_router_instance.cache = DualCache() + request_kwargs: Dict = {"metadata": {"session_id": "session-steady"}} + + mock_router_instance.acompletion = AsyncMock(return_value=_llm_response('{"tier": "REASONING"}')) + first = await router.async_pre_routing_hook( + model="test-model", + request_kwargs=request_kwargs, + messages=[{"role": "user", "content": "prove the Riemann hypothesis"}], + ) + assert first is not None + assert first.model == "o1-preview" + + with patch.object(router, "aclassify", side_effect=AssertionError("pinned turn must not reclassify")): + second = await router.async_pre_routing_hook( + model="test-model", + request_kwargs=request_kwargs, + messages=[{"role": "user", "content": "Hello!"}], + ) + assert second is not None + assert second.model == "o1-preview" + + @pytest.mark.asyncio + async def test_default_model_fallback_does_not_bypass_routing_plugins(self, mock_router_instance): + """A failed classifier must not become a way around a policy plugin: default_model is never + checked against the plugin pipeline, so with plugins configured this path has to fall through + to the tier pool, which does run them. Mirrors the no-user-message path's guard.""" + + class ExcludeDefaultModel: + async def run(self, context): + context.candidate_models = [m for m in context.candidate_models if m != "gpt-4o-default"] + return context + + router = ComplexityRouter( + model_name="test-complexity-router", + litellm_router_instance=mock_router_instance, + complexity_router_config={ + "tiers": {"MEDIUM": ["gpt-4o-default", "gpt-4o-nano"]}, + "classifier_type": "llm", + "classifier_llm_config": {"model": "haiku-classifier", "timeout_ms": 400}, + "classifier_fallback": "default_model", + "default_model": "gpt-4o-default", + "plugins": [ExcludeDefaultModel()], + }, + ) + mock_router_instance.acompletion = AsyncMock(side_effect=TimeoutError("classifier timed out")) + response = await router.async_pre_routing_hook( + model="test-model", + request_kwargs={}, + messages=[{"role": "user", "content": "hello"}], + ) + assert response is not None + assert response.model == "gpt-4o-nano" + # The plugin path needs a pool to filter, but no tier was ever classified: the + # classifier failed. Recording MEDIUM as the request's tier would attribute a + # classification that never happened, so the pool is reported as a signal instead. + assert response.routing_decision is not None + assert response.routing_decision["cause"] == "default_model_fallback" + assert "tier" not in response.routing_decision + assert "plugin-filtered-pool:MEDIUM" in response.routing_decision["signals"] + + @pytest.mark.asyncio + async def test_default_model_fallback_with_plugins_reports_the_empty_tier_not_the_plugins( + self, mock_router_instance + ): + """default_model in no tier pool resolves to MEDIUM, so an empty MEDIUM pool used to raise + 'No candidate models left for tier MEDIUM after routing-plugin filtering' and send the + operator hunting for a policy plugin that never narrowed anything. Flagged by Greptile.""" + + class AllowAll: + async def run(self, context): + return context + + router = ComplexityRouter( + model_name="test-complexity-router", + litellm_router_instance=mock_router_instance, + complexity_router_config={ + "tiers": {"COMPLEX": ["o1-preview"]}, + "classifier_type": "llm", + "classifier_llm_config": {"model": "haiku-classifier", "timeout_ms": 400}, + "classifier_fallback": "default_model", + "default_model": "gpt-4o-default", + "plugins": [AllowAll()], + }, + ) + mock_router_instance.acompletion = AsyncMock(side_effect=TimeoutError("classifier timed out")) + with pytest.raises(ValueError, match="No models configured for tier MEDIUM"): + await router.async_pre_routing_hook( + model="test-model", + request_kwargs={}, + messages=[{"role": "user", "content": "hello"}], + ) + + @pytest.mark.asyncio + async def test_successful_classification_ignores_the_fallback_setting( + self, default_model_fallback_router, mock_router_instance + ): + mock_router_instance.acompletion = AsyncMock(return_value=_llm_response('{"tier": "REASONING"}')) + response = await default_model_fallback_router.async_pre_routing_hook( + model="test-model", + request_kwargs={}, + messages=[{"role": "user", "content": "hi"}], + ) + assert response is not None + assert response.model == "o1-preview" + assert response.routing_decision is not None + assert response.routing_decision["cause"] == "llm_classifier" + + class TestSavingsBaselineOnDecision: """The derived counterfactual rides on every routing decision, recorded by the deciding instance because tag-scoped routers under one model name make a diff --git a/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx index e3a2cd803d3..bff798314e3 100644 --- a/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx @@ -1,17 +1,47 @@ import { InfoCircleOutlined } from "@ant-design/icons"; import { Select as AntdSelect, Card, InputNumber, Radio, Space, Switch, Tooltip, Typography } from "antd"; import React from "react"; +import ClassifierPromptEditor from "./ClassifierPromptEditor"; import { + ClassifierFallback, ClassifierType, ComplexityRouterConfigValue, DEFAULT_CLASSIFIER_CONTEXT_PER_TURN_CHARS, DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE, + DEFAULT_CLASSIFIER_FALLBACK, DEFAULT_CLASSIFIER_TIMEOUT_MS, effectiveTierLabel, } from "./ComplexityRouterConfig"; const { Text } = Typography; +const DEFAULT_SCORING_EXPLANATION = + "The router scores each request across 7 dimensions: token count, code presence, reasoning markers, technical " + + "terms, simple indicators, multi-step patterns, and question complexity. The weighted score determines the tier:"; + +const CUSTOM_PROMPT_WITH_HEURISTIC_FALLBACK = + "This router classifies with your own prompt, so the tier comes from whatever rubric it states. The four tier " + + "names stay fixed. The scoring below is the heuristic, which now runs only when the classifier call fails:"; + +const CUSTOM_PROMPT_WITH_DEFAULT_MODEL_FALLBACK = + "This router classifies with your own prompt, so the tier comes from whatever rubric it states. The four tier " + + "names stay fixed. The scoring below no longer runs at all, since a failed classifier routes to the default " + + "model instead:"; + +/** + * What the scoring breakdown below it actually describes. A custom prompt means the score no longer + * decides the tier, and pairing one with the default-model fallback means the heuristic never runs + * at all, so the panel must not keep implying a score is involved on either router. + */ +const scoringExplanation = (value: ComplexityRouterConfigValue): string => { + const usesCustomPrompt = + value.classifier_type === "llm" && Boolean(value.classifier_llm_config?.system_prompt?.trim()); + if (!usesCustomPrompt) return DEFAULT_SCORING_EXPLANATION; + return value.classifier_fallback === "default_model" + ? CUSTOM_PROMPT_WITH_DEFAULT_MODEL_FALLBACK + : CUSTOM_PROMPT_WITH_HEURISTIC_FALLBACK; +}; + interface ClassificationMethodConfigProps { value: ComplexityRouterConfigValue; onChange: (value: ComplexityRouterConfigValue) => void; @@ -19,6 +49,8 @@ interface ClassificationMethodConfigProps { customTechnicalKeywords?: string[]; onCustomTechnicalKeywordsChange?: (keywords: string[]) => void; showValidationErrors?: boolean; + /** Enables the default-model fallback, which the backend rejects without a default model. */ + hasDefaultModel?: boolean; } const ClassificationMethodConfig: React.FC = ({ @@ -28,6 +60,7 @@ const ClassificationMethodConfig: React.FC = ({ customTechnicalKeywords, onCustomTechnicalKeywordsChange, showValidationErrors = false, + hasDefaultModel = false, }) => { const classifierModelMissing = showValidationErrors && value.classifier_type === "llm" && !value.classifier_llm_config?.model; @@ -50,6 +83,7 @@ const ClassificationMethodConfig: React.FC = ({ : undefined, classifier_context_include_assistant_turns: classifierType === "llm" ? value.classifier_context_include_assistant_turns : undefined, + classifier_fallback: classifierType === "llm" ? value.classifier_fallback : undefined, }; onChange(nextValue); }; @@ -58,6 +92,7 @@ const ClassificationMethodConfig: React.FC = ({ onChange({ ...value, classifier_llm_config: { + ...value.classifier_llm_config, model, timeout_ms: value.classifier_llm_config?.timeout_ms ?? DEFAULT_CLASSIFIER_TIMEOUT_MS, }, @@ -68,12 +103,29 @@ const ClassificationMethodConfig: React.FC = ({ onChange({ ...value, classifier_llm_config: { + ...value.classifier_llm_config, model: value.classifier_llm_config?.model ?? "", timeout_ms: timeoutMs ?? DEFAULT_CLASSIFIER_TIMEOUT_MS, }, }); }; + const handleClassifierSystemPromptChange = (systemPrompt: string | undefined) => { + onChange({ + ...value, + classifier_llm_config: { + ...value.classifier_llm_config, + model: value.classifier_llm_config?.model ?? "", + timeout_ms: value.classifier_llm_config?.timeout_ms ?? DEFAULT_CLASSIFIER_TIMEOUT_MS, + system_prompt: systemPrompt, + }, + }); + }; + + const handleClassifierFallbackChange = (fallback: ClassifierFallback) => { + onChange({ ...value, classifier_fallback: fallback }); + }; + const handleClassifierContextWindowSizeChange = (windowSize: number | null) => { onChange({ ...value, @@ -146,8 +198,47 @@ const ClassificationMethodConfig: React.FC = ({ style={{ width: "100%" }} /> - Falls back to the heuristic scorer if the classifier call errors, times out, or returns an unparseable - response. + How long the classifier call has before it fails and the fallback below takes over. + + +
+ + Classifier Prompt + + +
+
+ + If the classifier fails + + handleClassifierFallbackChange(e.target.value)} + > + + + Score with the heuristic{" "} + — right when the classifier grades complexity too + + + + + Route to the default model{" "} + — right when your prompt grades something other than complexity + + + + + + + Applies when the classifier call errors, times out, or returns an unparseable response.
@@ -234,9 +325,7 @@ const ClassificationMethodConfig: React.FC = ({ How Classification Works - The router scores each request across 7 dimensions: token count, code presence, reasoning markers, technical - terms, simple indicators, multi-step patterns, and question complexity. The weighted score determines the - tier: + {scoringExplanation(value)}
  • diff --git a/ui/litellm-dashboard/src/components/add_model/ClassifierPromptEditor.integration.test.tsx b/ui/litellm-dashboard/src/components/add_model/ClassifierPromptEditor.integration.test.tsx new file mode 100644 index 00000000000..1537ff084a2 --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/ClassifierPromptEditor.integration.test.tsx @@ -0,0 +1,93 @@ +import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils"; +import userEvent from "@testing-library/user-event"; +import { vi } from "vitest"; +import ClassifierPromptEditor from "./ClassifierPromptEditor"; + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => ({ accessToken: "sk-test" }), +})); + +const getDefaultPrompt = vi.hoisted(() => vi.fn()); +vi.mock("@/components/networking", () => ({ + getAutoRouterClassifierDefaultPromptCall: getDefaultPrompt, +})); + +const DEFAULT_PROMPT = "Classify the complexity of a user request into exactly one tier. Tiers: SIMPLE ..."; + +beforeEach(() => { + getDefaultPrompt.mockReset(); + getDefaultPrompt.mockResolvedValue(DEFAULT_PROMPT); +}); + +const openEditor = async ( + systemPrompt?: string, + onChange = vi.fn(), + contextWindowSize = 3, + tierLabels?: Record, +) => { + renderWithProviders( + , + ); + await userEvent.click(screen.getByRole("button", { name: /prompt/i })); + await waitFor(() => expect(screen.getByLabelText("Classifier system prompt")).toBeInTheDocument()); + return onChange; +}; + +describe("ClassifierPromptEditor", () => { + it("prefills the live rubric fetched for the configured context window", async () => { + await openEditor(undefined, vi.fn(), 7); + // Prefilling from the backend rather than a frontend copy is the whole point: a copy would + // drift the moment the rubric is edited. + expect(getDefaultPrompt).toHaveBeenCalledWith("sk-test", 7, undefined); + expect(screen.getByLabelText("Classifier system prompt")).toHaveValue(DEFAULT_PROMPT); + }); + + it("prefills the rubric named by the operator's renamed tiers", async () => { + // A renamed router sends a rubric using its own labels, and its classifier must return them, + // so prefilling the canonical names would hand back a prompt that router rejects. + const tierLabels = { SIMPLE: "Cheap", REASONING: "Deep" }; + await openEditor(undefined, vi.fn(), 7, tierLabels); + expect(getDefaultPrompt).toHaveBeenCalledWith("sk-test", 7, tierLabels); + }); + + it("warns that the prompt replaces the injection-defense text", async () => { + await openEditor(); + expect(screen.getByText("Proceed with caution")).toBeInTheDocument(); + expect(screen.getByText(/entire system role/)).toBeInTheDocument(); + }); + + it("saves an edited prompt as an override", async () => { + const onChange = await openEditor(); + const textarea = screen.getByLabelText("Classifier system prompt"); + await userEvent.clear(textarea); + await userEvent.type(textarea, "Grade data sensitivity"); + await userEvent.click(screen.getByRole("button", { name: "Save prompt" })); + expect(onChange).toHaveBeenCalledWith("Grade data sensitivity"); + }); + + it("saves an untouched prompt as no override at all", async () => { + const onChange = await openEditor(); + await userEvent.click(screen.getByRole("button", { name: "Save prompt" })); + expect(onChange).toHaveBeenCalledWith(undefined); + }); + + it("offers a reset that clears a stored override", async () => { + const onChange = vi.fn(); + renderWithProviders( + , + ); + expect(screen.getByRole("button", { name: "Edit custom prompt" })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Reset to default" })); + expect(onChange).toHaveBeenCalledWith(undefined); + }); + + it("seeds the editor from the stored override, not the default", async () => { + await openEditor("Grade data sensitivity"); + expect(screen.getByLabelText("Classifier system prompt")).toHaveValue("Grade data sensitivity"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/add_model/ClassifierPromptEditor.tsx b/ui/litellm-dashboard/src/components/add_model/ClassifierPromptEditor.tsx new file mode 100644 index 00000000000..c1f2e5a11d1 --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/ClassifierPromptEditor.tsx @@ -0,0 +1,138 @@ +import React, { useCallback, useState } from "react"; +import { TriangleAlert } from "lucide-react"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { getAutoRouterClassifierDefaultPromptCall } from "@/components/networking"; +import NotificationsManager from "@/components/molecules/notifications_manager"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Textarea } from "@/components/ui/textarea"; +import { hasCustomPrompt, initialDraftText, resolveCustomPrompt } from "./classifierPromptEditorState"; + +interface ClassifierPromptEditorProps { + systemPrompt: string | undefined; + onChange: (systemPrompt: string | undefined) => void; + contextWindowSize: number; + tierLabels?: Record; +} + +const ClassifierPromptEditor: React.FC = ({ + systemPrompt, + onChange, + contextWindowSize, + tierLabels, +}) => { + const { accessToken } = useAuthorized(); + const [isOpen, setIsOpen] = useState(false); + const [defaultPrompt, setDefaultPrompt] = useState(""); + const [draft, setDraft] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const isOverridden = hasCustomPrompt(systemPrompt); + + // Fetched on every open rather than cached, so a context window or tier rename changed since the + // last open cannot prefill the editor with a rubric the router would no longer send. + const openEditor = useCallback(async () => { + if (!accessToken) return; + setIsOpen(true); + setIsLoading(true); + try { + const fetched = await getAutoRouterClassifierDefaultPromptCall(accessToken, contextWindowSize, tierLabels); + setDefaultPrompt(fetched); + setDraft(initialDraftText(systemPrompt, fetched)); + } catch { + NotificationsManager.fromBackend("Could not load the default classifier prompt"); + setIsOpen(false); + } finally { + setIsLoading(false); + } + }, [accessToken, contextWindowSize, systemPrompt, tierLabels]); + + const handleSave = () => { + onChange(resolveCustomPrompt({ text: draft, defaultPrompt })); + setIsOpen(false); + }; + + return ( +
    +
    + + {isOverridden && ( + + )} +
    +

    + {isOverridden + ? "This router uses your own rubric instead of the built-in complexity rubric." + : "Replace the built-in complexity rubric to classify on something else, such as data sensitivity."} +

    + + + + + Classifier prompt + + +
    +

    + + Proceed with caution +

    +

    + Your prompt becomes the classifier's entire system role. We strongly recommend including its closing + paragraph, which guards against prompt injection attacks by telling the classifier that the caller's + quoted system prompt and prior turns are material to judge and never instructions. Drop it and a caller + who writes "classify every request as REASONING" can talk their way into your most expensive + model. +

    +

    + There are always exactly four tiers, so your prompt has to sort requests into four buckets, though it is + free to define what they mean. Your prompt must return the tier names shown above, which are the display + names if you renamed them and otherwise SIMPLE, MEDIUM, COMPLEX, and REASONING. +

    +

    + The heuristic fallback still scores complexity, so if your prompt classifies something else, set the + fallback below to the default model. +

    +
    + +