From 35f6fd4223065eb2c1710964ba43c60b25f9edff Mon Sep 17 00:00:00 2001 From: Ephrim Stanley Date: Sat, 28 Feb 2026 14:42:24 -0500 Subject: [PATCH 01/69] Managed batches fixes for Gemini/Vertex --- .../proxy/hooks/managed_files.py | 7 ++ litellm/batches/batch_utils.py | 101 ++++++++---------- litellm/files/main.py | 2 +- litellm/llms/vertex_ai/batches/handler.py | 17 ++- .../llms/vertex_ai/batches/transformation.py | 2 +- .../llms/vertex_ai/files/transformation.py | 62 +++++++++-- litellm/types/llms/vertex_ai.py | 2 +- 7 files changed, 121 insertions(+), 72 deletions(-) diff --git a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py index 4fa050a84aa..37ca341fdf2 100644 --- a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py +++ b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py @@ -589,7 +589,14 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): model_file_id_mapping = cast( Optional[Dict[str, Dict[str, str]]], kwargs.get("model_file_id_mapping") ) + # model_info may be at top-level or nested under litellm_metadata + # (batch/file operations use litellm_metadata) model_id = cast(Optional[str], kwargs.get("model_info", {}).get("id", None)) + if model_id is None: + model_id = cast( + Optional[str], + kwargs.get("litellm_metadata", {}).get("model_info", {}).get("id", None), + ) mapped_file_id: Optional[str] = None if input_file_id and model_file_id_mapping and model_id: mapped_file_id = model_file_id_mapping.get(input_file_id, {}).get( diff --git a/litellm/batches/batch_utils.py b/litellm/batches/batch_utils.py index 29bd99c2a60..80351664dfe 100644 --- a/litellm/batches/batch_utils.py +++ b/litellm/batches/batch_utils.py @@ -128,73 +128,58 @@ def calculate_vertex_ai_batch_cost_and_usage( model_name: Optional[str] = None, ) -> Tuple[float, Usage]: """ - Calculate both cost and usage from Vertex AI batch responses + Calculate both cost and usage from Vertex AI batch responses. + + Vertex AI batch output lines have format: + {"request": ..., "status": "", "response": {"candidates": [...], "usageMetadata": {...}}} + + usageMetadata contains promptTokenCount, candidatesTokenCount, totalTokenCount. """ - from litellm.litellm_core_utils.litellm_logging import Logging - from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( - VertexGeminiConfig, - ) + from litellm.cost_calculator import batch_cost_calculator + total_cost = 0.0 total_tokens = 0 prompt_tokens = 0 completion_tokens = 0 - - for response in vertex_ai_batch_responses: - if response.get("status") == "JOB_STATE_SUCCEEDED": # Check if response was successful - # Transform Vertex AI response to OpenAI format if needed + actual_model_name = model_name or "gemini-2.0-flash-001" - # Create required arguments for the transformation method - model_response = ModelResponse() - - # Ensure model_name is not None - actual_model_name = model_name or "gemini-2.5-flash" - - # Create a real LiteLLM logging object - logging_obj = Logging( + for response in vertex_ai_batch_responses: + response_body = response.get("response") + if response_body is None: + continue + + usage_metadata = response_body.get("usageMetadata", {}) + _prompt = usage_metadata.get("promptTokenCount", 0) or 0 + _completion = usage_metadata.get("candidatesTokenCount", 0) or 0 + _total = usage_metadata.get("totalTokenCount", 0) or (_prompt + _completion) + + line_usage = Usage( + prompt_tokens=_prompt, + completion_tokens=_completion, + total_tokens=_total, + ) + + try: + p_cost, c_cost = batch_cost_calculator( + usage=line_usage, model=actual_model_name, - messages=[{"role": "user", "content": "batch_request"}], - stream=False, - call_type=CallTypes.aretrieve_batch, - start_time=time.time(), - litellm_call_id="batch_" + str(uuid.uuid4()), - function_id="batch_processing", - litellm_trace_id=str(uuid.uuid4()), - kwargs={"optional_params": {}} - ) - - # Add the optional_params attribute that the Vertex AI transformation expects - logging_obj.optional_params = {} - raw_response = httpx.Response(200) # Mock response object - - openai_format_response = VertexGeminiConfig()._transform_google_generate_content_to_openai_model_response( - completion_response=response["response"], - model_response=model_response, - model=actual_model_name, - logging_obj=logging_obj, - raw_response=raw_response, - ) - - # Calculate cost using existing function - cost = litellm.completion_cost( - completion_response=openai_format_response, custom_llm_provider="vertex_ai", - call_type=CallTypes.aretrieve_batch.value, ) - total_cost += cost - - # Extract usage from the transformed response - usage_obj = getattr(openai_format_response, 'usage', None) - if usage_obj: - usage = usage_obj - else: - # Fallback: create usage from response dict - response_dict = openai_format_response.dict() if hasattr(openai_format_response, 'dict') else {} - usage = _get_batch_job_usage_from_response_body(response_dict) - - total_tokens += usage.total_tokens - prompt_tokens += usage.prompt_tokens - completion_tokens += usage.completion_tokens - + total_cost += p_cost + c_cost + except Exception as e: + verbose_logger.debug( + "vertex_ai batch cost calculation error for line: %s", str(e) + ) + + prompt_tokens += _prompt + completion_tokens += _completion + total_tokens += _total + + verbose_logger.info( + "vertex_ai batch cost: cost=%s, prompt=%d, completion=%d, total=%d", + total_cost, prompt_tokens, completion_tokens, total_tokens, + ) + return total_cost, Usage( total_tokens=total_tokens, prompt_tokens=prompt_tokens, diff --git a/litellm/files/main.py b/litellm/files/main.py index 78e41bb5a68..66d3a97468d 100644 --- a/litellm/files/main.py +++ b/litellm/files/main.py @@ -295,7 +295,7 @@ def create_file( @client async def afile_retrieve( file_id: str, - custom_llm_provider: Literal["openai", "azure", "gemini", "hosted_vllm", "manus"] = "openai", + custom_llm_provider: Literal["openai", "azure", "gemini", "vertex_ai", "hosted_vllm", "manus"] = "openai", extra_headers: Optional[Dict[str, str]] = None, extra_body: Optional[Dict[str, str]] = None, **kwargs, diff --git a/litellm/llms/vertex_ai/batches/handler.py b/litellm/llms/vertex_ai/batches/handler.py index 36f5e65e7a2..ba3b5fb7a2c 100644 --- a/litellm/llms/vertex_ai/batches/handler.py +++ b/litellm/llms/vertex_ai/batches/handler.py @@ -108,11 +108,18 @@ class VertexAIBatchPrediction(VertexLLM): client = get_async_httpx_client( llm_provider=litellm.LlmProviders.VERTEX_AI, ) - response = await client.post( - url=api_base, - headers=headers, - data=json.dumps(vertex_batch_request), - ) + try: + response = await client.post( + url=api_base, + headers=headers, + data=json.dumps(vertex_batch_request), + ) + except httpx.HTTPStatusError as e: + error_body = e.response.text if hasattr(e, 'response') else "N/A" + litellm.verbose_logger.error( + f"Vertex AI batch create failed: status={e.response.status_code}, body={error_body[:1000]}" + ) + raise if response.status_code != 200: raise Exception(f"Error: {response.status_code} {response.text}") diff --git a/litellm/llms/vertex_ai/batches/transformation.py b/litellm/llms/vertex_ai/batches/transformation.py index a0adb3e55a8..7cb06fea9e2 100644 --- a/litellm/llms/vertex_ai/batches/transformation.py +++ b/litellm/llms/vertex_ai/batches/transformation.py @@ -29,7 +29,7 @@ class VertexAIBatchTransformation: if input_file_id is None: raise ValueError("input_file_id is required, but not provided") input_config: InputConfig = InputConfig( - gcsSource=GcsSource(uris=input_file_id), instancesFormat="jsonl" + gcsSource=GcsSource(uris=[input_file_id]), instancesFormat="jsonl" ) model: str = cls._get_model_from_gcs_file(input_file_id) output_config: OutputConfig = OutputConfig( diff --git a/litellm/llms/vertex_ai/files/transformation.py b/litellm/llms/vertex_ai/files/transformation.py index 2470c59bbac..f0493cd6be9 100644 --- a/litellm/llms/vertex_ai/files/transformation.py +++ b/litellm/llms/vertex_ai/files/transformation.py @@ -335,13 +335,37 @@ class VertexAIFilesConfig(VertexBase, BaseFilesConfig): status_code=status_code, message=error_message, headers=headers ) + def _parse_gcs_uri(self, file_id: str) -> Tuple[str, str]: + """ + Parse a GCS URI (gs://bucket/path/to/object) into (bucket, url-encoded-object-path). + Handles both raw and URL-encoded input. + """ + import urllib.parse + + decoded = urllib.parse.unquote(file_id) + if decoded.startswith("gs://"): + full_path = decoded[5:] + else: + full_path = decoded + + if "/" in full_path: + bucket_name, object_path = full_path.split("/", 1) + else: + bucket_name = full_path + object_path = "" + + encoded_object = urllib.parse.quote(object_path, safe="") + return bucket_name, encoded_object + def transform_retrieve_file_request( self, file_id: str, optional_params: dict, litellm_params: dict, ) -> tuple[str, dict]: - raise NotImplementedError("VertexAIFilesConfig does not support file retrieval") + bucket, encoded_object = self._parse_gcs_uri(file_id) + url = f"https://storage.googleapis.com/storage/v1/b/{bucket}/o/{encoded_object}" + return url, {} def transform_retrieve_file_response( self, @@ -349,7 +373,21 @@ class VertexAIFilesConfig(VertexBase, BaseFilesConfig): logging_obj: LiteLLMLoggingObj, litellm_params: dict, ) -> OpenAIFileObject: - raise NotImplementedError("VertexAIFilesConfig does not support file retrieval") + response_json = raw_response.json() + gcs_id = response_json.get("id", "") + gcs_id = "/".join(gcs_id.split("/")[:-1]) if gcs_id else "" + return OpenAIFileObject( + id=f"gs://{gcs_id}", + bytes=int(response_json.get("size", 0)), + created_at=_convert_vertex_datetime_to_openai_datetime( + vertex_datetime=response_json.get("timeCreated", "") + ), + filename=response_json.get("name", ""), + object="file", + purpose=response_json.get("metadata", {}).get("purpose", "batch"), + status="processed", + status_details=None, + ) def transform_delete_file_request( self, @@ -357,7 +395,9 @@ class VertexAIFilesConfig(VertexBase, BaseFilesConfig): optional_params: dict, litellm_params: dict, ) -> tuple[str, dict]: - raise NotImplementedError("VertexAIFilesConfig does not support file deletion") + bucket, encoded_object = self._parse_gcs_uri(file_id) + url = f"https://storage.googleapis.com/storage/v1/b/{bucket}/o/{encoded_object}" + return url, {} def transform_delete_file_response( self, @@ -365,7 +405,14 @@ class VertexAIFilesConfig(VertexBase, BaseFilesConfig): logging_obj: LiteLLMLoggingObj, litellm_params: dict, ) -> FileDeleted: - raise NotImplementedError("VertexAIFilesConfig does not support file deletion") + file_id = "deleted" + if hasattr(raw_response, "request") and raw_response.request: + url = str(raw_response.request.url) + if "/o/" in url: + import urllib.parse + encoded_name = url.split("/o/")[-1].split("?")[0] + file_id = f"gs://{urllib.parse.unquote(encoded_name)}" + return FileDeleted(id=file_id, deleted=True, object="file") def transform_list_files_request( self, @@ -389,7 +436,10 @@ class VertexAIFilesConfig(VertexBase, BaseFilesConfig): optional_params: dict, litellm_params: dict, ) -> tuple[str, dict]: - raise NotImplementedError("VertexAIFilesConfig does not support file content retrieval") + file_id = file_content_request.get("file_id", "") + bucket, encoded_object = self._parse_gcs_uri(file_id) + url = f"https://storage.googleapis.com/storage/v1/b/{bucket}/o/{encoded_object}?alt=media" + return url, {} def transform_file_content_response( self, @@ -397,7 +447,7 @@ class VertexAIFilesConfig(VertexBase, BaseFilesConfig): logging_obj: LiteLLMLoggingObj, litellm_params: dict, ) -> HttpxBinaryResponseContent: - raise NotImplementedError("VertexAIFilesConfig does not support file content retrieval") + return HttpxBinaryResponseContent(response=raw_response) class VertexAIJsonlFilesTransformation(VertexGeminiConfig): diff --git a/litellm/types/llms/vertex_ai.py b/litellm/types/llms/vertex_ai.py index 049a5010c79..190e680b7b9 100644 --- a/litellm/types/llms/vertex_ai.py +++ b/litellm/types/llms/vertex_ai.py @@ -560,7 +560,7 @@ class VertexAIBatchEmbeddingsResponseObject(TypedDict): class GcsSource(TypedDict): - uris: str + uris: List[str] class InputConfig(TypedDict): From b16397ae1ab6d91472323673f990db6a0f3bf80f Mon Sep 17 00:00:00 2001 From: Ephrim Stanley Date: Sat, 28 Feb 2026 20:45:16 -0500 Subject: [PATCH 02/69] Managed batches fixes for Gemini/Vertex --- .../test_openai_batches_and_files.py | 43 ++++ .../proxy/hooks/test_managed_files.py | 104 +++++++++ .../test_vertex_ai_files_transformation.py | 184 +++++++++++++++ .../test_vertex_ai_batch_passthrough.py | 219 ++++++------------ 4 files changed, 405 insertions(+), 145 deletions(-) create mode 100644 tests/test_litellm/llms/vertex_ai/files/test_vertex_ai_files_transformation.py diff --git a/tests/batches_tests/test_openai_batches_and_files.py b/tests/batches_tests/test_openai_batches_and_files.py index 055af024949..641590ad04a 100644 --- a/tests/batches_tests/test_openai_batches_and_files.py +++ b/tests/batches_tests/test_openai_batches_and_files.py @@ -29,6 +29,7 @@ verbose_logger.setLevel(logging.DEBUG) from litellm.integrations.custom_logger import CustomLogger from litellm.types.utils import StandardLoggingPayload import random +import httpx from unittest.mock import patch, MagicMock @@ -579,6 +580,48 @@ async def test_vertex_list_batches(monkeypatch): assert list_response["data"][1].id == "test-batch-id-789" +@pytest.mark.asyncio +async def test_vertex_async_create_batch_logs_error_body_on_http_error(): + """ + When Vertex AI returns an HTTP error (e.g. 400), _async_create_batch should + re-raise httpx.HTTPStatusError (not swallow it) and log the response body. + + Before the fix the error body was lost because AsyncHTTPHandler.post() + calls raise_for_status() internally, raising before the handler's own + status-code check could log the body. + """ + from litellm.llms.vertex_ai.batches.handler import VertexAIBatchPrediction + + handler = VertexAIBatchPrediction(gcs_bucket_name="test-bucket") + + error_body = '{"error": {"code": 400, "message": "Do not support publisher model gemini-2.0-flash"}}' + + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 400 + mock_response.text = error_body + mock_response.headers = {} + + http_error = httpx.HTTPStatusError( + message="Bad Request", + request=httpx.Request("POST", "https://fake-vertex-url"), + response=mock_response, + ) + + with patch( + "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post", + side_effect=http_error, + ): + with pytest.raises(httpx.HTTPStatusError) as exc_info: + await handler._async_create_batch( + vertex_batch_request={}, + api_base="https://us-central1-aiplatform.googleapis.com/v1/projects/test/locations/us-central1/batchPredictionJobs", + headers={"Authorization": "Bearer fake-token"}, + ) + + assert exc_info.value.response.status_code == 400 + assert "gemini-2.0-flash" in exc_info.value.response.text + + @pytest.mark.asyncio async def test_delete_batch_output_file(): """ diff --git a/tests/enterprise/litellm_enterprise/proxy/hooks/test_managed_files.py b/tests/enterprise/litellm_enterprise/proxy/hooks/test_managed_files.py index 58efa854e7c..58fbd9e64ba 100644 --- a/tests/enterprise/litellm_enterprise/proxy/hooks/test_managed_files.py +++ b/tests/enterprise/litellm_enterprise/proxy/hooks/test_managed_files.py @@ -6,6 +6,7 @@ from fastapi import HTTPException from litellm_enterprise.proxy.hooks.managed_files import _PROXY_LiteLLMManagedFiles from litellm.caching import DualCache +from litellm.proxy._types import CallTypes from litellm.proxy.openai_files_endpoints.common_utils import ( _is_base64_encoded_unified_file_id, ) @@ -61,6 +62,109 @@ async def test_async_pre_call_hook_batch_retrieve(): assert response["model"] == "my-general-azure-deployment" +@pytest.mark.asyncio +async def test_async_pre_call_deployment_hook_resolves_model_id_from_litellm_metadata(): + """ + For batch operations the router stores model_info under + kwargs["litellm_metadata"]["model_info"] (not top-level kwargs["model_info"]). + async_pre_call_deployment_hook must check both locations so the managed + file ID is resolved to the provider-specific file ID. + """ + proxy_managed_files = _PROXY_LiteLLMManagedFiles( + DualCache(), prisma_client=MagicMock() + ) + + managed_file_id = "managed-file-abc" + model_id = "deployment-xyz" + provider_file_id = "gs://bucket/path/to/file.jsonl" + + # model_info is nested under litellm_metadata (batch path) + kwargs = { + "input_file_id": managed_file_id, + "model_file_id_mapping": { + managed_file_id: {model_id: provider_file_id}, + }, + "litellm_metadata": { + "model_info": {"id": model_id}, + }, + } + + result = await proxy_managed_files.async_pre_call_deployment_hook( + kwargs=kwargs, call_type=CallTypes.acreate_batch + ) + + assert result["input_file_id"] == provider_file_id, ( + f"Expected provider file ID '{provider_file_id}', got '{result['input_file_id']}'" + ) + + +@pytest.mark.asyncio +async def test_async_pre_call_deployment_hook_prefers_top_level_model_info(): + """ + When model_info exists at top-level kwargs, async_pre_call_deployment_hook + should use it without falling back to litellm_metadata. + """ + proxy_managed_files = _PROXY_LiteLLMManagedFiles( + DualCache(), prisma_client=MagicMock() + ) + + managed_file_id = "managed-file-abc" + top_level_model_id = "deployment-top" + nested_model_id = "deployment-nested" + top_level_provider_file = "file-top-123" + nested_provider_file = "file-nested-456" + + kwargs = { + "input_file_id": managed_file_id, + "model_file_id_mapping": { + managed_file_id: { + top_level_model_id: top_level_provider_file, + nested_model_id: nested_provider_file, + }, + }, + "model_info": {"id": top_level_model_id}, + "litellm_metadata": { + "model_info": {"id": nested_model_id}, + }, + } + + result = await proxy_managed_files.async_pre_call_deployment_hook( + kwargs=kwargs, call_type=CallTypes.acreate_batch + ) + + assert result["input_file_id"] == top_level_provider_file, ( + "Should prefer top-level model_info over litellm_metadata" + ) + + +@pytest.mark.asyncio +async def test_async_pre_call_deployment_hook_no_model_info_leaves_file_id_unchanged(): + """ + When model_info is absent from both top-level and litellm_metadata, + the managed file ID should remain unchanged. + """ + proxy_managed_files = _PROXY_LiteLLMManagedFiles( + DualCache(), prisma_client=MagicMock() + ) + + managed_file_id = "managed-file-abc" + + kwargs = { + "input_file_id": managed_file_id, + "model_file_id_mapping": { + managed_file_id: {"some-model": "provider-file-xyz"}, + }, + } + + result = await proxy_managed_files.async_pre_call_deployment_hook( + kwargs=kwargs, call_type=CallTypes.acreate_batch + ) + + assert result["input_file_id"] == managed_file_id, ( + "File ID should remain unchanged when model_info is not available" + ) + + # def test_list_managed_files(): # proxy_managed_files = _PROXY_LiteLLMManagedFiles(DualCache()) diff --git a/tests/test_litellm/llms/vertex_ai/files/test_vertex_ai_files_transformation.py b/tests/test_litellm/llms/vertex_ai/files/test_vertex_ai_files_transformation.py new file mode 100644 index 00000000000..6f1d753484d --- /dev/null +++ b/tests/test_litellm/llms/vertex_ai/files/test_vertex_ai_files_transformation.py @@ -0,0 +1,184 @@ +""" +Tests for VertexAIFilesConfig transformation methods (Issues 5-7). +""" + +import json +import urllib.parse + +import httpx +import pytest +from unittest.mock import MagicMock + +from litellm.llms.vertex_ai.files.transformation import VertexAIFilesConfig +from litellm.types.llms.openai import OpenAIFileObject, HttpxBinaryResponseContent +from openai.types.file_deleted import FileDeleted + + +@pytest.fixture +def config(): + return VertexAIFilesConfig() + + +class TestParseGcsUri: + """Tests for the _parse_gcs_uri helper used by retrieve / content / delete.""" + + def test_should_parse_standard_gs_uri(self, config): + bucket, encoded = config._parse_gcs_uri( + "gs://my-bucket/path/to/object.jsonl" + ) + assert bucket == "my-bucket" + assert encoded == urllib.parse.quote("path/to/object.jsonl", safe="") + + def test_should_parse_uri_with_nested_publisher_path(self, config): + uri = "gs://litellm-local/litellm-vertex-files/publishers/google/models/gemini-2.0-flash-001/abc-123" + bucket, encoded = config._parse_gcs_uri(uri) + assert bucket == "litellm-local" + expected_path = "litellm-vertex-files/publishers/google/models/gemini-2.0-flash-001/abc-123" + assert encoded == urllib.parse.quote(expected_path, safe="") + + def test_should_handle_url_encoded_input(self, config): + encoded_uri = urllib.parse.quote("gs://my-bucket/some/path", safe="") + bucket, encoded = config._parse_gcs_uri(encoded_uri) + assert bucket == "my-bucket" + assert encoded == urllib.parse.quote("some/path", safe="") + + def test_should_handle_bucket_only(self, config): + bucket, encoded = config._parse_gcs_uri("gs://my-bucket") + assert bucket == "my-bucket" + assert encoded == "" + + def test_should_handle_no_gs_prefix(self, config): + bucket, encoded = config._parse_gcs_uri("my-bucket/object.txt") + assert bucket == "my-bucket" + assert encoded == "object.txt" + +class TestTransformRetrieveFile: + + def test_should_build_correct_gcs_metadata_url(self, config): + file_id = "gs://my-bucket/path/to/file.jsonl" + url, params = config.transform_retrieve_file_request( + file_id=file_id, optional_params={}, litellm_params={} + ) + expected_encoded = urllib.parse.quote("path/to/file.jsonl", safe="") + assert url == f"https://storage.googleapis.com/storage/v1/b/my-bucket/o/{expected_encoded}" + assert params == {} + + def test_should_return_openai_file_object_from_gcs_response(self, config): + gcs_json = { + "id": "my-bucket/path/to/file.jsonl/123456", + "name": "path/to/file.jsonl", + "size": "4096", + "timeCreated": "2025-02-15T10:00:00.000Z", + "metadata": {"purpose": "batch"}, + } + raw_response = MagicMock(spec=httpx.Response) + raw_response.json.return_value = gcs_json + + result = config.transform_retrieve_file_response( + raw_response=raw_response, + logging_obj=MagicMock(), + litellm_params={}, + ) + + assert isinstance(result, OpenAIFileObject) + assert result.id == "gs://my-bucket/path/to/file.jsonl" + assert result.filename == "path/to/file.jsonl" + assert result.bytes == 4096 + assert result.object == "file" + assert result.status == "processed" + assert result.purpose == "batch" + + def test_should_default_purpose_to_batch_when_metadata_missing(self, config): + gcs_json = { + "id": "bucket/obj/999", + "name": "obj", + "size": "0", + "timeCreated": "2025-01-01T00:00:00.000Z", + } + raw_response = MagicMock(spec=httpx.Response) + raw_response.json.return_value = gcs_json + + result = config.transform_retrieve_file_response( + raw_response=raw_response, + logging_obj=MagicMock(), + litellm_params={}, + ) + assert result.purpose == "batch" + + +class TestTransformFileContent: + + def test_should_build_gcs_media_download_url(self, config): + file_id = "gs://my-bucket/path/to/file.jsonl" + url, params = config.transform_file_content_request( + file_content_request={"file_id": file_id}, + optional_params={}, + litellm_params={}, + ) + encoded = urllib.parse.quote("path/to/file.jsonl", safe="") + assert url == f"https://storage.googleapis.com/storage/v1/b/my-bucket/o/{encoded}?alt=media" + assert params == {} + + def test_should_return_binary_response_content(self, config): + raw_response = httpx.Response( + status_code=200, + content=b'{"line": 1}\n{"line": 2}\n', + headers={"content-type": "application/octet-stream"}, + request=httpx.Request("GET", "https://example.com"), + ) + + result = config.transform_file_content_response( + raw_response=raw_response, + logging_obj=MagicMock(), + litellm_params={}, + ) + + assert isinstance(result, HttpxBinaryResponseContent) + assert result.response.content == b'{"line": 1}\n{"line": 2}\n' + + +class TestTransformDeleteFile: + def test_should_build_correct_gcs_delete_url(self, config): + file_id = "gs://my-bucket/path/to/file.jsonl" + url, params = config.transform_delete_file_request( + file_id=file_id, optional_params={}, litellm_params={} + ) + encoded = urllib.parse.quote("path/to/file.jsonl", safe="") + assert url == f"https://storage.googleapis.com/storage/v1/b/my-bucket/o/{encoded}" + assert params == {} + + def test_should_return_file_deleted_with_reconstructed_id(self, config): + raw_response = MagicMock(spec=httpx.Response) + mock_request = MagicMock() + encoded_name = urllib.parse.quote( + "litellm-vertex-files/publishers/google/models/gemini-2.0-flash-001/abc", safe="" + ) + mock_request.url = ( + f"https://storage.googleapis.com/storage/v1/b/my-bucket/o/{encoded_name}" + ) + raw_response.request = mock_request + + result = config.transform_delete_file_response( + raw_response=raw_response, + logging_obj=MagicMock(), + litellm_params={}, + ) + + assert isinstance(result, FileDeleted) + assert result.deleted is True + assert result.object == "file" + assert "litellm-vertex-files/publishers/google/models/gemini-2.0-flash-001/abc" in result.id + + def test_should_fallback_to_deleted_id_when_no_request(self, config): + raw_response = MagicMock(spec=httpx.Response) + raw_response.request = None + + result = config.transform_delete_file_response( + raw_response=raw_response, + logging_obj=MagicMock(), + litellm_params={}, + ) + + assert isinstance(result, FileDeleted) + assert result.id == "deleted" + assert result.deleted is True diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_vertex_ai_batch_passthrough.py b/tests/test_litellm/proxy/pass_through_endpoints/test_vertex_ai_batch_passthrough.py index 66c063d47d8..c2f6d3fd539 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_vertex_ai_batch_passthrough.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_vertex_ai_batch_passthrough.py @@ -227,52 +227,29 @@ class TestVertexAIBatchPassthroughHandler: mock_managed_files_hook.store_unified_object_id.assert_called_once() def test_batch_cost_calculation_integration(self): - """Test integration with batch cost calculation""" + """Single Vertex AI response → non-zero cost with correct token counts.""" from litellm.batches.batch_utils import calculate_vertex_ai_batch_cost_and_usage - - # Mock Vertex AI batch responses + vertex_ai_batch_responses = [ { - "status": "JOB_STATE_SUCCEEDED", "response": { - "candidates": [ - { - "content": { - "parts": [ - {"text": "Hello, world!"} - ] - } - } - ], "usageMetadata": { "promptTokenCount": 10, "candidatesTokenCount": 5, - "totalTokenCount": 15 + "totalTokenCount": 15, } } } ] - - with patch('litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini.VertexGeminiConfig') as mock_config: - with patch('litellm.completion_cost') as mock_completion_cost: - - # Setup mocks - mock_config.return_value._transform_google_generate_content_to_openai_model_response.return_value = Mock( - usage=Mock(total_tokens=15, prompt_tokens=10, completion_tokens=5) - ) - mock_completion_cost.return_value = 0.001 - - # Test the cost calculation - total_cost, usage = calculate_vertex_ai_batch_cost_and_usage( - vertex_ai_batch_responses, - model_name="gemini-1.5-flash" - ) - - # Verify results - assert total_cost == 0.001 - assert usage.total_tokens == 15 - assert usage.prompt_tokens == 10 - assert usage.completion_tokens == 5 + + total_cost, usage = calculate_vertex_ai_batch_cost_and_usage( + vertex_ai_batch_responses, model_name="gemini-1.5-flash-001" + ) + + assert usage.total_tokens == 15 + assert usage.prompt_tokens == 10 + assert usage.completion_tokens == 5 + assert total_cost > 0, "batch_cost_calculator should return a non-zero cost" def test_batch_response_transformation(self): """Test transformation of Vertex AI batch responses to OpenAI format""" @@ -385,155 +362,107 @@ class TestVertexAIBatchPassthroughHandler: class TestVertexAIBatchCostCalculation: - """Test cases for Vertex AI batch cost calculation functionality""" + """Test cases for Vertex AI batch cost calculation functionality. - def test_calculate_vertex_ai_batch_cost_and_usage_success(self): - """Test successful batch cost and usage calculation""" + The function under test (calculate_vertex_ai_batch_cost_and_usage) extracts + usageMetadata directly from Vertex AI response dicts and calls + batch_cost_calculator — no VertexGeminiConfig transformation involved. + """ + + def test_should_aggregate_cost_and_usage_across_responses(self): + """Two successful responses → costs and token counts are summed.""" from litellm.batches.batch_utils import calculate_vertex_ai_batch_cost_and_usage - - # Mock successful batch responses - vertex_ai_batch_responses = [ + + responses = [ { - "status": "JOB_STATE_SUCCEEDED", "response": { - "candidates": [ - { - "content": { - "parts": [ - {"text": "Hello, world!"} - ] - } - } - ], "usageMetadata": { "promptTokenCount": 10, "candidatesTokenCount": 5, - "totalTokenCount": 15 + "totalTokenCount": 15, } } }, { - "status": "JOB_STATE_SUCCEEDED", "response": { - "candidates": [ - { - "content": { - "parts": [ - {"text": "How are you?"} - ] - } - } - ], "usageMetadata": { "promptTokenCount": 8, "candidatesTokenCount": 3, - "totalTokenCount": 11 + "totalTokenCount": 11, } } - } + }, ] - - with patch('litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini.VertexGeminiConfig') as mock_config: - with patch('litellm.completion_cost') as mock_completion_cost: - - # Setup mocks - mock_model_response = Mock() - mock_model_response.usage = Mock(total_tokens=15, prompt_tokens=10, completion_tokens=5) - mock_config.return_value._transform_google_generate_content_to_openai_model_response.return_value = mock_model_response - mock_completion_cost.return_value = 0.001 - - # Test the calculation - total_cost, usage = calculate_vertex_ai_batch_cost_and_usage( - vertex_ai_batch_responses, - model_name="gemini-1.5-flash" - ) - - # Verify results - assert total_cost == 0.002 # 2 responses * 0.001 each - assert usage.total_tokens == 30 # 15 + 15 - assert usage.prompt_tokens == 20 # 10 + 10 - assert usage.completion_tokens == 10 # 5 + 5 - def test_calculate_vertex_ai_batch_cost_and_usage_with_failed_responses(self): - """Test batch cost calculation with some failed responses""" + total_cost, usage = calculate_vertex_ai_batch_cost_and_usage( + responses, model_name="gemini-1.5-flash-001" + ) + + assert usage.prompt_tokens == 18 + assert usage.completion_tokens == 8 + assert usage.total_tokens == 26 + assert total_cost > 0, "batch_cost_calculator should return a non-zero cost" + + def test_should_skip_responses_with_null_response_body(self): + """Failed lines (response: None) are skipped without error.""" from litellm.batches.batch_utils import calculate_vertex_ai_batch_cost_and_usage - - # Mock batch responses with some failures - vertex_ai_batch_responses = [ + + responses = [ { - "status": "JOB_STATE_SUCCEEDED", "response": { - "candidates": [ - { - "content": { - "parts": [ - {"text": "Hello, world!"} - ] - } - } - ], "usageMetadata": { "promptTokenCount": 10, "candidatesTokenCount": 5, - "totalTokenCount": 15 + "totalTokenCount": 15, } } }, + {"status": "JOB_STATE_FAILED", "response": None}, { - "status": "JOB_STATE_FAILED", # Failed response - "response": None - }, - { - "status": "JOB_STATE_SUCCEEDED", "response": { - "candidates": [ - { - "content": { - "parts": [ - {"text": "How are you?"} - ] - } - } - ], "usageMetadata": { "promptTokenCount": 8, "candidatesTokenCount": 3, - "totalTokenCount": 11 + "totalTokenCount": 11, } } - } + }, ] - - with patch('litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini.VertexGeminiConfig') as mock_config: - with patch('litellm.completion_cost') as mock_completion_cost: - - # Setup mocks - mock_model_response = Mock() - mock_model_response.usage = Mock(total_tokens=15, prompt_tokens=10, completion_tokens=5) - mock_config.return_value._transform_google_generate_content_to_openai_model_response.return_value = mock_model_response - mock_completion_cost.return_value = 0.001 - - # Test the calculation - total_cost, usage = calculate_vertex_ai_batch_cost_and_usage( - vertex_ai_batch_responses, - model_name="gemini-1.5-flash" - ) - - # Verify results - should only process successful responses - assert total_cost == 0.002 # 2 successful responses * 0.001 each - assert usage.total_tokens == 30 # 15 + 15 - assert usage.prompt_tokens == 20 # 10 + 10 - assert usage.completion_tokens == 10 # 5 + 5 - def test_calculate_vertex_ai_batch_cost_and_usage_empty_responses(self): - """Test batch cost calculation with empty response list""" + total_cost, usage = calculate_vertex_ai_batch_cost_and_usage( + responses, model_name="gemini-1.5-flash-001" + ) + + assert usage.prompt_tokens == 18 + assert usage.completion_tokens == 8 + assert usage.total_tokens == 26 + assert total_cost > 0 + + def test_should_return_zeros_for_empty_response_list(self): + """Empty input → zero cost and zero usage.""" from litellm.batches.batch_utils import calculate_vertex_ai_batch_cost_and_usage - - # Test with empty list - total_cost, usage = calculate_vertex_ai_batch_cost_and_usage([], model_name="gemini-1.5-flash") - - # Verify results + + total_cost, usage = calculate_vertex_ai_batch_cost_and_usage( + [], model_name="gemini-1.5-flash-001" + ) + assert total_cost == 0.0 assert usage.total_tokens == 0 assert usage.prompt_tokens == 0 assert usage.completion_tokens == 0 + + def test_should_handle_missing_usage_metadata_gracefully(self): + """Response without usageMetadata → 0 tokens, 0 cost for that line.""" + from litellm.batches.batch_utils import calculate_vertex_ai_batch_cost_and_usage + + responses = [ + {"response": {"candidates": [{"content": {"parts": [{"text": "hi"}]}}]}}, + ] + + total_cost, usage = calculate_vertex_ai_batch_cost_and_usage( + responses, model_name="gemini-1.5-flash-001" + ) + + assert usage.prompt_tokens == 0 + assert usage.completion_tokens == 0 + assert usage.total_tokens == 0 From 705ef64ffcb20d9ee5cb9557e8f66e713b1b0167 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 28 Feb 2026 18:19:07 -0800 Subject: [PATCH 03/69] fix(ui): Audit logs - server-side pagination, filtering, and drawer view - Replace client-side full-fetch loop with single react-query call using keepPreviousData; remove 5-second polling - All filters (object ID, action, table, changed_by, team ID, key hash) now passed as query params to the backend - Add object_team_id and object_key_hash params to /audit endpoint using Prisma JSON path filtering (PostgreSQL) to search inside before_value and updated_values JSON columns - Migrate table from custom TanStack DataTable to AntD Table with server-side pagination - Replace inline row expansion with a right-side AntD Drawer showing metadata and before/after diff - Refactor uiAuditLogsCall to accept a structured options object Co-Authored-By: Claude Sonnet 4.6 --- .../proxy/audit_logging_endpoints.py | 51 +- .../src/components/networking.tsx | 53 +- .../AuditLogDrawer/AuditLogDrawer.tsx | 217 +++++ .../src/components/view_logs/audit_logs.tsx | 853 ++++++------------ 4 files changed, 571 insertions(+), 603 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.tsx diff --git a/enterprise/litellm_enterprise/proxy/audit_logging_endpoints.py b/enterprise/litellm_enterprise/proxy/audit_logging_endpoints.py index d1b00420d31..5ab3669b50c 100644 --- a/enterprise/litellm_enterprise/proxy/audit_logging_endpoints.py +++ b/enterprise/litellm_enterprise/proxy/audit_logging_endpoints.py @@ -1,13 +1,13 @@ """ AUDIT LOGGING -All /audit logging endpoints. Attempting to write these as CRUD endpoints. +All /audit logging endpoints. Attempting to write these as CRUD endpoints. GET - /audit/{id} - Get audit log by id GET - /audit - Get all audit logs """ -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional #### AUDIT LOGGING #### from fastapi import APIRouter, Depends, HTTPException, Query @@ -22,6 +22,21 @@ from litellm.proxy.auth.user_api_key_auth import user_api_key_auth router = APIRouter() +def _build_json_field_conditions( + field: str, json_key: str, value: str +) -> List[Dict[str, Any]]: + """ + Build OR conditions to match a value inside a JSON column at the given key. + + Uses Prisma's JSON path filtering (PostgreSQL only). Returns a list of + two conditions — one for `before_value` and one for `updated_values` — to + be merged into the caller's top-level OR list. + """ + return [ + {field: {"path": [json_key], "string_contains": value}}, + ] + + @router.get( "/audit", tags=["Audit Logging"], @@ -49,6 +64,14 @@ async def get_audit_logs( ), start_date: Optional[str] = Query(None, description="Filter logs after this date"), end_date: Optional[str] = Query(None, description="Filter logs before this date"), + object_team_id: Optional[str] = Query( + None, + description="Filter by team_id present in before_value or updated_values JSON (PostgreSQL only)", + ), + object_key_hash: Optional[str] = Query( + None, + description="Filter by token (key hash) present in before_value or updated_values JSON (PostgreSQL only)", + ), # Sorting parameters sort_by: Optional[str] = Query( None, @@ -60,6 +83,9 @@ async def get_audit_logs( Get all audit logs with filtering and pagination. Returns a paginated response of audit logs matching the specified filters. + + Note: object_team_id and object_key_hash use Prisma JSON path filtering, + which requires PostgreSQL. """ from litellm.proxy.proxy_server import prisma_client @@ -82,18 +108,33 @@ async def get_audit_logs( if object_id: where_conditions["object_id"] = object_id if start_date or end_date: - date_filter = {} + date_filter: Dict[str, Any] = {} if start_date: date_filter["gte"] = start_date if end_date: date_filter["lte"] = end_date where_conditions["updated_at"] = date_filter + # JSON field filters (PostgreSQL only) — search inside before_value and + # updated_values for a matching key/value pair. + if object_team_id: + where_conditions["OR"] = [ + *_build_json_field_conditions("before_value", "team_id", object_team_id), + *_build_json_field_conditions("updated_values", "team_id", object_team_id), + ] + if object_key_hash: + existing_or: List[Dict[str, Any]] = where_conditions.get("OR", []) + where_conditions["OR"] = [ + *existing_or, + *_build_json_field_conditions("before_value", "token", object_key_hash), + *_build_json_field_conditions("updated_values", "token", object_key_hash), + ] + # Build sort conditions - order_by = {} + order_by: Dict[str, Any] = {} if sort_by and isinstance(sort_by, str): order_by[sort_by] = sort_order - elif sort_order and isinstance(sort_order, str): + else: order_by["updated_at"] = sort_order # Default sort by updated_at # Get paginated results diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 0df6d813d7c..919246ee851 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -8642,30 +8642,46 @@ export const updateSSOSettings = async (accessToken: string, settings: Record { +export interface UiAuditLogsParams { + action?: string; + table_name?: string; + object_id?: string; + changed_by?: string; + changed_by_api_key?: string; + object_team_id?: string; + object_key_hash?: string; + sort_by?: string; + sort_order?: "asc" | "desc"; +} + +export interface UiAuditLogsCallOptions { + accessToken: string; + page?: number; + page_size?: number; + params?: UiAuditLogsParams; +} + +export const uiAuditLogsCall = async ({ + accessToken, + page = 1, + page_size = 50, + params = {}, +}: UiAuditLogsCallOptions) => { try { - // Construct base URL let url = proxyBaseUrl ? `${proxyBaseUrl}/audit` : `/audit`; - // Add query parameters if they exist const queryParams = new URLSearchParams(); - // if (start_date) queryParams.append('start_date', start_date); - // if (end_date) queryParams.append('end_date', end_date); - if (page) queryParams.append("page", page.toString()); - if (page_size) queryParams.append("page_size", page_size.toString()); + queryParams.append("page", page.toString()); + queryParams.append("page_size", page_size.toString()); - // Append query parameters to URL if any exist - const queryString = queryParams.toString(); - if (queryString) { - url += `?${queryString}`; + for (const [key, value] of Object.entries(params)) { + if (value != null && value !== "") { + queryParams.append(key, String(value)); + } } + url += `?${queryParams.toString()}`; + const response = await fetch(url, { method: "GET", headers: { @@ -8681,8 +8697,7 @@ export const uiAuditLogsCall = async ( throw new Error(errorMessage); } - const data = await response.json(); - return data; + return await response.json(); } catch (error) { console.error("Failed to fetch audit logs:", error); throw error; diff --git a/ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.tsx b/ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.tsx new file mode 100644 index 00000000000..1e8dca34f22 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.tsx @@ -0,0 +1,217 @@ +import { Drawer, Tag, Tooltip } from "antd"; +import { CloseOutlined } from "@ant-design/icons"; +import moment from "moment"; +import { AuditLogEntry } from "../columns"; + +interface AuditLogDrawerProps { + open: boolean; + onClose: () => void; + log: AuditLogEntry | null; +} + +const TABLE_NAME_DISPLAY: Record = { + LiteLLM_VerificationToken: "Keys", + LiteLLM_TeamTable: "Teams", + LiteLLM_UserTable: "Users", + LiteLLM_OrganizationTable: "Organizations", + LiteLLM_ProxyModelTable: "Models", +}; + +const ACTION_COLOR: Record = { + created: "green", + updated: "blue", + deleted: "red", + rotated: "orange", +}; + +function JsonBlock({ value }: { value: Record }) { + return ( +
+      {JSON.stringify(value, null, 2)}
+    
+ ); +} + +function MetadataRow({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+ {label} + {value} +
+ ); +} + +function DiffSection({ log }: { log: AuditLogEntry }) { + const { action, table_name, before_value, updated_values } = log; + const isKeyTable = table_name === "LiteLLM_VerificationToken"; + const isUpdateAction = action === "updated" || action === "rotated"; + + let displayBefore = before_value; + let displayAfter = updated_values; + + if (isUpdateAction && before_value && updated_values) { + const changedBefore: Record = {}; + const changedAfter: Record = {}; + const allKeys = new Set([ + ...Object.keys(before_value), + ...Object.keys(updated_values), + ]); + + allKeys.forEach((key) => { + const bStr = JSON.stringify(before_value[key]); + const aStr = JSON.stringify(updated_values[key]); + if (bStr !== aStr) { + if (key in before_value) changedBefore[key] = before_value[key]; + if (key in updated_values) changedAfter[key] = updated_values[key]; + } + }); + + // Fields only in before (removed) + Object.keys(before_value).forEach((key) => { + if (!(key in updated_values) && !(key in changedBefore)) { + changedBefore[key] = before_value[key]; + changedAfter[key] = undefined; + } + }); + + // Fields only in after (added) + Object.keys(updated_values).forEach((key) => { + if (!(key in before_value) && !(key in changedAfter)) { + changedAfter[key] = updated_values[key]; + changedBefore[key] = undefined; + } + }); + + displayBefore = + Object.keys(changedBefore).length > 0 + ? changedBefore + : { note: "No differing fields detected" }; + displayAfter = + Object.keys(changedAfter).length > 0 + ? changedAfter + : { note: "No differing fields detected" }; + } + + const renderValue = (value: Record | null | undefined, label: string) => { + if (!value || Object.keys(value).length === 0) { + return

N/A

; + } + + // For key table updates, filter to only show meaningful fields + if (isKeyTable && isUpdateAction) { + const knownKeyFields = ["token", "spend", "max_budget"]; + const hasOnlyKnown = Object.keys(value).every((k) => knownKeyFields.includes(k)); + if (hasOnlyKnown && !("note" in value)) { + return ( +
+ {value.token !== undefined && ( +

Token: {value.token ?? "N/A"}

+ )} + {value.spend !== undefined && ( +

Spend: ${Number(value.spend).toFixed(6)}

+ )} + {value.max_budget !== undefined && ( +

Max Budget: ${Number(value.max_budget).toFixed(6)}

+ )} +
+ ); + } + } + + return ; + }; + + return ( +
+
+

Before

+ {renderValue(displayBefore, "before")} +
+
+

After

+ {renderValue(displayAfter, "after")} +
+
+ ); +} + +export function AuditLogDrawer({ open, onClose, log }: AuditLogDrawerProps) { + if (!log) return null; + + const tableDisplay = TABLE_NAME_DISPLAY[log.table_name] ?? log.table_name; + const actionColor = ACTION_COLOR[log.action] ?? "default"; + + return ( + + {/* Header */} +
+
+ + {log.action} + + + {moment.utc(log.updated_at).local().format("MMM D, YYYY HH:mm:ss")} + +
+ +
+ + {/* Body */} +
+ {/* Metadata */} +
+

+ Details +

+ + + {log.object_id} + + } + /> + + + + {log.changed_by_api_key.slice(0, 12)}… + + + ) : ( + "—" + ) + } + /> +
+ + {/* Diff */} +
+

+ Changes +

+ +
+
+
+ ); +} diff --git a/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx b/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx index 918447a6149..d3372eca6ff 100644 --- a/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx @@ -1,12 +1,15 @@ -import { DataTable } from "./table"; +import { useState } from "react"; +import { useQuery, keepPreviousData } from "@tanstack/react-query"; +import { Table, Tag, Input, Select, Button, Tooltip } from "antd"; +import { ReloadOutlined } from "@ant-design/icons"; +import type { ColumnsType, TablePaginationConfig } from "antd/es/table"; import moment from "moment"; -import { useRef, useState, useEffect, useCallback, useMemo } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { uiAuditLogsCall, keyListCall } from "../networking"; -import { AuditLogEntry, auditLogColumns } from "./columns"; -import { Text } from "@tremor/react"; +import { uiAuditLogsCall } from "../networking"; +import { AuditLogEntry } from "./columns"; +import { AuditLogDrawer } from "./AuditLogDrawer/AuditLogDrawer"; import { Team } from "../key_team_helpers/key_list"; -import { formatNumberWithCommas } from "@/utils/dataUtils"; + +const { Search } = Input; interface AuditLogsProps { accessToken: string | null; @@ -21,6 +24,23 @@ interface AuditLogsProps { const asset_logos_folder = "../ui/assets/"; export const auditLogsPreviewImg = `${asset_logos_folder}audit-logs-preview.png`; +const TABLE_NAME_DISPLAY: Record = { + LiteLLM_VerificationToken: "Keys", + LiteLLM_TeamTable: "Teams", + LiteLLM_UserTable: "Users", + LiteLLM_OrganizationTable: "Organizations", + LiteLLM_ProxyModelTable: "Models", +}; + +const ACTION_COLOR: Record = { + created: "green", + updated: "blue", + deleted: "red", + rotated: "orange", +}; + +const PAGE_SIZE = 50; + export default function AuditLogs({ userID, userRole, @@ -28,413 +48,144 @@ export default function AuditLogs({ accessToken, isActive, premiumUser, - allTeams, }: AuditLogsProps) { - const [startTime, setStartTime] = useState(moment().subtract(24, "hours").format("YYYY-MM-DDTHH:mm")); + const [page, setPage] = useState(1); - const actionFilterRef = useRef(null); - const tableFilterRef = useRef(null); - const [clientCurrentPage, setClientCurrentPage] = useState(1); - const [pageSize] = useState(50); - const [filters, setFilters] = useState>({}); - const [selectedTeamId, setSelectedTeamId] = useState(""); - const [selectedKeyHash, setSelectedKeyHash] = useState(""); - const [objectIdSearch, setObjectIdSearch] = useState(""); - const [selectedActionFilter, setSelectedActionFilter] = useState("all"); - const [selectedTableFilter, setSelectedTableFilter] = useState("all"); - const [actionFilterOpen, setActionFilterOpen] = useState(false); - const [tableFilterOpen, setTableFilterOpen] = useState(false); + // Filter state + const [objectId, setObjectId] = useState(""); + const [changedBy, setChangedBy] = useState(""); + const [keyHash, setKeyHash] = useState(""); + const [teamId, setTeamId] = useState(""); + const [action, setAction] = useState(undefined); + const [tableName, setTableName] = useState(undefined); - const allLogsQuery = useQuery({ - queryKey: ["all_audit_logs", accessToken, token, userRole, userID, startTime], + // Drawer state + const [selectedLog, setSelectedLog] = useState(null); + const [drawerOpen, setDrawerOpen] = useState(false); + + const query = useQuery({ + queryKey: [ + "audit_logs", + page, + PAGE_SIZE, + objectId, + changedBy, + keyHash, + teamId, + action, + tableName, + ], queryFn: async () => { if (!accessToken || !token || !userRole || !userID) { - return []; + return { audit_logs: [], total: 0, page: 1, page_size: PAGE_SIZE, total_pages: 0 }; } - - const formattedStartTimeStr = moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss"); - const formattedEndTimeStr = moment().utc().format("YYYY-MM-DD HH:mm:ss"); - - let accumulatedLogs: AuditLogEntry[] = []; - let currentPageToFetch = 1; - let totalPagesFromBackend = 1; - const backendPageSize = 50; - - do { - const response = await uiAuditLogsCall( - accessToken, - formattedStartTimeStr, - formattedEndTimeStr, - currentPageToFetch, - backendPageSize, - ); - accumulatedLogs = accumulatedLogs.concat(response.audit_logs); - totalPagesFromBackend = response.total_pages; - currentPageToFetch++; - } while (currentPageToFetch <= totalPagesFromBackend); - - return accumulatedLogs; + return uiAuditLogsCall({ + accessToken, + page, + page_size: PAGE_SIZE, + params: { + object_id: objectId || undefined, + changed_by: changedBy || undefined, + object_key_hash: keyHash || undefined, + object_team_id: teamId || undefined, + action: action || undefined, + table_name: tableName || undefined, + sort_by: "updated_at", + sort_order: "desc", + }, + }); }, enabled: !!accessToken && !!token && !!userRole && !!userID && isActive, - refetchInterval: 5000, - refetchIntervalInBackground: true, + placeholderData: keepPreviousData, }); - const handleRefresh = () => { - allLogsQuery.refetch(); + const handleFilterChange = () => { + // Reset to page 1 whenever a filter changes + setPage(1); }; - const handleFilterChange = (newFilters: Record) => { - setFilters(newFilters); + const handleTableChange = (pagination: TablePaginationConfig) => { + setPage(pagination.current ?? 1); }; - const handleFilterReset = () => { - setFilters({}); - setSelectedTeamId(""); - setSelectedKeyHash(""); - setObjectIdSearch(""); - setSelectedActionFilter("all"); - setSelectedTableFilter("all"); - setClientCurrentPage(1); + const handleRowClick = (log: AuditLogEntry) => { + setSelectedLog(log); + setDrawerOpen(true); }; - const fetchKeyHashForAlias = useCallback( - async (keyAlias: string) => { - if (!accessToken) return; - - try { - const response = await keyListCall(accessToken, null, null, keyAlias, null, null, 1, 10); - - const selectedKey = response.keys.find((key: any) => key.key_alias === keyAlias); - - if (selectedKey) { - setSelectedKeyHash(selectedKey.token); - } else { - setSelectedKeyHash(""); - } - } catch (error) { - console.error("Error fetching key hash for alias:", error); - setSelectedKeyHash(""); - } + const columns: ColumnsType = [ + { + title: "Timestamp", + dataIndex: "updated_at", + key: "updated_at", + width: 200, + render: (val: string) => ( + + {moment.utc(val).local().format("MMM D, YYYY HH:mm:ss")} + + ), }, - [accessToken], - ); - - useEffect(() => { - if (!accessToken) return; - - let teamIdChanged = false; - let keyHashChanged = false; - - if (filters["Team ID"]) { - if (selectedTeamId !== filters["Team ID"]) { - setSelectedTeamId(filters["Team ID"]); - teamIdChanged = true; - } - } else { - if (selectedTeamId !== "") { - setSelectedTeamId(""); - teamIdChanged = true; - } - } - - if (filters["Key Hash"]) { - if (selectedKeyHash !== filters["Key Hash"]) { - setSelectedKeyHash(filters["Key Hash"]); - keyHashChanged = true; - } - } else if (filters["Key Alias"]) { - fetchKeyHashForAlias(filters["Key Alias"]); - } else { - if (selectedKeyHash !== "") { - setSelectedKeyHash(""); - keyHashChanged = true; - } - } - - if (teamIdChanged || keyHashChanged) { - setClientCurrentPage(1); - } - }, [filters, accessToken, fetchKeyHashForAlias, selectedTeamId, selectedKeyHash]); - - useEffect(() => { - setClientCurrentPage(1); - }, [selectedTeamId, selectedKeyHash, startTime, objectIdSearch, selectedActionFilter, selectedTableFilter]); - - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if (actionFilterRef.current && !actionFilterRef.current.contains(event.target as Node)) { - setActionFilterOpen(false); - } - if (tableFilterRef.current && !tableFilterRef.current.contains(event.target as Node)) { - setTableFilterOpen(false); - } - } - - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, []); - - const completeFilteredLogs = useMemo(() => { - if (!allLogsQuery.data) return []; - return allLogsQuery.data.filter((log) => { - let matchesTeam = true; - let matchesKey = true; - let matchesObjectId = true; - let matchesAction = true; - let matchesTable = true; - - if (selectedTeamId) { - const beforeTeamId = - typeof log.before_value === "string" ? JSON.parse(log.before_value)?.team_id : log.before_value?.team_id; - const updatedTeamId = - typeof log.updated_values === "string" - ? JSON.parse(log.updated_values)?.team_id - : log.updated_values?.team_id; - matchesTeam = beforeTeamId === selectedTeamId || updatedTeamId === selectedTeamId; - } - - if (selectedKeyHash) { - try { - const beforeBody = typeof log.before_value === "string" ? JSON.parse(log.before_value) : log.before_value; - const updatedBody = - typeof log.updated_values === "string" ? JSON.parse(log.updated_values) : log.updated_values; - - const beforeKey = beforeBody?.token; - const updatedKey = updatedBody?.token; - - matchesKey = - (typeof beforeKey === "string" && beforeKey.includes(selectedKeyHash)) || - (typeof updatedKey === "string" && updatedKey.includes(selectedKeyHash)); - } catch (e) { - matchesKey = false; - } - } - - if (objectIdSearch) { - matchesObjectId = log.object_id?.toLowerCase().includes(objectIdSearch.toLowerCase()); - } - - if (selectedActionFilter !== "all") { - matchesAction = log.action?.toLowerCase() === selectedActionFilter.toLowerCase(); - } - - if (selectedTableFilter !== "all") { - let tableMatchName = ""; - switch (selectedTableFilter) { - case "keys": - tableMatchName = "litellm_verificationtoken"; - break; - case "teams": - tableMatchName = "litellm_teamtable"; - break; - case "users": - tableMatchName = "litellm_usertable"; - break; - // Add other direct table names if needed, or rely on a more generic match - default: - tableMatchName = selectedTableFilter; // Should not happen with current UI options - } - matchesTable = log.table_name?.toLowerCase() === tableMatchName; - } - - return matchesTeam && matchesKey && matchesObjectId && matchesAction && matchesTable; - }); - }, [allLogsQuery.data, selectedTeamId, selectedKeyHash, objectIdSearch, selectedActionFilter, selectedTableFilter]); - - const totalFilteredItems = completeFilteredLogs.length; - const totalFilteredPages = Math.ceil(totalFilteredItems / pageSize) || 1; - - const paginatedViewOfFilteredLogs = useMemo(() => { - const start = (clientCurrentPage - 1) * pageSize; - const end = start + pageSize; - return completeFilteredLogs.slice(start, end); - }, [completeFilteredLogs, clientCurrentPage, pageSize]); - - // Check if audit logs are empty (not loading and no data) - const showAuditLogsInfo = !allLogsQuery.data || allLogsQuery.data.length === 0; - - // Custom AuditLogsInfoMessage component - const AuditLogsInfoMessage = ({ show }: { show: boolean }) => { - if (!show) return null; - - return ( -
-
- - - - - -
-
-

Audit Logs Not Available

-

- To enable audit logging, add the following configuration to your LiteLLM proxy configuration file: -

-
-            {`litellm_settings:
-  store_audit_logs: true`}
-          
-

- Note: This will only affect new requests after the configuration change and proxy restart. -

-
-
- ); - }; - - const renderSubComponent = useCallback(({ row }: { row: any }) => { - const AuditLogRowExpansionPanel = ({ rowData }: { rowData: AuditLogEntry }) => { - const { before_value, updated_values, table_name, action } = rowData; - - const renderValue = (value: Record, isKeyTable: boolean) => { - if (!value || Object.keys(value).length === 0) return N/A; - - if (isKeyTable) { - const changedKeys = Object.keys(value); - const knownKeyFields = ["token", "spend", "max_budget"]; - - const onlyKnownFieldsChanged = changedKeys.every((key) => knownKeyFields.includes(key)); - - if (onlyKnownFieldsChanged && changedKeys.length > 0) { - return ( -
- {changedKeys.includes("token") && ( -

- Token: {value.token || "N/A"} -

- )} - {changedKeys.includes("spend") && ( -

- Spend:{" "} - {value.spend !== undefined ? `$${formatNumberWithCommas(value.spend, 6)}` : "N/A"} -

- )} - {changedKeys.includes("max_budget") && ( -

- Max Budget:{" "} - {value.max_budget !== undefined ? `$${formatNumberWithCommas(value.max_budget, 6)}` : "N/A"} -

- )} -
- ); - } else { - if ( - value["No differing fields detected in 'before' state"] || - value["No differing fields detected in 'updated' state"] || - value["No fields changed"] - ) { - return {value[Object.keys(value)[0]]}; // Display the N/A message string - } - return ( -
-                {JSON.stringify(value, null, 2)}
-              
- ); - } - } - - return ( -
-            {JSON.stringify(value, null, 2)}
-          
- ); - }; - - let displayBeforeValue = before_value; - let displayUpdatedValue = updated_values; - - if ((action === "updated" || action === "rotated") && before_value && updated_values) { - if ( - table_name === "LiteLLM_TeamTable" || - table_name === "LiteLLM_UserTable" || - table_name === "LiteLLM_VerificationToken" - ) { - const changedBefore: Record = {}; - const changedUpdated: Record = {}; - const allKeys = new Set([...Object.keys(before_value), ...Object.keys(updated_values)]); - - allKeys.forEach((key) => { - const beforeValStr = JSON.stringify(before_value[key]); - const updatedValStr = JSON.stringify(updated_values[key]); - if (beforeValStr !== updatedValStr) { - if (before_value.hasOwnProperty(key)) { - changedBefore[key] = before_value[key]; - } - if (updated_values.hasOwnProperty(key)) { - changedUpdated[key] = updated_values[key]; - } - } - }); - - Object.keys(before_value).forEach((key) => { - if (!updated_values.hasOwnProperty(key) && !changedBefore.hasOwnProperty(key)) { - changedBefore[key] = before_value[key]; - changedUpdated[key] = undefined; - } - }); - - Object.keys(updated_values).forEach((key) => { - if (!before_value.hasOwnProperty(key) && !changedUpdated.hasOwnProperty(key)) { - changedUpdated[key] = updated_values[key]; - changedBefore[key] = undefined; - } - }); - - displayBeforeValue = - Object.keys(changedBefore).length > 0 - ? changedBefore - : { "No differing fields detected in 'before' state": "N/A" }; - displayUpdatedValue = - Object.keys(changedUpdated).length > 0 - ? changedUpdated - : { "No differing fields detected in 'updated' state": "N/A" }; - - if (Object.keys(changedBefore).length === 0 && Object.keys(changedUpdated).length === 0) { - displayBeforeValue = { "No fields changed": "N/A" }; - displayUpdatedValue = { "No fields changed": "N/A" }; - } - } - } - - return ( -
-
-

Before Value:

- {renderValue(displayBeforeValue, table_name === "LiteLLM_VerificationToken")} -
-
-

Updated Value:

- {renderValue(displayUpdatedValue, table_name === "LiteLLM_VerificationToken")} -
-
- ); - }; - - return ; - }, []); + { + title: "Action", + dataIndex: "action", + key: "action", + width: 100, + render: (val: string) => ( + + {val} + + ), + }, + { + title: "Table", + dataIndex: "table_name", + key: "table_name", + width: 130, + render: (val: string) => TABLE_NAME_DISPLAY[val] ?? val, + }, + { + title: "Object ID", + dataIndex: "object_id", + key: "object_id", + render: (val: string) => ( + + {val} + + ), + }, + { + title: "Changed By", + dataIndex: "changed_by", + key: "changed_by", + width: 200, + render: (val: string) => val || "—", + }, + { + title: "API Key", + dataIndex: "changed_by_api_key", + key: "changed_by_api_key", + width: 140, + render: (val: string) => + val ? ( + + {val.slice(0, 12)}… + + ) : ( + "—" + ), + }, + ]; if (!premiumUser) { return (

✨ Enterprise Feature.

- +

This is a LiteLLM Enterprise feature, and requires a valid key to use. - - +

+

Here's a preview of what Audit Logs offer: - +

Audit Logs Preview { - console.error("Failed to load audit logs preview image"); (e.target as HTMLImageElement).style.display = "none"; }} /> @@ -454,204 +204,149 @@ export default function AuditLogs({ ); } - const currentDisplayItemsStart = totalFilteredItems > 0 ? (clientCurrentPage - 1) * pageSize + 1 : 0; - const currentDisplayItemsEnd = Math.min(clientCurrentPage * pageSize, totalFilteredItems); + const auditLogs: AuditLogEntry[] = query.data?.audit_logs ?? []; + const total: number = query.data?.total ?? 0; return ( <> -
- {/* */}
+ {/* Header */}
-

Audit Logs

+
+

Audit Logs

+ +
- {/* Show Audit Logs Info Message when no data */} - - -
-
-
-
- setObjectIdSearch(e.target.value)} - className="px-3 py-2 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" - /> -
- - -
-
- -
- {/* Custom Action Filter Dropdown */} -
- - - {actionFilterOpen && ( -
-
- {[ - { label: "All Actions", value: "all" }, - { label: "Created", value: "created" }, - { label: "Updated", value: "updated" }, - { label: "Deleted", value: "deleted" }, - { label: "Rotated", value: "rotated" }, - ].map((option) => ( - - ))} -
-
- )} -
- - {/* Custom Table Filter Dropdown */} -
- - - {tableFilterOpen && ( -
-
- {[ - { label: "All Tables", value: "all" }, - { label: "Keys", value: "keys" }, - { label: "Teams", value: "teams" }, - { label: "Users", value: "users" }, - ].map((option) => ( - - ))} -
-
- )} -
- - - Showing {allLogsQuery.isLoading ? "..." : currentDisplayItemsStart} -{" "} - {allLogsQuery.isLoading ? "..." : currentDisplayItemsEnd} of{" "} - {allLogsQuery.isLoading ? "..." : totalFilteredItems} results - -
- - Page {allLogsQuery.isLoading ? "..." : clientCurrentPage} of{" "} - {allLogsQuery.isLoading ? "..." : totalFilteredPages} - - - -
-
+ {/* Filters */} +
+ { + setObjectId(val); + handleFilterChange(); + }} + onChange={(e) => { + if (!e.target.value) { + setObjectId(""); + handleFilterChange(); + } + }} + /> + { + setChangedBy(val); + handleFilterChange(); + }} + onChange={(e) => { + if (!e.target.value) { + setChangedBy(""); + handleFilterChange(); + } + }} + /> + { + setTeamId(val); + handleFilterChange(); + }} + onChange={(e) => { + if (!e.target.value) { + setTeamId(""); + handleFilterChange(); + } + }} + /> + { + setKeyHash(val); + handleFilterChange(); + }} + onChange={(e) => { + if (!e.target.value) { + setKeyHash(""); + handleFilterChange(); + } + }} + /> + { + setTableName(val); + handleFilterChange(); + }} + />
- true} + + {/* Table */} + + columns={columns} + dataSource={auditLogs} + rowKey="id" + loading={query.isLoading} + size="small" + onRow={(record) => ({ + onClick: () => handleRowClick(record), + style: { cursor: "pointer" }, + })} + pagination={{ + current: page, + pageSize: PAGE_SIZE, + total, + showTotal: (t) => `${t} total`, + showSizeChanger: false, + onChange: (p) => setPage(p), + }} + onChange={handleTableChange} />
+ + setDrawerOpen(false)} + log={selectedLog} + /> ); } From 86d5b4c632f4b3e3279e64b1cd0426d985a00dfc Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Mon, 2 Mar 2026 18:43:07 -0800 Subject: [PATCH 04/69] feat: add Nebius AI Studio models to model_prices_and_context_window.json (#22614) Add 30 Nebius AI Studio models covering: - Text-to-text: DeepSeek (R1, R1-0528, R1-Distill, V3, V3-0324), Meta Llama (3.1-8B/70B/405B, 3.3-70B), Qwen (3-235B/32B/30B/14B/4B, 2.5-72B/32B, 2.5-Coder-7B, QwQ-32B), Mistral Nemo, NousResearch Hermes-3, NVIDIA Nemotron Ultra/Super, Google Gemma-3-27B, Llama-Guard-3 - Vision: Qwen2.5-VL-72B, Qwen2-VL-72B, Qwen2-VL-7B - Embedding: BAAI/bge-en-icl, BAAI/bge-multilingual-gemma2, intfloat/e5-mistral-7b Pricing sourced from https://nebius.com/prices-ai-studio (base flavor). Context windows sourced from https://docs.nebius.com/studio/inference/models/ Co-authored-by: Cursor Agent Co-authored-by: Ishaan Jaff --- ...odel_prices_and_context_window_backup.json | 333 +++++++++++++++++- model_prices_and_context_window.json | 333 +++++++++++++++++- 2 files changed, 662 insertions(+), 4 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 48a93830d4f..d4c5b476af6 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -23991,6 +23991,335 @@ "/v1/images/generations" ] }, + "nebius/deepseek-ai/DeepSeek-R1": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 8e-07, + "output_cost_per_token": 2.4e-06, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "supports_reasoning": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/deepseek-ai/DeepSeek-R1-0528": { + "max_tokens": 164000, + "max_input_tokens": 164000, + "max_output_tokens": 164000, + "input_cost_per_token": 8e-07, + "output_cost_per_token": 2.4e-06, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "supports_reasoning": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/deepseek-ai/DeepSeek-R1-Distill-Llama-70B": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 2.5e-07, + "output_cost_per_token": 7.5e-07, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/deepseek-ai/DeepSeek-V3": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 5e-07, + "output_cost_per_token": 1.5e-06, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/deepseek-ai/DeepSeek-V3-0324": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 5e-07, + "output_cost_per_token": 1.5e-06, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/google/gemma-3-27b-it": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 6e-08, + "output_cost_per_token": 2e-07, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "supports_vision": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/meta-llama/Llama-3.3-70B-Instruct": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 1.3e-07, + "output_cost_per_token": 4e-07, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/meta-llama/Llama-Guard-3-8B": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 2e-08, + "output_cost_per_token": 6e-08, + "litellm_provider": "nebius", + "mode": "chat", + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/meta-llama/Meta-Llama-3.1-8B-Instruct": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 2e-08, + "output_cost_per_token": 6e-08, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/meta-llama/Meta-Llama-3.1-70B-Instruct": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 1.3e-07, + "output_cost_per_token": 4e-07, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/meta-llama/Meta-Llama-3.1-405B-Instruct": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 1e-06, + "output_cost_per_token": 3e-06, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/mistralai/Mistral-Nemo-Instruct-2407": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 4e-08, + "output_cost_per_token": 1.2e-07, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/NousResearch/Hermes-3-Llama-3.1-405B": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 1e-06, + "output_cost_per_token": 3e-06, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/nvidia/Llama-3.1-Nemotron-Ultra-253B-v1": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 6e-07, + "output_cost_per_token": 1.8e-06, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/nvidia/Llama-3.3-Nemotron-Super-49B-v1": { + "max_tokens": 131072, + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "input_cost_per_token": 1e-07, + "output_cost_per_token": 4e-07, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/Qwen/Qwen3-235B-A22B": { + "max_tokens": 262144, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "input_cost_per_token": 2e-07, + "output_cost_per_token": 6e-07, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/Qwen/Qwen3-32B": { + "max_tokens": 32768, + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "input_cost_per_token": 1e-07, + "output_cost_per_token": 3e-07, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/Qwen/Qwen3-30B-A3B": { + "max_tokens": 32768, + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "input_cost_per_token": 1e-07, + "output_cost_per_token": 3e-07, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/Qwen/Qwen3-14B": { + "max_tokens": 32768, + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "input_cost_per_token": 8e-08, + "output_cost_per_token": 2.4e-07, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/Qwen/Qwen3-4B": { + "max_tokens": 32768, + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "input_cost_per_token": 8e-08, + "output_cost_per_token": 2.4e-07, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/Qwen/QwQ-32B": { + "max_tokens": 32768, + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "input_cost_per_token": 1.5e-07, + "output_cost_per_token": 4.5e-07, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "supports_reasoning": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/Qwen/Qwen2.5-72B-Instruct": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 1.3e-07, + "output_cost_per_token": 4e-07, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/Qwen/Qwen2.5-32B-Instruct": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 6e-08, + "output_cost_per_token": 2e-07, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/Qwen/Qwen2.5-Coder-7B": { + "max_tokens": 32768, + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "input_cost_per_token": 1e-08, + "output_cost_per_token": 3e-08, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/Qwen/Qwen2.5-VL-72B-Instruct": { + "max_tokens": 131072, + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "input_cost_per_token": 1.3e-07, + "output_cost_per_token": 4e-07, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "supports_vision": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/Qwen/Qwen2-VL-72B-Instruct": { + "max_tokens": 131072, + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "input_cost_per_token": 1.3e-07, + "output_cost_per_token": 4e-07, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "supports_vision": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/Qwen/Qwen2-VL-7B-Instruct": { + "max_tokens": 131072, + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "input_cost_per_token": 2e-08, + "output_cost_per_token": 6e-08, + "litellm_provider": "nebius", + "mode": "chat", + "supports_vision": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/BAAI/bge-en-icl": { + "max_tokens": 32768, + "max_input_tokens": 32768, + "input_cost_per_token": 1e-08, + "output_cost_per_token": 0.0, + "litellm_provider": "nebius", + "mode": "embedding", + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/BAAI/bge-multilingual-gemma2": { + "max_tokens": 8192, + "max_input_tokens": 8192, + "input_cost_per_token": 1e-08, + "output_cost_per_token": 0.0, + "litellm_provider": "nebius", + "mode": "embedding", + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/intfloat/e5-mistral-7b-instruct": { + "max_tokens": 32768, + "max_input_tokens": 32768, + "input_cost_per_token": 1e-08, + "output_cost_per_token": 0.0, + "litellm_provider": "nebius", + "mode": "embedding", + "source": "https://nebius.com/prices-ai-studio" + }, "nvidia.nemotron-nano-12b-v2": { "input_cost_per_token": 2e-07, "litellm_provider": "bedrock_converse", @@ -26953,7 +27282,7 @@ "supports_function_calling": true }, "perplexity/pplx-embed-v1-0.6b": { - "input_cost_per_token": 0.000000004, + "input_cost_per_token": 4e-09, "litellm_provider": "perplexity", "max_input_tokens": 32768, "max_tokens": 32768, @@ -26963,7 +27292,7 @@ "source": "https://docs.perplexity.ai/docs/embeddings/quickstart" }, "perplexity/pplx-embed-v1-4b": { - "input_cost_per_token": 0.00000003, + "input_cost_per_token": 3e-08, "litellm_provider": "perplexity", "max_input_tokens": 32768, "max_tokens": 32768, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index f2aa6287a56..4934f11d456 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -24226,6 +24226,335 @@ "/v1/images/generations" ] }, + "nebius/deepseek-ai/DeepSeek-R1": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 8e-07, + "output_cost_per_token": 2.4e-06, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "supports_reasoning": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/deepseek-ai/DeepSeek-R1-0528": { + "max_tokens": 164000, + "max_input_tokens": 164000, + "max_output_tokens": 164000, + "input_cost_per_token": 8e-07, + "output_cost_per_token": 2.4e-06, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "supports_reasoning": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/deepseek-ai/DeepSeek-R1-Distill-Llama-70B": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 2.5e-07, + "output_cost_per_token": 7.5e-07, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/deepseek-ai/DeepSeek-V3": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 5e-07, + "output_cost_per_token": 1.5e-06, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/deepseek-ai/DeepSeek-V3-0324": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 5e-07, + "output_cost_per_token": 1.5e-06, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/google/gemma-3-27b-it": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 6e-08, + "output_cost_per_token": 2e-07, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "supports_vision": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/meta-llama/Llama-3.3-70B-Instruct": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 1.3e-07, + "output_cost_per_token": 4e-07, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/meta-llama/Llama-Guard-3-8B": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 2e-08, + "output_cost_per_token": 6e-08, + "litellm_provider": "nebius", + "mode": "chat", + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/meta-llama/Meta-Llama-3.1-8B-Instruct": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 2e-08, + "output_cost_per_token": 6e-08, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/meta-llama/Meta-Llama-3.1-70B-Instruct": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 1.3e-07, + "output_cost_per_token": 4e-07, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/meta-llama/Meta-Llama-3.1-405B-Instruct": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 1e-06, + "output_cost_per_token": 3e-06, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/mistralai/Mistral-Nemo-Instruct-2407": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 4e-08, + "output_cost_per_token": 1.2e-07, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/NousResearch/Hermes-3-Llama-3.1-405B": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 1e-06, + "output_cost_per_token": 3e-06, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/nvidia/Llama-3.1-Nemotron-Ultra-253B-v1": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 6e-07, + "output_cost_per_token": 1.8e-06, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/nvidia/Llama-3.3-Nemotron-Super-49B-v1": { + "max_tokens": 131072, + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "input_cost_per_token": 1e-07, + "output_cost_per_token": 4e-07, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/Qwen/Qwen3-235B-A22B": { + "max_tokens": 262144, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "input_cost_per_token": 2e-07, + "output_cost_per_token": 6e-07, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/Qwen/Qwen3-32B": { + "max_tokens": 32768, + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "input_cost_per_token": 1e-07, + "output_cost_per_token": 3e-07, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/Qwen/Qwen3-30B-A3B": { + "max_tokens": 32768, + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "input_cost_per_token": 1e-07, + "output_cost_per_token": 3e-07, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/Qwen/Qwen3-14B": { + "max_tokens": 32768, + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "input_cost_per_token": 8e-08, + "output_cost_per_token": 2.4e-07, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/Qwen/Qwen3-4B": { + "max_tokens": 32768, + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "input_cost_per_token": 8e-08, + "output_cost_per_token": 2.4e-07, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/Qwen/QwQ-32B": { + "max_tokens": 32768, + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "input_cost_per_token": 1.5e-07, + "output_cost_per_token": 4.5e-07, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "supports_reasoning": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/Qwen/Qwen2.5-72B-Instruct": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 1.3e-07, + "output_cost_per_token": 4e-07, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/Qwen/Qwen2.5-32B-Instruct": { + "max_tokens": 128000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "input_cost_per_token": 6e-08, + "output_cost_per_token": 2e-07, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/Qwen/Qwen2.5-Coder-7B": { + "max_tokens": 32768, + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "input_cost_per_token": 1e-08, + "output_cost_per_token": 3e-08, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/Qwen/Qwen2.5-VL-72B-Instruct": { + "max_tokens": 131072, + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "input_cost_per_token": 1.3e-07, + "output_cost_per_token": 4e-07, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "supports_vision": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/Qwen/Qwen2-VL-72B-Instruct": { + "max_tokens": 131072, + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "input_cost_per_token": 1.3e-07, + "output_cost_per_token": 4e-07, + "litellm_provider": "nebius", + "mode": "chat", + "supports_function_calling": true, + "supports_vision": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/Qwen/Qwen2-VL-7B-Instruct": { + "max_tokens": 131072, + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "input_cost_per_token": 2e-08, + "output_cost_per_token": 6e-08, + "litellm_provider": "nebius", + "mode": "chat", + "supports_vision": true, + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/BAAI/bge-en-icl": { + "max_tokens": 32768, + "max_input_tokens": 32768, + "input_cost_per_token": 1e-08, + "output_cost_per_token": 0.0, + "litellm_provider": "nebius", + "mode": "embedding", + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/BAAI/bge-multilingual-gemma2": { + "max_tokens": 8192, + "max_input_tokens": 8192, + "input_cost_per_token": 1e-08, + "output_cost_per_token": 0.0, + "litellm_provider": "nebius", + "mode": "embedding", + "source": "https://nebius.com/prices-ai-studio" + }, + "nebius/intfloat/e5-mistral-7b-instruct": { + "max_tokens": 32768, + "max_input_tokens": 32768, + "input_cost_per_token": 1e-08, + "output_cost_per_token": 0.0, + "litellm_provider": "nebius", + "mode": "embedding", + "source": "https://nebius.com/prices-ai-studio" + }, "nvidia.nemotron-nano-12b-v2": { "input_cost_per_token": 2e-07, "litellm_provider": "bedrock_converse", @@ -27188,7 +27517,7 @@ "supports_function_calling": true }, "perplexity/pplx-embed-v1-0.6b": { - "input_cost_per_token": 0.000000004, + "input_cost_per_token": 4e-09, "litellm_provider": "perplexity", "max_input_tokens": 32768, "max_tokens": 32768, @@ -27198,7 +27527,7 @@ "source": "https://docs.perplexity.ai/docs/embeddings/quickstart" }, "perplexity/pplx-embed-v1-4b": { - "input_cost_per_token": 0.00000003, + "input_cost_per_token": 3e-08, "litellm_provider": "perplexity", "max_input_tokens": 32768, "max_tokens": 32768, From 18216ac07c68dee1d9fd8d1d6f4788b62b9394ec Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 3 Mar 2026 10:48:00 +0530 Subject: [PATCH 05/69] Fix: Azure ai finetuning api --- litellm/fine_tuning/main.py | 55 +++ ...odel_prices_and_context_window_backup.json | 353 +++++++++++++++--- litellm/types/llms/openai.py | 13 +- tests/batches_tests/test_fine_tuning_api.py | 58 +++ 4 files changed, 423 insertions(+), 56 deletions(-) diff --git a/litellm/fine_tuning/main.py b/litellm/fine_tuning/main.py index f5b8b097026..db77fa32919 100644 --- a/litellm/fine_tuning/main.py +++ b/litellm/fine_tuning/main.py @@ -34,6 +34,44 @@ vertex_fine_tuning_apis_instance = VertexFineTuningAPI() ################################################# +def _prepare_azure_extra_body( + extra_body: Optional[Dict[str, Any]], + kwargs: Dict[str, Any], + azure_specific_hyperparams: Dict[str, Any], +) -> Dict[str, Any]: + """ + Prepare extra_body for Azure fine-tuning API by combining Azure-specific parameters. + + Azure fine-tuning API accepts additional parameters beyond the standard OpenAI spec: + - trainingType: Type of training (e.g., 1 for supervised fine-tuning) + - prompt_loss_weight: Weight for prompt loss in training + + These parameters must be passed in the extra_body field when calling the Azure OpenAI SDK. + + Args: + extra_body: Optional existing extra_body dict + kwargs: Request kwargs that may contain Azure-specific parameters + azure_specific_hyperparams: Dict of Azure-specific hyperparameters already extracted + + Returns: + Dict containing all Azure-specific parameters to be passed in extra_body + """ + if extra_body is None: + extra_body = {} + + # Azure-specific root-level parameters + azure_specific_params = ["trainingType"] + for param in azure_specific_params: + if param in kwargs: + extra_body[param] = kwargs[param] + + # Add Azure-specific hyperparameters + if azure_specific_hyperparams: + extra_body.update(azure_specific_hyperparams) + + return extra_body + + @client async def acreate_fine_tuning_job( model: str, @@ -114,6 +152,15 @@ def create_fine_tuning_job( # handle hyperparameters hyperparameters = hyperparameters or {} # original hyperparameters + + # For Azure, extract Azure-specific hyperparameters before creating OpenAI-spec hyperparameters + azure_specific_hyperparams = {} + if custom_llm_provider == "azure": + azure_hyperparameter_keys = ["prompt_loss_weight"] + for key in azure_hyperparameter_keys: + if key in hyperparameters: + azure_specific_hyperparams[key] = hyperparameters.pop(key) + _oai_hyperparameters: Hyperparameters = Hyperparameters( **hyperparameters ) # Typed Hyperparameters for OpenAI Spec @@ -207,6 +254,10 @@ def create_fine_tuning_job( extra_body.pop("azure_ad_token", None) else: get_secret_str("AZURE_AD_TOKEN") # type: ignore + + # Prepare Azure-specific parameters for extra_body + extra_body = _prepare_azure_extra_body(extra_body, kwargs, azure_specific_hyperparams) + create_fine_tuning_job_data = FineTuningJobCreate( model=model, training_file=training_file, @@ -220,6 +271,10 @@ def create_fine_tuning_job( create_fine_tuning_job_data_dict = create_fine_tuning_job_data.model_dump( exclude_none=True ) + + # Add extra_body if it has Azure-specific parameters + if extra_body: + create_fine_tuning_job_data_dict["extra_body"] = extra_body response = azure_fine_tuning_apis_instance.create_fine_tuning_job( api_base=api_base, diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index d4c5b476af6..4934f11d456 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -846,7 +846,9 @@ "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 2.5e-08, + "cache_creation_input_token_cost": 3.125e-07 }, "anthropic.claude-3-opus-20240229-v1:0": { "input_cost_per_token": 1.5e-05, @@ -859,7 +861,9 @@ "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 1.5e-06, + "cache_creation_input_token_cost": 1.875e-05 }, "anthropic.claude-3-sonnet-20240229-v1:0": { "input_cost_per_token": 3e-06, @@ -873,7 +877,9 @@ "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 3e-07, + "cache_creation_input_token_cost": 3.75e-06 }, "anthropic.claude-instant-v1": { "input_cost_per_token": 8e-07, @@ -1512,7 +1518,9 @@ "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 3e-07, + "cache_creation_input_token_cost": 3.75e-06 }, "apac.anthropic.claude-3-5-sonnet-20241022-v2:0": { "cache_creation_input_token_cost": 3.75e-06, @@ -1545,7 +1553,9 @@ "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 2.5e-08, + "cache_creation_input_token_cost": 3.125e-07 }, "apac.anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 1.375e-06, @@ -1581,7 +1591,9 @@ "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 3e-07, + "cache_creation_input_token_cost": 3.75e-06 }, "apac.anthropic.claude-sonnet-4-20250514-v1:0": { "cache_creation_input_token_cost": 3.75e-06, @@ -6925,7 +6937,9 @@ "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 3e-07, + "cache_creation_input_token_cost": 3.75e-06 }, "bedrock/sa-east-1/meta.llama3-70b-instruct-v1:0": { "input_cost_per_token": 4.45e-06, @@ -7344,7 +7358,9 @@ "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 3.6e-07, + "cache_creation_input_token_cost": 4.5e-06 }, "bedrock/us-gov-east-1/anthropic.claude-3-haiku-20240307-v1:0": { "input_cost_per_token": 3e-07, @@ -7358,7 +7374,9 @@ "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 3e-08, + "cache_creation_input_token_cost": 3.75e-07 }, "bedrock/us-gov-east-1/claude-sonnet-4-5-20250929-v1:0": { "input_cost_per_token": 3.3e-06, @@ -7376,7 +7394,9 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 3.3e-07, + "cache_creation_input_token_cost": 4.125e-06 }, "bedrock/us-gov-east-1/meta.llama3-70b-instruct-v1:0": { "input_cost_per_token": 2.65e-06, @@ -7489,7 +7509,9 @@ "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 3.6e-07, + "cache_creation_input_token_cost": 4.5e-06 }, "bedrock/us-gov-west-1/anthropic.claude-3-haiku-20240307-v1:0": { "input_cost_per_token": 3e-07, @@ -7503,7 +7525,9 @@ "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 3e-08, + "cache_creation_input_token_cost": 3.75e-07 }, "bedrock/us-gov-west-1/claude-sonnet-4-5-20250929-v1:0": { "input_cost_per_token": 3.3e-06, @@ -7521,7 +7545,9 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 3.3e-07, + "cache_creation_input_token_cost": 4.125e-06 }, "bedrock/us-gov-west-1/meta.llama3-70b-instruct-v1:0": { "input_cost_per_token": 2.65e-06, @@ -9753,6 +9779,74 @@ } ] }, + "dashscope/qwen3-vl-plus": { + "litellm_provider": "dashscope", + "max_input_tokens": 260096, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true, + "tiered_pricing": [ + { + "input_cost_per_token": 2e-07, + "output_cost_per_token": 1.6e-06, + "range": [ + 0, + 32000.0 + ] + }, + { + "input_cost_per_token": 3e-07, + "output_cost_per_token": 2.4e-06, + "range": [ + 32000.0, + 128000.0 + ] + }, + { + "input_cost_per_token": 6e-07, + "output_cost_per_token": 4.8e-06, + "range": [ + 128000.0, + 256000.0 + ] + } + ] + }, + "dashscope/qwen3.5-plus": { + "litellm_provider": "dashscope", + "max_input_tokens": 991808, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true, + "tiered_pricing": [ + { + "input_cost_per_token": 4e-07, + "output_cost_per_token": 2.4e-06, + "range": [ + 0, + 256000.0 + ] + }, + { + "input_cost_per_token": 5e-07, + "output_cost_per_token": 3e-06, + "range": [ + 256000.0, + 1000000.0 + ] + } + ] + }, "dashscope/qwq-plus": { "input_cost_per_token": 8e-07, "litellm_provider": "dashscope", @@ -11089,7 +11183,7 @@ "supports_tool_choice": true }, "deepinfra/google/gemini-2.0-flash-001": { - "deprecation_date": "2026-03-31", + "deprecation_date": "2026-06-01", "max_tokens": 1000000, "max_input_tokens": 1000000, "max_output_tokens": 1000000, @@ -11950,7 +12044,9 @@ "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "cache_read_input_token_cost": 2.5e-08, + "cache_creation_input_token_cost": 3.125e-07 }, "eu.anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 1.375e-06, @@ -11987,7 +12083,9 @@ "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 3e-07, + "cache_creation_input_token_cost": 3.75e-06 }, "eu.anthropic.claude-3-5-sonnet-20241022-v2:0": { "input_cost_per_token": 3e-06, @@ -12004,7 +12102,9 @@ "supports_prompt_caching": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 3e-07, + "cache_creation_input_token_cost": 3.75e-06 }, "eu.anthropic.claude-3-7-sonnet-20250219-v1:0": { "input_cost_per_token": 3e-06, @@ -12022,7 +12122,9 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 3e-07, + "cache_creation_input_token_cost": 3.75e-06 }, "eu.anthropic.claude-3-haiku-20240307-v1:0": { "input_cost_per_token": 2.5e-07, @@ -12036,7 +12138,9 @@ "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 2.5e-08, + "cache_creation_input_token_cost": 3.125e-07 }, "eu.anthropic.claude-3-opus-20240229-v1:0": { "input_cost_per_token": 1.5e-05, @@ -12049,7 +12153,9 @@ "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 1.5e-06, + "cache_creation_input_token_cost": 1.875e-05 }, "eu.anthropic.claude-3-sonnet-20240229-v1:0": { "input_cost_per_token": 3e-06, @@ -12063,7 +12169,9 @@ "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 3e-07, + "cache_creation_input_token_cost": 3.75e-06 }, "eu.anthropic.claude-opus-4-1-20250805-v1:0": { "cache_creation_input_token_cost": 1.875e-05, @@ -13590,7 +13698,7 @@ }, "gemini-2.0-flash": { "cache_read_input_token_cost": 2.5e-08, - "deprecation_date": "2026-03-31", + "deprecation_date": "2026-06-01", "input_cost_per_audio_token": 7e-07, "input_cost_per_token": 1e-07, "litellm_provider": "vertex_ai-language-models", @@ -13630,7 +13738,7 @@ }, "gemini-2.0-flash-001": { "cache_read_input_token_cost": 3.75e-08, - "deprecation_date": "2026-03-31", + "deprecation_date": "2026-06-01", "input_cost_per_audio_token": 1e-06, "input_cost_per_token": 1.5e-07, "litellm_provider": "vertex_ai-language-models", @@ -13716,7 +13824,7 @@ }, "gemini-2.0-flash-lite": { "cache_read_input_token_cost": 1.875e-08, - "deprecation_date": "2026-03-31", + "deprecation_date": "2026-06-01", "input_cost_per_audio_token": 7.5e-08, "input_cost_per_token": 7.5e-08, "litellm_provider": "vertex_ai-language-models", @@ -13752,7 +13860,7 @@ }, "gemini-2.0-flash-lite-001": { "cache_read_input_token_cost": 1.875e-08, - "deprecation_date": "2026-03-31", + "deprecation_date": "2026-06-01", "input_cost_per_audio_token": 7.5e-08, "input_cost_per_token": 7.5e-08, "litellm_provider": "vertex_ai-language-models", @@ -14669,6 +14777,7 @@ "supports_web_search": true }, "gemini-3-pro-preview": { + "deprecation_date": "2026-03-26", "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07, @@ -15805,7 +15914,7 @@ }, "gemini/gemini-2.0-flash": { "cache_read_input_token_cost": 2.5e-08, - "deprecation_date": "2026-03-31", + "deprecation_date": "2026-06-01", "input_cost_per_audio_token": 7e-07, "input_cost_per_token": 1e-07, "litellm_provider": "gemini", @@ -15846,7 +15955,7 @@ }, "gemini/gemini-2.0-flash-001": { "cache_read_input_token_cost": 2.5e-08, - "deprecation_date": "2026-03-31", + "deprecation_date": "2026-06-01", "input_cost_per_audio_token": 7e-07, "input_cost_per_token": 1e-07, "litellm_provider": "gemini", @@ -15934,7 +16043,7 @@ }, "gemini/gemini-2.0-flash-lite": { "cache_read_input_token_cost": 1.875e-08, - "deprecation_date": "2026-03-31", + "deprecation_date": "2026-06-01", "input_cost_per_audio_token": 7.5e-08, "input_cost_per_token": 7.5e-08, "litellm_provider": "gemini", @@ -15970,7 +16079,7 @@ "tpm": 4000000 }, "gemini/gemini-2.0-flash-lite-preview-02-05": { - "deprecation_date": "2025-12-02", + "deprecation_date": "2025-12-09", "cache_read_input_token_cost": 1.875e-08, "input_cost_per_audio_token": 7.5e-08, "input_cost_per_token": 7.5e-08, @@ -16925,6 +17034,7 @@ "tpm": 800000 }, "gemini/gemini-3-pro-preview": { + "deprecation_date": "2026-03-09", "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "input_cost_per_token": 2e-06, @@ -23112,6 +23222,21 @@ "supports_response_schema": true, "supports_tool_choice": true }, + "mistral/magistral-medium-1-2-2509": { + "input_cost_per_token": 2e-06, + "litellm_provider": "mistral", + "max_input_tokens": 40000, + "max_output_tokens": 40000, + "max_tokens": 40000, + "mode": "chat", + "output_cost_per_token": 5e-06, + "source": "https://mistral.ai/news/magistral", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, "mistral/mistral-ocr-latest": { "litellm_provider": "mistral", "ocr_cost_per_page": 0.001, @@ -23177,6 +23302,21 @@ "supports_response_schema": true, "supports_tool_choice": true }, + "mistral/magistral-small-1-2-2509": { + "input_cost_per_token": 5e-07, + "litellm_provider": "mistral", + "max_input_tokens": 40000, + "max_output_tokens": 40000, + "max_tokens": 40000, + "mode": "chat", + "output_cost_per_token": 1.5e-06, + "source": "https://mistral.ai/pricing#api-pricing", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, "mistral/mistral-embed": { "input_cost_per_token": 1e-07, "litellm_provider": "mistral", @@ -23238,24 +23378,41 @@ "supports_tool_choice": true }, "mistral/mistral-large-latest": { - "input_cost_per_token": 2e-06, + "input_cost_per_token": 5e-07, "litellm_provider": "mistral", - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "max_tokens": 128000, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, "mode": "chat", - "output_cost_per_token": 6e-06, + "output_cost_per_token": 1.5e-06, + "source": "https://docs.mistral.ai/models/mistral-large-3-25-12", "supports_assistant_prefill": true, "supports_function_calling": true, "supports_response_schema": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "supports_vision": true }, "mistral/mistral-large-3": { "input_cost_per_token": 5e-07, "litellm_provider": "mistral", - "max_input_tokens": 256000, - "max_output_tokens": 8191, - "max_tokens": 8191, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 1.5e-06, + "source": "https://docs.mistral.ai/models/mistral-large-3-25-12", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "mistral/mistral-large-2512": { + "input_cost_per_token": 5e-07, + "litellm_provider": "mistral", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, "mode": "chat", "output_cost_per_token": 1.5e-06, "source": "https://docs.mistral.ai/models/mistral-large-3-25-12", @@ -23306,14 +23463,30 @@ "input_cost_per_token": 4e-07, "litellm_provider": "mistral", "max_input_tokens": 131072, - "max_output_tokens": 8191, - "max_tokens": 8191, + "max_output_tokens": 131072, + "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 2e-06, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_response_schema": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "supports_vision": true + }, + "mistral/mistral-medium-3-1-2508": { + "input_cost_per_token": 4e-07, + "litellm_provider": "mistral", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2e-06, + "source": "https://mistral.ai/news/mistral-medium-3", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true }, "mistral/mistral-small": { "input_cost_per_token": 1e-07, @@ -23329,17 +23502,79 @@ "supports_tool_choice": true }, "mistral/mistral-small-latest": { - "input_cost_per_token": 1e-07, + "input_cost_per_token": 6e-08, "litellm_provider": "mistral", - "max_input_tokens": 32000, - "max_output_tokens": 8191, - "max_tokens": 8191, + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, "mode": "chat", - "output_cost_per_token": 3e-07, + "output_cost_per_token": 1.8e-07, + "source": "https://mistral.ai/pricing", "supports_assistant_prefill": true, "supports_function_calling": true, "supports_response_schema": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "supports_vision": true + }, + "mistral/mistral-small-3-2-2506": { + "input_cost_per_token": 6e-08, + "litellm_provider": "mistral", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.8e-07, + "source": "https://mistral.ai/pricing", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "mistral/ministral-3-3b-2512": { + "input_cost_per_token": 1e-07, + "litellm_provider": "mistral", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1e-07, + "source": "https://mistral.ai/pricing", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "mistral/ministral-3-8b-2512": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "mistral", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 1.5e-07, + "source": "https://mistral.ai/pricing", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "mistral/ministral-3-14b-2512": { + "input_cost_per_token": 2e-07, + "litellm_provider": "mistral", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 2e-07, + "source": "https://mistral.ai/pricing", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true }, "mistral/mistral-tiny": { "input_cost_per_token": 2.5e-07, @@ -25657,7 +25892,7 @@ "supports_tool_choice": true }, "openrouter/google/gemini-2.0-flash-001": { - "deprecation_date": "2026-03-31", + "deprecation_date": "2026-06-01", "input_cost_per_audio_token": 7e-07, "input_cost_per_token": 1e-07, "litellm_provider": "openrouter", @@ -29554,7 +29789,9 @@ "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 3e-07, + "cache_creation_input_token_cost": 3.75e-06 }, "us.anthropic.claude-3-5-sonnet-20241022-v2:0": { "cache_creation_input_token_cost": 3.75e-06, @@ -29607,7 +29844,9 @@ "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 2.5e-08, + "cache_creation_input_token_cost": 3.125e-07 }, "us.anthropic.claude-3-opus-20240229-v1:0": { "input_cost_per_token": 1.5e-05, @@ -29620,7 +29859,9 @@ "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 1.5e-06, + "cache_creation_input_token_cost": 1.875e-05 }, "us.anthropic.claude-3-sonnet-20240229-v1:0": { "input_cost_per_token": 3e-06, @@ -29634,7 +29875,9 @@ "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 3e-07, + "cache_creation_input_token_cost": 3.75e-06 }, "us.anthropic.claude-opus-4-1-20250805-v1:0": { "cache_creation_input_token_cost": 1.875e-05, @@ -30527,7 +30770,7 @@ "supports_tool_choice": true }, "vercel_ai_gateway/google/gemini-2.0-flash": { - "deprecation_date": "2026-03-31", + "deprecation_date": "2026-06-01", "input_cost_per_token": 1.5e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 1048576, @@ -30541,7 +30784,7 @@ "supports_response_schema": true }, "vercel_ai_gateway/google/gemini-2.0-flash-lite": { - "deprecation_date": "2026-03-31", + "deprecation_date": "2026-06-01", "input_cost_per_token": 7.5e-08, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 1048576, @@ -37898,7 +38141,7 @@ }, "gemini/gemini-2.0-flash-lite-001": { "cache_read_input_token_cost": 1.875e-08, - "deprecation_date": "2026-03-31", + "deprecation_date": "2026-06-01", "input_cost_per_audio_token": 7.5e-08, "input_cost_per_token": 7.5e-08, "litellm_provider": "gemini", diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index f82f6a02f22..d06d879dad1 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -71,7 +71,14 @@ from openai.types.responses.response_create_params import ( ToolParam, ) from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall -from pydantic import BaseModel, ConfigDict, Discriminator, PrivateAttr, field_serializer, field_validator +from pydantic import ( + BaseModel, + ConfigDict, + Discriminator, + PrivateAttr, + field_serializer, + field_validator, +) from typing_extensions import Annotated, Dict, Required, TypedDict, override from litellm.types.llms.base import BaseLiteLLMOpenAIResponseObject @@ -964,6 +971,10 @@ class Hyperparameters(BaseModel): n_epochs: Optional[Union[str, int]] = ( None # "The number of epochs to train the model for" ) + + model_config = { + "extra": "allow" + } class FineTuningJobCreate(BaseModel): diff --git a/tests/batches_tests/test_fine_tuning_api.py b/tests/batches_tests/test_fine_tuning_api.py index c6a731ea54f..7e238173480 100644 --- a/tests/batches_tests/test_fine_tuning_api.py +++ b/tests/batches_tests/test_fine_tuning_api.py @@ -596,3 +596,61 @@ async def test_mock_openai_retrieve_fine_tune_job(): # Verify the request mock_retrieve.assert_called_once_with(fine_tuning_job_id="ft-123") + + +@pytest.mark.asyncio +async def test_mock_azure_create_fine_tune_job_with_azure_specific_params(): + """Test that Azure-specific parameters are passed through extra_body""" + from openai import AsyncAzureOpenAI + from openai.types.fine_tuning.fine_tuning_job import FineTuningJob + from openai.types.fine_tuning.fine_tuning_job import Hyperparameters as OAIHyperparameters + + mock_response = FineTuningJob( + id="ft-azure-123", + model="gpt-4.1-mini-2025-04-14", + created_at=1677610602, + status="validating_files", + fine_tuned_model=None, + object="fine_tuning.job", + hyperparameters=OAIHyperparameters(n_epochs=3), + organization_id="org-123", + seed=42, + training_file="file-123", + result_files=[], + ) + + with patch("litellm.llms.azure.fine_tuning.handler.AzureOpenAIFineTuningAPI.create_fine_tuning_job") as mock_create: + mock_create.return_value = mock_response + + response = await litellm.acreate_fine_tuning_job( + model="gpt-4.1-mini-2025-04-14", + training_file="file-123", + custom_llm_provider="azure", + api_base="https://test.openai.azure.com", + api_key="test-key", + api_version="2025-04-01-preview", + trainingType=1, + hyperparameters={ + "n_epochs": 3, + "prompt_loss_weight": 0.1 + }, + ) + + # Verify the request + mock_create.assert_called_once() + request_params = mock_create.call_args.kwargs + + # Check that create_fine_tuning_job_data contains the correct structure + create_data = request_params["create_fine_tuning_job_data"] + assert create_data["model"] == "gpt-4.1-mini-2025-04-14" + assert create_data["training_file"] == "file-123" + assert create_data["hyperparameters"] == {"n_epochs": 3} + + # Azure-specific parameters should be in extra_body + assert "extra_body" in create_data + assert create_data["extra_body"]["trainingType"] == 1 + assert create_data["extra_body"]["prompt_loss_weight"] == 0.1 + + # Verify the response + assert response.id == "ft-azure-123" + assert response.model == "gpt-4.1-mini-2025-04-14" From 5b0238736c2b994d17dee7d96af228882f78e277 Mon Sep 17 00:00:00 2001 From: ryan-crabbe <128659760+ryan-crabbe@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:49:48 -0800 Subject: [PATCH 06/69] Add incident report: cache eviction closes in-use httpx clients (#22309) --- .../httpx_cache_eviction_incident/index.md | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 docs/my-website/blog/httpx_cache_eviction_incident/index.md diff --git a/docs/my-website/blog/httpx_cache_eviction_incident/index.md b/docs/my-website/blog/httpx_cache_eviction_incident/index.md new file mode 100644 index 00000000000..9e6152d0e63 --- /dev/null +++ b/docs/my-website/blog/httpx_cache_eviction_incident/index.md @@ -0,0 +1,132 @@ +--- +slug: httpx-cache-eviction-incident +title: "Incident Report: Cache Eviction Closes In-Use httpx Clients" +date: 2026-02-27T10:00:00 +authors: + - name: Ryan Crabbe + title: Performance Engineer, LiteLLM + url: https://www.linkedin.com/in/ryan-crabbe-0b9687214 + - name: Ishaan Jaff + title: "CTO, LiteLLM" + url: https://www.linkedin.com/in/reffajnaahsi/ + image_url: https://pbs.twimg.com/profile_images/1613813310264340481/lz54oEiB_400x400.jpg + - name: Krrish Dholakia + title: "CEO, LiteLLM" + url: https://www.linkedin.com/in/krish-d/ + image_url: https://pbs.twimg.com/profile_images/1298587542745358340/DZv3Oj-h_400x400.jpg +tags: [incident-report, caching, stability] +hide_table_of_contents: false +--- + +**Date:** February 27, 2026 +**Duration:** ~6 days (Feb 21 merge -> Feb 27 fix) +**Severity:** High +**Status:** Resolved + +> **Note:** This fix is available starting from LiteLLM `v1.81.14.rc.2` or higher. + +## Summary + +A change to improve Redis connection pool cleanup introduced a regression that closed **httpx clients** that were still actively being used by the proxy. The `LLMClientCache` (an in-memory TTL cache) stores both Redis clients *and* httpx clients under the same eviction policy. When a cache entry expired or was evicted, the new cleanup code called `aclose()`/`close()` on the evicted value which worked correctly for Redis clients, but destroyed httpx clients that other parts of the system still held references to and were actively using for LLM API calls. + +**Impact:** Any proxy instance that hit the cache TTL (default 10 minutes) or capacity limit (200 entries) would have its httpx clients closed out from under it, causing requests to LLM providers to fail with connection errors. + +--- + +## Background + +`LLMClientCache` extends `InMemoryCache` and is used to cache SDK clients (OpenAI, Anthropic, etc.) to avoid re-creating them on every request. These clients are keyed by configuration + event loop ID. The cache has: + +- **Max size:** 200 entries +- **Default TTL:** 10 minutes + +When the cache is full or entries expire, `InMemoryCache.evict_cache()` calls `_remove_key()` to drop entries. + +The cached values are a mix of: +- **Redis/async Redis clients** — owned exclusively by the cache, safe to close on eviction +- **httpx-backed SDK clients** (OpenAI, Anthropic, etc.) — shared references, still in use by router/model instances + +--- + +## Root Cause + +[PR #21717](https://github.com/BerriAI/litellm/pull/21717) overrode `_remove_key()` in `LLMClientCache` to close async clients on eviction: + +
+Problematic code added in PR #21717 + +```python +class LLMClientCache(InMemoryCache): + def _remove_key(self, key: str) -> None: + value = self.cache_dict.get(key) + super()._remove_key(key) + if value is not None: + close_fn = getattr(value, "aclose", None) or getattr(value, "close", None) + if close_fn and asyncio.iscoroutinefunction(close_fn): + try: + asyncio.get_running_loop().create_task(close_fn()) + except RuntimeError: + pass + elif close_fn and callable(close_fn): + try: + close_fn() + except Exception: + pass +``` + +
+ +The intent was correct for Redis clients — prevent connection pool leaks when cached Redis clients expire. But `LLMClientCache` also stores httpx-backed SDK clients (e.g., `AsyncOpenAI`, `AsyncAnthropic`). These clients: + +1. Have an `aclose()` method (inherited from httpx) +2. Are still held by references elsewhere in the codebase (router, model instances) +3. Were being closed without any check on whether they were still in use + +So when the cache evicted an entry, it would call `aclose()` on an httpx client that was still being used for active LLM requests → closed transport → connection errors. + +--- + +## The Fix + +[PR #22247](https://github.com/BerriAI/litellm/pull/22247) removed the `_remove_key` override entirely: + +
+The fix (PR #22247) + +```diff + class LLMClientCache(InMemoryCache): +- def _remove_key(self, key: str) -> None: +- """Close async clients before evicting them to prevent connection pool leaks.""" +- value = self.cache_dict.get(key) +- super()._remove_key(key) +- if value is not None: +- close_fn = getattr(value, "aclose", None) or getattr( +- value, "close", None +- ) +- ... +- + def update_cache_key_with_event_loop(self, key): +``` + +
+ +The eviction now simply drops the reference and lets Python's GC handle cleanup, which is safe because: +- httpx clients that are still referenced elsewhere stay alive +- Unreferenced clients get cleaned up by GC naturally + +The other improvements from PR #21717 were kept: +- **`max_connections` respected for URL-based Redis configs**, previously silently dropped +- **`disconnect()` now closes both sync and async Redis clients**, sync client was previously leaked +- **Connection pool passthrough**, when a pool is provided with a URL config, it's used directly instead of creating a duplicate + +--- + +## Remediation + +| Action | Status | Code | +|--------|--------|------| +| Remove `_remove_key` override that closes shared clients on eviction | ✅ Done | [PR #22247](https://github.com/BerriAI/litellm/pull/22247) | +| Add e2e test: evicted client still usable (capacity) | ✅ Done | [PR #22313](https://github.com/BerriAI/litellm/pull/22313) | +| Add e2e test: expired client still usable (TTL) | ✅ Done | [PR #22313](https://github.com/BerriAI/litellm/pull/22313) | + +The e2e tests go through `get_async_httpx_client()` the same code path the proxy uses in production and assert the client is still functional after eviction. These run in CI on every PR against `main`. If anyone modifies `LLMClientCache` eviction behavior, overrides `_remove_key`, or adds any form of client cleanup on eviction, these tests will fail regardless of the implementation approach. From 6bcba46dda1ffd8dcba33e5b19c57f1d2e66b5e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaeyeon=20Kim=28=EA=B9=80=EC=9E=AC=EC=97=B0=29?= Date: Tue, 3 Mar 2026 06:57:54 +0100 Subject: [PATCH 07/69] fix: set mock status_code in JWT OIDC discovery tests (#22361) The _resolve_jwks_url method checks response.status_code != 200, but MagicMock returns a MagicMock object for status_code which is always truthy (!= 200). Explicitly set mock_response.status_code = 200 so the tests exercise the intended code path. Co-authored-by: Claude Opus 4.6 --- tests/test_litellm/proxy/auth/test_handle_jwt.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_litellm/proxy/auth/test_handle_jwt.py b/tests/test_litellm/proxy/auth/test_handle_jwt.py index 8418dde5e9c..3c190974277 100644 --- a/tests/test_litellm/proxy/auth/test_handle_jwt.py +++ b/tests/test_litellm/proxy/auth/test_handle_jwt.py @@ -1559,6 +1559,7 @@ async def test_resolve_jwks_url_caches_resolved_jwks_uri(): jwks_url = "https://login.microsoftonline.com/tenant/discovery/keys" mock_response = MagicMock() + mock_response.status_code = 200 mock_response.json.return_value = {"jwks_uri": jwks_url} with patch.object(handler.http_handler, "get", new_callable=AsyncMock, return_value=mock_response) as mock_get: @@ -1587,6 +1588,7 @@ async def test_resolve_jwks_url_raises_if_no_jwks_uri_in_discovery_doc(): discovery_url = "https://example.com/.well-known/openid-configuration" mock_response = MagicMock() + mock_response.status_code = 200 mock_response.json.return_value = {"issuer": "https://example.com"} # no jwks_uri with patch.object(handler.http_handler, "get", new_callable=AsyncMock, return_value=mock_response): From 213799282b7d6c0eb76c9853f4f46d99daad7396 Mon Sep 17 00:00:00 2001 From: Shivaang <38239870+shivaaang@users.noreply.github.com> Date: Tue, 3 Mar 2026 01:02:59 -0500 Subject: [PATCH 08/69] fix(openrouter): register OpenRouter as native Responses API provider (#22355) OpenRouter supports the Responses API at /api/v1/responses with encrypted_content for multi-turn stateless reasoning workflows. Without native registration, requests fall through to the chat completion bridge, which uses a different format (reasoning_details) and drops encrypted_content entirely. This adds OpenRouterResponsesAPIConfig to route requests directly to OpenRouter's Responses API endpoint, preserving encrypted_content. Fixes https://github.com/BerriAI/litellm/issues/22189 Co-authored-by: Krish Dholakia --- litellm/__init__.py | 1 + litellm/_lazy_imports_registry.py | 5 + .../openrouter/responses/transformation.py | 77 ++++++++++++ litellm/utils.py | 2 + ...est_openrouter_responses_transformation.py | 112 ++++++++++++++++++ 5 files changed, 197 insertions(+) create mode 100644 litellm/llms/openrouter/responses/transformation.py create mode 100644 tests/test_litellm/llms/openrouter/responses/test_openrouter_responses_transformation.py diff --git a/litellm/__init__.py b/litellm/__init__.py index f00b816be5c..84b8e47c462 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -1441,6 +1441,7 @@ if TYPE_CHECKING: from .llms.manus.responses.transformation import ManusResponsesAPIConfig as ManusResponsesAPIConfig from .llms.perplexity.responses.transformation import PerplexityResponsesConfig as PerplexityResponsesConfig from .llms.databricks.responses.transformation import DatabricksResponsesAPIConfig as DatabricksResponsesAPIConfig + from .llms.openrouter.responses.transformation import OpenRouterResponsesAPIConfig as OpenRouterResponsesAPIConfig from .llms.gemini.interactions.transformation import GoogleAIStudioInteractionsConfig as GoogleAIStudioInteractionsConfig from .llms.openai.chat.o_series_transformation import OpenAIOSeriesConfig as OpenAIOSeriesConfig, OpenAIOSeriesConfig as OpenAIO1Config from .llms.anthropic.skills.transformation import AnthropicSkillsConfig as AnthropicSkillsConfig diff --git a/litellm/_lazy_imports_registry.py b/litellm/_lazy_imports_registry.py index 6ff997b4531..4bb336a4d77 100644 --- a/litellm/_lazy_imports_registry.py +++ b/litellm/_lazy_imports_registry.py @@ -231,6 +231,7 @@ LLM_CONFIG_NAMES = ( "VolcEngineResponsesAPIConfig", "PerplexityResponsesConfig", "DatabricksResponsesAPIConfig", + "OpenRouterResponsesAPIConfig", "GoogleAIStudioInteractionsConfig", "OpenAIOSeriesConfig", "AnthropicSkillsConfig", @@ -923,6 +924,10 @@ _LLM_CONFIGS_IMPORT_MAP = { ".llms.databricks.responses.transformation", "DatabricksResponsesAPIConfig", ), + "OpenRouterResponsesAPIConfig": ( + ".llms.openrouter.responses.transformation", + "OpenRouterResponsesAPIConfig", + ), "GoogleAIStudioInteractionsConfig": ( ".llms.gemini.interactions.transformation", "GoogleAIStudioInteractionsConfig", diff --git a/litellm/llms/openrouter/responses/transformation.py b/litellm/llms/openrouter/responses/transformation.py new file mode 100644 index 00000000000..ddce6fd3844 --- /dev/null +++ b/litellm/llms/openrouter/responses/transformation.py @@ -0,0 +1,77 @@ +""" +OpenRouter Responses API Configuration. + +OpenRouter supports the Responses API at https://openrouter.ai/api/v1/responses +with OpenAI-compatible request/response format, including reasoning with +encrypted_content for multi-turn stateless workflows. + +Docs: https://openrouter.ai/docs/api/reference/responses/overview +""" + +from typing import Optional + +import litellm +from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig +from litellm.secret_managers.main import get_secret_str +from litellm.types.router import GenericLiteLLMParams +from litellm.types.utils import LlmProviders + + +class OpenRouterResponsesAPIConfig(OpenAIResponsesAPIConfig): + """ + Configuration for OpenRouter's Responses API. + + Inherits from OpenAIResponsesAPIConfig since OpenRouter's Responses API + is compatible with OpenAI's Responses API specification. + + Key difference from direct OpenAI: + - Uses https://openrouter.ai/api/v1 as the API base + - Uses OPENROUTER_API_KEY for authentication + """ + + @property + def custom_llm_provider(self) -> LlmProviders: + return LlmProviders.OPENROUTER + + def validate_environment( + self, + headers: dict, + model: str, + litellm_params: Optional[GenericLiteLLMParams], + ) -> dict: + litellm_params = litellm_params or GenericLiteLLMParams() + api_key = ( + litellm_params.api_key + or litellm.api_key + or get_secret_str("OPENROUTER_API_KEY") + or get_secret_str("OR_API_KEY") + ) + + if not api_key: + raise ValueError( + "OpenRouter API key is required. Set OPENROUTER_API_KEY " + "environment variable or pass api_key parameter." + ) + + headers.update( + { + "Authorization": f"Bearer {api_key}", + } + ) + return headers + + def get_complete_url( + self, + api_base: Optional[str], + litellm_params: dict, + ) -> str: + api_base = ( + api_base + or litellm.api_base + or get_secret_str("OPENROUTER_API_BASE") + or "https://openrouter.ai/api/v1" + ) + + api_base = api_base.rstrip("/") + + return f"{api_base}/responses" diff --git a/litellm/utils.py b/litellm/utils.py index cbe6aa8e793..d192609eead 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -8312,6 +8312,8 @@ class ProviderConfigManager: if model and "gpt" in model.lower(): return litellm.DatabricksResponsesAPIConfig() return None + elif litellm.LlmProviders.OPENROUTER == provider: + return litellm.OpenRouterResponsesAPIConfig() elif litellm.LlmProviders.HOSTED_VLLM == provider: return litellm.HostedVLLMResponsesAPIConfig() return None diff --git a/tests/test_litellm/llms/openrouter/responses/test_openrouter_responses_transformation.py b/tests/test_litellm/llms/openrouter/responses/test_openrouter_responses_transformation.py new file mode 100644 index 00000000000..544ec1ec719 --- /dev/null +++ b/tests/test_litellm/llms/openrouter/responses/test_openrouter_responses_transformation.py @@ -0,0 +1,112 @@ +""" +Tests for OpenRouter Responses API configuration. + +Validates that OpenRouter is registered as a native Responses API provider, +routing requests directly to https://openrouter.ai/api/v1/responses instead +of falling back to the chat completion bridge. This is required to preserve +reasoning.encrypted_content for multi-turn stateless workflows. + +Related issue: https://github.com/BerriAI/litellm/issues/22189 +""" + +import litellm +from litellm.llms.openrouter.responses.transformation import ( + OpenRouterResponsesAPIConfig, +) +from litellm.types.utils import LlmProviders +from litellm.utils import ProviderConfigManager + + +class TestOpenRouterResponsesAPIConfig: + """Test OpenRouter Responses API configuration.""" + + def test_custom_llm_provider(self): + """custom_llm_provider should return OPENROUTER.""" + config = OpenRouterResponsesAPIConfig() + assert config.custom_llm_provider == LlmProviders.OPENROUTER + + def test_get_complete_url_default(self): + """Default URL should point to OpenRouter's Responses API endpoint.""" + config = OpenRouterResponsesAPIConfig() + url = config.get_complete_url(api_base=None, litellm_params={}) + assert url == "https://openrouter.ai/api/v1/responses" + + def test_get_complete_url_custom_base(self): + """Custom api_base should be respected.""" + config = OpenRouterResponsesAPIConfig() + url = config.get_complete_url( + api_base="https://custom.openrouter.ai/api/v1", + litellm_params={}, + ) + assert url == "https://custom.openrouter.ai/api/v1/responses" + + def test_get_complete_url_strips_trailing_slash(self): + """Trailing slashes on api_base should be stripped.""" + config = OpenRouterResponsesAPIConfig() + url = config.get_complete_url( + api_base="https://openrouter.ai/api/v1/", + litellm_params={}, + ) + assert url == "https://openrouter.ai/api/v1/responses" + + def test_validate_environment_sets_auth_header(self): + """validate_environment should set the Authorization header.""" + config = OpenRouterResponsesAPIConfig() + from litellm.types.router import GenericLiteLLMParams + + params = GenericLiteLLMParams(api_key="sk-or-test-key") + headers = config.validate_environment( + headers={}, model="openai/o4-mini", litellm_params=params + ) + assert headers["Authorization"] == "Bearer sk-or-test-key" + + def test_validate_environment_raises_without_key(self): + """validate_environment should raise when no API key is available.""" + config = OpenRouterResponsesAPIConfig() + from litellm.types.router import GenericLiteLLMParams + + try: + config.validate_environment( + headers={}, + model="openai/o4-mini", + litellm_params=GenericLiteLLMParams(), + ) + assert False, "Should have raised ValueError" + except ValueError as e: + assert "OpenRouter API key is required" in str(e) + + +class TestOpenRouterResponsesAPIRegistration: + """Test that OpenRouter is properly registered as a native Responses API provider.""" + + def test_provider_config_manager_returns_openrouter_config(self): + """ + ProviderConfigManager.get_provider_responses_api_config should return + OpenRouterResponsesAPIConfig for the OPENROUTER provider, NOT None. + + When it returns None, requests fall through to the completion bridge, + which loses encrypted_content (the bug in issue #22189). + """ + config = ProviderConfigManager.get_provider_responses_api_config( + provider=LlmProviders.OPENROUTER, + ) + assert config is not None, ( + "OpenRouter must be registered as a native Responses API provider " + "to preserve reasoning.encrypted_content" + ) + assert isinstance(config, OpenRouterResponsesAPIConfig) + + def test_openrouter_not_using_completion_bridge(self): + """ + Verify that OpenRouter does NOT fall through to the completion bridge. + The completion bridge drops encrypted_content because chat completions + use a different format (reasoning_details) than the Responses API. + """ + config = ProviderConfigManager.get_provider_responses_api_config( + provider=LlmProviders.OPENROUTER, + ) + # If config is not None, the native Responses API path is used + assert config is not None + # The URL should point to OpenRouter's responses endpoint + url = config.get_complete_url(api_base=None, litellm_params={}) + assert "/responses" in url From 67f90254edc5b9d46fffdc7297a8eaf7950bd144 Mon Sep 17 00:00:00 2001 From: Krish Dholakia Date: Mon, 2 Mar 2026 22:06:49 -0800 Subject: [PATCH 09/69] feat(guardrails): team-based guardrail registration and approval workflow (#22459) * feat(guardrails): team-based guardrail registration and approval workflow Add team-based guardrail submission system where teams can register Generic Guardrail API guardrails for admin review. Includes: - POST /guardrails/register endpoint for team-scoped submissions - Admin review endpoints (list/get/approve/reject submissions) - Team Guardrails tab in the UI dashboard - extra_headers support for forwarding client headers to guardrail APIs - Prisma schema migration for status, submitted_at, reviewed_at fields - Documentation for team-based guardrails and static/dynamic headers Co-Authored-By: Claude Opus 4.6 * fix(guardrails): address review feedback - SSRF, silent failure, redundant query - Validate api_base URL scheme (http/https only) and hostname in register_guardrail to prevent SSRF via team submissions - Return warning field in approve response when in-memory initialization fails so admins know the guardrail won't work until next sync cycle - Eliminate redundant DB query in list_guardrail_submissions by fetching all team guardrails once and deriving both filtered list and summary counts from the single result set Co-Authored-By: Claude Opus 4.6 * fix(guardrails): add pending_review status guard to reject endpoint Prevent rejecting already-active or already-rejected guardrails, which would create a DB/memory inconsistency (active in memory but rejected in DB). Now mirrors the approve endpoint's status check. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .../adding_provider/generic_guardrail_api.md | 29 + .../docs/proxy/guardrails/quick_start.md | 1 + .../proxy/guardrails/team_based_guardrails.md | 137 +++ docs/my-website/img/admin_team_guardrails.png | Bin 0 -> 535985 bytes docs/my-website/sidebars.js | 1 + .../migration.sql | 8 + .../litellm_proxy_extras/schema.prisma | 7 + litellm/proxy/_types.py | 2 + .../proxy/guardrails/guardrail_endpoints.py | 469 ++++++- .../generic_guardrail_api/__init__.py | 1 + .../generic_guardrail_api.py | 51 +- .../proxy/guardrails/guardrail_registry.py | 14 +- litellm/proxy/schema.prisma | 7 + litellm/types/guardrails.py | 9 + schema.prisma | 7 + .../create_team_key_and_submit_guardrail.sh | 92 ++ scripts/test_guardrails_register_endpoints.sh | 126 ++ .../test_generic_guardrail_api.py | 93 +- .../guardrails/test_guardrail_endpoints.py | 470 ++++++- ui/litellm-dashboard/package-lock.json | 15 + .../src/components/guardrails.tsx | 7 + .../guardrails/TeamGuardrailsTab.tsx | 1081 +++++++++++++++++ .../src/components/networking.tsx | 126 ++ 23 files changed, 2724 insertions(+), 29 deletions(-) create mode 100644 docs/my-website/docs/proxy/guardrails/team_based_guardrails.md create mode 100644 docs/my-website/img/admin_team_guardrails.png create mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20260228170127_support_team_based_guardrails/migration.sql create mode 100755 scripts/create_team_key_and_submit_guardrail.sh create mode 100755 scripts/test_guardrails_register_endpoints.sh create mode 100644 ui/litellm-dashboard/src/components/guardrails/TeamGuardrailsTab.tsx diff --git a/docs/my-website/docs/adding_provider/generic_guardrail_api.md b/docs/my-website/docs/adding_provider/generic_guardrail_api.md index eb567a69fcb..cc0dbf1f4e9 100644 --- a/docs/my-website/docs/adding_provider/generic_guardrail_api.md +++ b/docs/my-website/docs/adding_provider/generic_guardrail_api.md @@ -244,6 +244,35 @@ litellm_settings: language: "en" ``` +### Static and dynamic headers + +You can send two kinds of headers to your guardrail endpoint: + +- **Static headers** (`headers`): A key/value map sent with **every** request to your guardrail. Use this for fixed values (e.g. API keys, `X-Service-Name`). Configure in `litellm_params`: + + ```yaml + litellm_params: + guardrail: generic_guardrail_api + api_base: https://your-guardrail-api.com + headers: + X-Service-Name: "my-app" + X-API-Key: "secret" + ``` + +- **Dynamic headers** (`extra_headers`): A list of **header names** that are forwarded from the **client request** to your guardrail. Only headers in this list (plus a small default allowlist such as `x-litellm-*`) have their values sent; others are sent as `[present]`. Use this to pass through client-provided headers (e.g. `x-request-id`, `x-correlation-id`). Configure in `litellm_params`: + + ```yaml + litellm_params: + guardrail: generic_guardrail_api + api_base: https://your-guardrail-api.com + extra_headers: + - x-request-id + - x-correlation-id + - x-custom-auth + ``` + +This mirrors the [MCP static and extra headers](/docs/mcp#forwarding-custom-headers-to-mcp-servers) behavior. + ### Example: Pillar Security [Pillar Security](https://pillar.security) uses the Generic Guardrail API to provide comprehensive AI security scanning including prompt injection protection, PII/PCI detection, secret detection, and content moderation. diff --git a/docs/my-website/docs/proxy/guardrails/quick_start.md b/docs/my-website/docs/proxy/guardrails/quick_start.md index ddb215fcb66..e5a90f74a8a 100644 --- a/docs/my-website/docs/proxy/guardrails/quick_start.md +++ b/docs/my-website/docs/proxy/guardrails/quick_start.md @@ -73,6 +73,7 @@ guardrails: plr_scanners: true ``` +For generic guardrail APIs you can also set **static headers** (`headers`: key/value sent on every request) and **dynamic headers** (`extra_headers`: list of client header names to forward). See [Generic Guardrail API - Static and dynamic headers](/docs/adding_provider/generic_guardrail_api#static-and-dynamic-headers). ### Supported values for `mode` (Event Hooks) diff --git a/docs/my-website/docs/proxy/guardrails/team_based_guardrails.md b/docs/my-website/docs/proxy/guardrails/team_based_guardrails.md new file mode 100644 index 00000000000..2d55294a711 --- /dev/null +++ b/docs/my-website/docs/proxy/guardrails/team_based_guardrails.md @@ -0,0 +1,137 @@ +import Image from '@theme/IdealImage'; + +# Team-Based Guardrails + +Team-based guardrails let **developers** register a guardrail for their team via the API; an **admin** then reviews and approves or rejects it in the LiteLLM UI. Only [Generic Guardrail API](/docs/adding_provider/generic_guardrail_api) guardrails can be registered this way. + +## Overview + +- **Developer flow:** Use a **team-scoped API key** to `POST /guardrails/register` with your guardrail config. The submission is stored with status `pending_review`. +- **Admin flow:** In the proxy UI, open **Guardrails → Team Guardrails**, review pending submissions, and **Approve** or **Reject**. Approved guardrails become active and are initialized in memory. + +--- + +## Developer flow: Register a guardrail + +### Prerequisites + +- A **team-scoped** API key (the key must be associated with a team). Keys without a team cannot register guardrails. +- Your guardrail must follow the [Generic Guardrail API](/docs/adding_provider/generic_guardrail_api) contract and config. + +### Request + +**Endpoint:** `POST /guardrails/register` + +**Headers:** `Authorization: Bearer ` + +**Body:** JSON matching the Generic Guardrail API config. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `guardrail_name` | string | Yes | Unique name for the guardrail. | +| `litellm_params` | object | Yes | Must include `guardrail: "generic_guardrail_api"`, `mode` (e.g. `pre_call`, `post_call`), and `api_base`. See [Generic Guardrail API](/docs/adding_provider/generic_guardrail_api#litellm-configuration). | +| `guardrail_info` | object | No | Optional metadata (e.g. `description`). | + +### Requirements for `litellm_params` + +- `guardrail` must be exactly `"generic_guardrail_api"`. +- `api_base` is required (your guardrail API base URL). +- `mode` is required (e.g. `pre_call`, `post_call`, `during_call`). + +### Example + +```bash +curl -X POST "http://localhost:4000/guardrails/register" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "guardrail_name": "my-team-guard", + "litellm_params": { + "guardrail": "generic_guardrail_api", + "mode": "pre_call", + "api_base": "https://your-guardrail-api.com", + "api_key": "optional-api-key", + "unreachable_fallback": "fail_closed", + "forward_api_key": true + }, + "guardrail_info": { + "description": "Team content moderation guardrail" + } + }' +``` + +### Example response + +```json +{ + "guardrail_id": "123e4567-e89b-12d3-a456-426614174000", + "guardrail_name": "my-team-guard", + "status": "pending_review", + "submitted_at": "2025-02-28T12:00:00.000Z" +} +``` + +### Errors + +- **400** – Missing or invalid body (e.g. `guardrail` not `generic_guardrail_api`, missing `api_base` or `mode`), or a guardrail with the same `guardrail_name` already exists. +- **400** – "Registration requires an API key associated with a team. Use a team-scoped key." → Use an API key that has a team. +- **500** – Server/database error. + +After a successful register, the guardrail stays in `pending_review` until an admin approves or rejects it. + +--- + +## Admin flow: Approve or reject in the UI + +Admins review and approve or reject team guardrail submissions in the LiteLLM proxy UI. + +### 1. Open the Guardrails page + +In the proxy dashboard, go to **Guardrails** (sidebar or navigation). + +### 2. Open the Team Guardrails tab + +Switch to the **Team Guardrails** tab. This tab lists all team-submitted guardrails and their status. + +Team Guardrails admin view: status summary (Total, Pending Review, Active, Rejected), guardrail list with Pending Review tag, and detail panel with Approve/Reject buttons and configuration options. + +### 3. Review submissions + +The table shows: + +- **Name**, **Team**, **Endpoint** (api_base), **Status** (Pending Review / Active / Rejected), **Submitted** date, **Submitted by** (user/email), and other config details. + +Summary cards show counts for **Total**, **Pending Review**, **Active**, and **Rejected**. + + + +### 4. Approve or reject + +- **Pending Review:** Use **Approve** to activate the guardrail. The proxy sets its status to `active` and initializes it in memory so it can be used on requests. +- Use **Reject** to decline the submission (status becomes `rejected`). + +Approval triggers the same initialization as adding a guardrail via config or the admin guardrail API; rejection only updates the status and does not load the guardrail. + + + +### API equivalent (admin only) + +Admins can also use the REST API: + +- **List submissions:** `GET /guardrails/submissions` (optional query: `status`, `team_id`, `search`) +- **Get one:** `GET /guardrails/submissions/{guardrail_id}` +- **Approve:** `POST /guardrails/submissions/{guardrail_id}/approve` +- **Reject:** `POST /guardrails/submissions/{guardrail_id}/reject` + +These endpoints require **admin** (e.g. `PROXY_ADMIN`) authentication. + +--- + +## Summary + +| Role | Action | +|------|--------| +| **Developer** | Call `POST /guardrails/register` with a team-scoped key and a `generic_guardrail_api` config. Submission enters `pending_review`. | +| **Admin** | Open **Guardrails → Team Guardrails** in the UI (or use the submissions API), then **Approve** or **Reject** each submission. Approved guardrails become active. | + +Only guardrails with `litellm_params.guardrail: "generic_guardrail_api"` are accepted for registration. For the full contract and config options, see [Generic Guardrail API](/docs/adding_provider/generic_guardrail_api). diff --git a/docs/my-website/img/admin_team_guardrails.png b/docs/my-website/img/admin_team_guardrails.png new file mode 100644 index 0000000000000000000000000000000000000000..5ce3c2687a9971b9663ff989cc240e9821b0a09c GIT binary patch literal 535985 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

5g=;C!fb=FW{*H1L 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

!8HfhS`I0nL^9ODe&JwgXn=f({sJ=cfA0 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

    ?#ztYA{%Im`Ml9^PgqSUrYJ;7pO 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 wt|7u~WDS0sbgG7& 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

    nW3s&pWRu0bR$9 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%0AjO`{(ikNB~Zr38z-qgz&@Xt>3itB2 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 z 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

diff --git a/ui/litellm-dashboard/src/components/guardrails/TeamGuardrailsTab.tsx b/ui/litellm-dashboard/src/components/guardrails/TeamGuardrailsTab.tsx new file mode 100644 index 00000000000..a2246fd976d --- /dev/null +++ b/ui/litellm-dashboard/src/components/guardrails/TeamGuardrailsTab.tsx @@ -0,0 +1,1081 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { + SearchIcon, + PlusIcon, + ChevronDownIcon, + ChevronUpIcon, + XIcon, + CheckIcon, + ExternalLinkIcon, + KeyIcon, + ServerIcon, + AlertCircleIcon, + InfoIcon, +} from "lucide-react"; +import { + listGuardrailSubmissions, + approveGuardrailSubmission, + rejectGuardrailSubmission, + updateGuardrailCall, + type GuardrailSubmissionItem, +} from "@/components/networking"; +import NotificationsManager from "@/components/molecules/notifications_manager"; + +type GuardrailStatus = "active" | "pending" | "rejected"; + +type TeamGuardrail = { + id: string; + team: string; + name: string; + endpoint: string; + status: GuardrailStatus; + model: string; + forwardKey: boolean; + description: string; + method: "POST" | "GET"; + customHeaders: { + key: string; + value: string; + }[]; + extraHeaders: string[]; + submittedAt: string; + submittedBy: string; + mode?: string; + unreachable_fallback?: string; + additionalProviderParams?: Record; + guardrailType?: string; +}; + +function mapStatus(apiStatus: string): GuardrailStatus { + if (apiStatus === "pending_review") return "pending"; + if (apiStatus === "active" || apiStatus === "rejected") return apiStatus; + return "active"; +} + +function formatSubmissionDate(value: string | null | undefined): string { + if (!value) return "—"; + try { + const d = new Date(value); + return isNaN(d.getTime()) ? value : d.toISOString().slice(0, 10); + } catch { + return value; + } +} + +function submissionToTeamGuardrail(item: GuardrailSubmissionItem): TeamGuardrail { + const params = item.litellm_params ?? {}; + const info = item.guardrail_info ?? {}; + const headers = params.headers; + const customHeaders: { key: string; value: string }[] = Array.isArray(headers) + ? headers.map((h: { key?: string; name?: string; value: string }) => ({ + key: (h.key ?? h.name ?? "").toString(), + value: String(h.value ?? ""), + })) + : typeof headers === "object" && headers !== null + ? Object.entries(headers).map(([key, value]) => ({ + key, + value: String(value ?? ""), + })) + : []; + const endpoint = + (params.api_base as string) ?? (params.url as string) ?? ""; + const model = + (info.model as string) ?? (params.model as string) ?? "—"; + const forwardKey = (params.forward_api_key as boolean) ?? true; + const extraHeaders = Array.isArray(params.extra_headers) + ? (params.extra_headers as string[]).filter((h): h is string => typeof h === "string") + : []; + return { + id: item.guardrail_id, + team: item.team_id ?? "—", + name: item.guardrail_name, + endpoint, + status: mapStatus(item.status), + model, + forwardKey, + description: (info.description as string) ?? "", + method: (params.method as "POST" | "GET") ?? "POST", + customHeaders, + extraHeaders, + submittedAt: formatSubmissionDate(item.submitted_at), + submittedBy: item.submitted_by_email ?? item.submitted_by_user_id ?? "—", + mode: params.mode as string | undefined, + unreachable_fallback: params.unreachable_fallback as string | undefined, + additionalProviderParams: params.additional_provider_specific_params as Record | undefined, + guardrailType: params.guardrail as string | undefined, + }; +} + +const STATUS_CONFIG: Record< + GuardrailStatus, + { label: string; bg: string; text: string; dot: string } +> = { + active: { + label: "Active", + bg: "bg-green-50", + text: "text-green-700", + dot: "bg-green-500", + }, + pending: { + label: "Pending Review", + bg: "bg-yellow-50", + text: "text-yellow-700", + dot: "bg-yellow-500", + }, + rejected: { + label: "Rejected", + bg: "bg-red-50", + text: "text-red-700", + dot: "bg-red-500", + }, +}; + +const TEAM_COLORS: Record = { + "ML Platform": "bg-purple-100 text-purple-700", + "Data Science": "bg-blue-100 text-blue-700", + Security: "bg-red-100 text-red-700", + "Customer Success": "bg-orange-100 text-orange-700", + Legal: "bg-gray-100 text-gray-700", + Finance: "bg-green-100 text-green-700", +}; + +function buildEquivalentConfigYaml(g: TeamGuardrail): string { + const lines: string[] = [ + "litellm_settings:", + " guardrails:", + ` - guardrail_name: "${g.name.replace(/"/g, '\\"')}"`, + " litellm_params:", + ` guardrail: ${g.guardrailType ?? "generic_guardrail_api"}`, + ` mode: ${g.mode ?? "pre_call"} # or post_call, during_call`, + ` api_base: ${g.endpoint || "https://your-guardrail-api.com"}`, + " api_key: os.environ/YOUR_GUARDRAIL_API_KEY # optional", + ` unreachable_fallback: ${g.unreachable_fallback ?? "fail_closed"} # default: fail_closed. Set to fail_open to proceed if the guardrail endpoint is unreachable.`, + ` forward_api_key: ${g.forwardKey}`, + ]; + if (g.model && g.model !== "—") { + lines.push(` model: "${g.model}" # LLM model name sent to the guardrail for context`); + } + if (g.customHeaders.length > 0) { + lines.push(" headers: # static headers (sent with every request)"); + for (const h of g.customHeaders) { + lines.push(` ${h.key}: "${String(h.value).replace(/"/g, '\\"')}"`); + } + } + if (g.extraHeaders.length > 0) { + lines.push(" extra_headers: # forward these client request headers to the guardrail"); + for (const name of g.extraHeaders) { + lines.push(` - ${name}`); + } + } + if (g.additionalProviderParams && Object.keys(g.additionalProviderParams).length > 0) { + lines.push(" additional_provider_specific_params:"); + for (const [k, v] of Object.entries(g.additionalProviderParams)) { + const val = typeof v === "string" ? `"${v}"` : String(v); + lines.push(` ${k}: ${val}`); + } + } + return lines.join("\n"); +} + +function StatCard({ + label, + value, + color, +}: { + label: string; + value: number; + color: string; +}) { + return ( +
+
{value}
+
{label}
+
+ ); +} + +function Toggle({ + enabled, + onToggle, +}: { + enabled: boolean; + onToggle: () => void; +}) { + return ( + + ); +} + +type GuardrailCardProps = { + guardrail: TeamGuardrail; + isSelected: boolean; + isHeadersExpanded: boolean; + onSelect: () => void; + onToggleForwardKey: () => void; + onToggleHeaders: () => void; + onApprove: () => void; + onReject: () => void; +}; + +function GuardrailCard({ + guardrail: g, + isSelected, + isHeadersExpanded, + onSelect, + onToggleForwardKey, + onToggleHeaders, + onApprove, + onReject, +}: GuardrailCardProps) { + const status = STATUS_CONFIG[g.status]; + const teamColor = TEAM_COLORS[g.team] ?? "bg-gray-100 text-gray-700"; + return ( +
+
+
+
+ + Team: {g.team} + + + + {status.label} + +
+

{g.name}

+

+ {g.description} +

+
+ + + {g.endpoint} + +
+
+ + Model: {g.model} + + + Submitted:{" "} + {g.submittedAt} + +
+
+
+
+ + Forward API Key + + +
+
+ + {g.status === "pending" && ( + <> + + + + )} +
+
+
+
+ + {isHeadersExpanded && ( +
+ {g.customHeaders.length === 0 ? ( +

+ No static headers configured. +

+ ) : ( +
+ {g.customHeaders.map((h, i) => ( +
+ + {h.key} + + : + + {h.value} + +
+ ))} +
+ )} +
+ )} +
+
+ ); +} + +function ConfigRow({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +type DetailPanelProps = { + guardrail: TeamGuardrail; + onClose: () => void; + onApprove: () => void; + onReject: () => void; + onToggleForwardKey: () => void; + onUpdateCustomHeaders: ( + customHeaders: { key: string; value: string }[] + ) => Promise; + onUpdateExtraHeaders: (extraHeaders: string[]) => Promise; +}; + +function DetailPanel({ + guardrail: g, + onClose, + onApprove, + onReject, + onToggleForwardKey, + onUpdateCustomHeaders, + onUpdateExtraHeaders, +}: DetailPanelProps) { + const [configExpanded, setConfigExpanded] = useState(false); + const [newExtraHeader, setNewExtraHeader] = useState(""); + const [newStaticHeaderKey, setNewStaticHeaderKey] = useState(""); + const [newStaticHeaderValue, setNewStaticHeaderValue] = useState(""); + const status = STATUS_CONFIG[g.status]; + const teamColor = TEAM_COLORS[g.team] ?? "bg-gray-100 text-gray-700"; + return ( +
+ ); +} + +type ConfirmDialogProps = { + action: "approve" | "reject"; + guardrailName: string; + onConfirm: () => void; + onCancel: () => void; +}; + +function ConfirmDialog({ + action, + guardrailName, + onConfirm, + onCancel, +}: ConfirmDialogProps) { + const isApprove = action === "approve"; + return ( +
+
+
+ {isApprove ? ( + + ) : ( + + )} +
+

+ {isApprove ? "Approve Guardrail" : "Reject Guardrail"} +

+

+ Are you sure you want to {action}{" "} + "{guardrailName}"?{" "} + {isApprove + ? "This will make it active and available for use." + : "This will mark it as rejected and notify the team."} +

+
+ + +
+
+
+ ); +} + +interface TeamGuardrailsTabProps { + accessToken: string | null; +} + +export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) { + const [guardrails, setGuardrails] = useState([]); + const [summary, setSummary] = useState({ + total: 0, + pending_review: 0, + active: 0, + rejected: 0, + }); + const [search, setSearch] = useState(""); + const [statusFilter, setStatusFilter] = useState< + "all" | GuardrailStatus + >("all"); + const [selectedId, setSelectedId] = useState(null); + const [expandedHeaders, setExpandedHeaders] = useState>(new Set()); + const [confirmAction, setConfirmAction] = useState<{ + id: string; + action: "approve" | "reject"; + } | null>(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [searchDebounced, setSearchDebounced] = useState(""); + + useEffect(() => { + const t = setTimeout(() => setSearchDebounced(search), 300); + return () => clearTimeout(t); + }, [search]); + + const fetchSubmissions = useCallback(async () => { + if (!accessToken) { + setIsLoading(false); + return; + } + setIsLoading(true); + setError(null); + try { + const statusParam = + statusFilter === "all" + ? undefined + : statusFilter === "pending" + ? "pending_review" + : statusFilter; + const res = await listGuardrailSubmissions(accessToken, { + status: statusParam, + search: searchDebounced.trim() || undefined, + }); + setGuardrails(res.submissions.map(submissionToTeamGuardrail)); + setSummary(res.summary); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load submissions"); + setGuardrails([]); + } finally { + setIsLoading(false); + } + }, [accessToken, statusFilter, searchDebounced]); + + useEffect(() => { + fetchSubmissions(); + }, [fetchSubmissions]); + + const filtered = guardrails; + const selected = guardrails.find((g) => g.id === selectedId) ?? null; + const totalCount = summary.total; + const pendingCount = summary.pending_review; + const activeCount = summary.active; + const rejectedCount = summary.rejected; + + async function toggleForwardKey(id: string) { + if (!accessToken) return; + const g = guardrails.find((x) => x.id === id); + if (!g) return; + const newValue = !g.forwardKey; + try { + await updateGuardrailCall(accessToken, id, { + litellm_params: { forward_api_key: newValue }, + }); + setGuardrails((prev) => + prev.map((x) => (x.id === id ? { ...x, forwardKey: newValue } : x)) + ); + NotificationsManager.success( + newValue ? "Forward API key enabled" : "Forward API key disabled" + ); + } catch { + NotificationsManager.fromBackend("Failed to update forward API key"); + } + } + + async function updateCustomHeaders( + id: string, + customHeaders: { key: string; value: string }[] + ) { + if (!accessToken) return; + const headersObj: Record = {}; + for (const { key, value } of customHeaders) { + if (key.trim()) headersObj[key.trim()] = value; + } + try { + await updateGuardrailCall(accessToken, id, { + litellm_params: { headers: headersObj }, + }); + setGuardrails((prev) => + prev.map((x) => + x.id === id + ? { + ...x, + customHeaders: customHeaders.filter((h) => h.key.trim()), + } + : x + ) + ); + NotificationsManager.success("Static headers updated"); + } catch { + NotificationsManager.fromBackend("Failed to update static headers"); + } + } + + async function updateExtraHeaders(id: string, extraHeaders: string[]) { + if (!accessToken) return; + try { + await updateGuardrailCall(accessToken, id, { + litellm_params: { extra_headers: extraHeaders }, + }); + setGuardrails((prev) => + prev.map((x) => (x.id === id ? { ...x, extraHeaders } : x)) + ); + NotificationsManager.success("Forward client headers updated"); + } catch { + NotificationsManager.fromBackend("Failed to update forward client headers"); + } + } + + async function handleApprove(id: string) { + if (!accessToken) return; + try { + await approveGuardrailSubmission(accessToken, id); + setConfirmAction(null); + if (selectedId === id) setSelectedId(null); + await fetchSubmissions(); + NotificationsManager.success("Guardrail approved"); + } catch { + NotificationsManager.fromBackend("Failed to approve guardrail"); + } + } + + async function handleReject(id: string) { + if (!accessToken) return; + try { + await rejectGuardrailSubmission(accessToken, id); + setConfirmAction(null); + if (selectedId === id) setSelectedId(null); + await fetchSubmissions(); + NotificationsManager.success("Guardrail rejected"); + } catch { + NotificationsManager.fromBackend("Failed to reject guardrail"); + } + } + + function toggleHeaders(id: string) { + setExpandedHeaders((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + return ( +
+
+
+ + + + +
+
+
+ + setSearch(e.target.value)} + className="w-full pl-9 pr-4 py-2 border border-gray-200 rounded-md text-sm text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500" + /> +
+ + +
+
+ {isLoading && ( +
+ Loading submissions… +
+ )} + {error && ( +
+ {error} +
+ )} + {!isLoading && !error && filtered.length === 0 && ( +
+ No guardrails match your filters. +
+ )} + {!isLoading && !error && filtered.map((g) => ( + setSelectedId(selectedId === g.id ? null : g.id)} + onToggleForwardKey={() => toggleForwardKey(g.id)} + onToggleHeaders={() => toggleHeaders(g.id)} + onApprove={() => setConfirmAction({ id: g.id, action: "approve" })} + onReject={() => setConfirmAction({ id: g.id, action: "reject" })} + /> + ))} +
+
+ {selected && ( + setSelectedId(null)} + onApprove={() => + setConfirmAction({ id: selected.id, action: "approve" }) + } + onReject={() => + setConfirmAction({ id: selected.id, action: "reject" }) + } + onToggleForwardKey={() => toggleForwardKey(selected.id)} + onUpdateCustomHeaders={(customHeaders) => + updateCustomHeaders(selected.id, customHeaders) + } + onUpdateExtraHeaders={(extraHeaders) => + updateExtraHeaders(selected.id, extraHeaders) + } + /> + )} + {confirmAction && ( + g.id === confirmAction.id)?.name ?? "" + } + onConfirm={() => + confirmAction.action === "approve" + ? handleApprove(confirmAction.id) + : handleReject(confirmAction.id) + } + onCancel={() => setConfirmAction(null)} + /> + )} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 0df6d813d7c..f64e909ae3e 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -5484,6 +5484,131 @@ export const getGuardrailsList = async (accessToken: string) => { } }; +// Team guardrail submissions (admin) +export interface GuardrailSubmissionItem { + guardrail_id: string; + guardrail_name: string; + status: string; // "pending_review" | "active" | "rejected" + team_id?: string | null; + team_guardrail?: boolean; // true when submitted via team (team_id set) + litellm_params?: Record | null; + guardrail_info?: Record | null; + submitted_by_user_id?: string | null; + submitted_by_email?: string | null; + submitted_at?: string | null; + reviewed_at?: string | null; + created_at?: string | null; + updated_at?: string | null; +} + +export interface GuardrailSubmissionSummary { + total: number; + pending_review: number; + active: number; + rejected: number; +} + +export interface ListGuardrailSubmissionsResponse { + submissions: GuardrailSubmissionItem[]; + summary: GuardrailSubmissionSummary; +} + +export const listGuardrailSubmissions = async ( + accessToken: string, + params?: { status?: string; team_id?: string; team_guardrail?: boolean; search?: string } +): Promise => { + const url = proxyBaseUrl ? `${proxyBaseUrl}/guardrails/submissions` : `/guardrails/submissions`; + const searchParams = new URLSearchParams(); + if (params?.status) searchParams.set("status", params.status); + if (params?.team_id) searchParams.set("team_id", params.team_id); + if (params?.team_guardrail !== undefined) searchParams.set("team_guardrail", String(params.team_guardrail)); + if (params?.search) searchParams.set("search", params.search); + const fullUrl = searchParams.toString() ? `${url}?${searchParams.toString()}` : url; + const response = await fetch(fullUrl, { + method: "GET", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + return response.json(); +}; + +export const getGuardrailSubmission = async ( + accessToken: string, + guardrailId: string +): Promise => { + const url = proxyBaseUrl + ? `${proxyBaseUrl}/guardrails/submissions/${encodeURIComponent(guardrailId)}` + : `/guardrails/submissions/${encodeURIComponent(guardrailId)}`; + const response = await fetch(url, { + method: "GET", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + return response.json(); +}; + +export const approveGuardrailSubmission = async ( + accessToken: string, + guardrailId: string +): Promise<{ guardrail_id: string; status: string; message: string }> => { + const url = proxyBaseUrl + ? `${proxyBaseUrl}/guardrails/submissions/${encodeURIComponent(guardrailId)}/approve` + : `/guardrails/submissions/${encodeURIComponent(guardrailId)}/approve`; + const response = await fetch(url, { + method: "POST", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + return response.json(); +}; + +export const rejectGuardrailSubmission = async ( + accessToken: string, + guardrailId: string +): Promise<{ guardrail_id: string; status: string; message: string }> => { + const url = proxyBaseUrl + ? `${proxyBaseUrl}/guardrails/submissions/${encodeURIComponent(guardrailId)}/reject` + : `/guardrails/submissions/${encodeURIComponent(guardrailId)}/reject`; + const response = await fetch(url, { + method: "POST", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + return response.json(); +}; + // Guardrails / Policies usage (dashboard) export const getGuardrailsUsageOverview = async ( accessToken: string, @@ -8364,6 +8489,7 @@ export const updateGuardrailCall = async ( guardrail_name?: string; default_on?: boolean; guardrail_info?: Record; + litellm_params?: Record; }, ) => { try { From d07689d2d70804959dbd2e2e39cc797e3a96e37e Mon Sep 17 00:00:00 2001 From: Harshit28j Date: Tue, 3 Mar 2026 11:59:58 +0530 Subject: [PATCH 10/69] =?UTF-8?q?bump:=20version=201.82.0=20=E2=86=92=201.?= =?UTF-8?q?82.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 577e51a0d22..6f9add2e4cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "litellm" -version = "1.82.0" +version = "1.82.1" description = "Library to easily interface with LLM API providers" authors = ["BerriAI"] license = "MIT" @@ -183,7 +183,7 @@ requires = ["poetry-core", "wheel"] build-backend = "poetry.core.masonry.api" [tool.commitizen] -version = "1.82.0" +version = "1.82.1" version_files = [ "pyproject.toml:^version" ] From f8034f15ada8daf1bb0ed08ead330e3f7e67eee8 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 3 Mar 2026 14:51:12 +0530 Subject: [PATCH 11/69] Remove defualt hardcoded thinking levels for gemini 3 family --- .../vertex_and_google_ai_studio_gemini.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py index dee826e5783..0905f22362e 100644 --- a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py +++ b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py @@ -1136,23 +1136,6 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): if VertexGeminiConfig._is_gemini_3_or_newer(model): if "temperature" not in optional_params: optional_params["temperature"] = 1.0 - # Only add thinkingLevel if model supports it (exclude image models) - if "image" not in model.lower(): - thinking_config = optional_params.get("thinkingConfig", {}) - if ( - "thinkingLevel" not in thinking_config - and "thinkingBudget" not in thinking_config - ): - # For gemini-3-flash-preview, default to "minimal" to match Gemini 2.5 Flash behavior - # For other Gemini 3 models, default to "low" - is_gemini3flash = ( - "gemini-3-flash-preview" in model.lower() - or "gemini-3-flash" in model.lower() - ) - thinking_config["thinkingLevel"] = ( - "minimal" if is_gemini3flash else "low" - ) - optional_params["thinkingConfig"] = thinking_config return optional_params From 213423cb45308e47538b207dc043c1149e75cda7 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 3 Mar 2026 15:05:20 +0530 Subject: [PATCH 12/69] Fix test case --- .../test_vertex_and_google_ai_studio_gemini.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py index 196bb00f40d..8beb19bf1ac 100644 --- a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py +++ b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py @@ -2130,7 +2130,7 @@ def test_reasoning_effort_dict_format_gemini_3(): assert result["thinkingConfig"]["thinkingLevel"] == "high" assert result["thinkingConfig"]["includeThoughts"] is True - # Test dict format without effort key - should fall back to Gemini 3 default (low) + # Test dict format without effort key - no thinkingConfig should be set optional_params = {} non_default_params = {"reasoning_effort": {"summary": "auto"}} result = v.map_openai_params( @@ -2139,8 +2139,8 @@ def test_reasoning_effort_dict_format_gemini_3(): model=model, drop_params=False, ) - # Gemini 3 defaults to thinkingLevel="low" when no explicit effort is set - assert result["thinkingConfig"]["thinkingLevel"] == "low" + # No effort key in dict → no thinkingConfig set + assert "thinkingConfig" not in result def test_temperature_default_for_gemini_3(): @@ -2453,8 +2453,8 @@ def test_gemini_3_image_models_no_thinking_config(): def test_gemini_3_text_models_get_thinking_config(): """ - Test that Gemini 3 text models DO receive automatic thinkingConfig. - This ensures we didn't break the existing behavior for non-image models. + Test that Gemini 3 text models do NOT receive automatic thinkingConfig + when no reasoning_effort or thinking param is provided. """ from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( VertexGeminiConfig, @@ -2462,7 +2462,7 @@ def test_gemini_3_text_models_get_thinking_config(): v = VertexGeminiConfig() - # Test gemini-3-pro-preview (text model, should get thinking) + # Test gemini-3-pro-preview (text model, no explicit thinking params) model = "gemini-3-pro-preview" optional_params = {} non_default_params = {} @@ -2474,9 +2474,8 @@ def test_gemini_3_text_models_get_thinking_config(): drop_params=False, ) - # Should have thinkingConfig automatically added - assert "thinkingConfig" in result - assert result["thinkingConfig"]["thinkingLevel"] == "low" + # Should NOT have thinkingConfig automatically added when user provides no reasoning_effort + assert "thinkingConfig" not in result assert result["temperature"] == 1.0 From 851be587751fef0dca958394c9bd77746de1cae6 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 3 Mar 2026 15:07:47 +0530 Subject: [PATCH 13/69] Add day 0 support of gemini-3.1-flash-lite-preview --- docs/my-website/docs/providers/gemini.md | 1 + docs/my-website/docs/providers/vertex.md | 1 + ...odel_prices_and_context_window_backup.json | 498 ++++++++++++++++-- model_prices_and_context_window.json | 145 +++++ .../llm_cost_calc/test_llm_cost_calc_utils.py | 48 +- ...test_vertex_and_google_ai_studio_gemini.py | 11 +- 6 files changed, 642 insertions(+), 62 deletions(-) diff --git a/docs/my-website/docs/providers/gemini.md b/docs/my-website/docs/providers/gemini.md index 6de2263916c..f97f025c19b 100644 --- a/docs/my-website/docs/providers/gemini.md +++ b/docs/my-website/docs/providers/gemini.md @@ -2041,6 +2041,7 @@ response = litellm.completion( | gemini-2.0-flash-lite-preview-02-05 | `completion(model='gemini/gemini-2.0-flash-lite-preview-02-05', messages)` | `os.environ['GEMINI_API_KEY']` | | gemini-2.5-flash-preview-09-2025 | `completion(model='gemini/gemini-2.5-flash-preview-09-2025', messages)` | `os.environ['GEMINI_API_KEY']` | | gemini-2.5-flash-lite-preview-09-2025 | `completion(model='gemini/gemini-2.5-flash-lite-preview-09-2025', messages)` | `os.environ['GEMINI_API_KEY']` | +| gemini-3.1-flash-lite-preview | `completion(model='gemini/gemini-3.1-flash-lite-preview', messages)` | `os.environ['GEMINI_API_KEY']` | | gemini-flash-latest | `completion(model='gemini/gemini-flash-latest', messages)` | `os.environ['GEMINI_API_KEY']` | | gemini-flash-lite-latest | `completion(model='gemini/gemini-flash-lite-latest', messages)` | `os.environ['GEMINI_API_KEY']` | diff --git a/docs/my-website/docs/providers/vertex.md b/docs/my-website/docs/providers/vertex.md index 63e4dceec00..94619082e88 100644 --- a/docs/my-website/docs/providers/vertex.md +++ b/docs/my-website/docs/providers/vertex.md @@ -1685,6 +1685,7 @@ litellm.vertex_location = "us-central1 # Your Location | gemini-2.5-pro | `completion('gemini-2.5-pro', messages)`, `completion('vertex_ai/gemini-2.5-pro', messages)` | | gemini-2.5-flash-preview-09-2025 | `completion('gemini-2.5-flash-preview-09-2025', messages)`, `completion('vertex_ai/gemini-2.5-flash-preview-09-2025', messages)` | | gemini-2.5-flash-lite-preview-09-2025 | `completion('gemini-2.5-flash-lite-preview-09-2025', messages)`, `completion('vertex_ai/gemini-2.5-flash-lite-preview-09-2025', messages)` | +| gemini-3.1-flash-lite-preview | `completion('gemini-3.1-flash-lite-preview', messages)`, `completion('vertex_ai/gemini-3.1-flash-lite-preview', messages)` | ## Private Service Connect (PSC) Endpoints diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index d4c5b476af6..42b4f0f7762 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -846,7 +846,9 @@ "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 2.5e-08, + "cache_creation_input_token_cost": 3.125e-07 }, "anthropic.claude-3-opus-20240229-v1:0": { "input_cost_per_token": 1.5e-05, @@ -859,7 +861,9 @@ "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 1.5e-06, + "cache_creation_input_token_cost": 1.875e-05 }, "anthropic.claude-3-sonnet-20240229-v1:0": { "input_cost_per_token": 3e-06, @@ -873,7 +877,9 @@ "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 3e-07, + "cache_creation_input_token_cost": 3.75e-06 }, "anthropic.claude-instant-v1": { "input_cost_per_token": 8e-07, @@ -1512,7 +1518,9 @@ "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 3e-07, + "cache_creation_input_token_cost": 3.75e-06 }, "apac.anthropic.claude-3-5-sonnet-20241022-v2:0": { "cache_creation_input_token_cost": 3.75e-06, @@ -1545,7 +1553,9 @@ "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 2.5e-08, + "cache_creation_input_token_cost": 3.125e-07 }, "apac.anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 1.375e-06, @@ -1581,7 +1591,9 @@ "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 3e-07, + "cache_creation_input_token_cost": 3.75e-06 }, "apac.anthropic.claude-sonnet-4-20250514-v1:0": { "cache_creation_input_token_cost": 3.75e-06, @@ -6925,7 +6937,9 @@ "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 3e-07, + "cache_creation_input_token_cost": 3.75e-06 }, "bedrock/sa-east-1/meta.llama3-70b-instruct-v1:0": { "input_cost_per_token": 4.45e-06, @@ -7344,7 +7358,9 @@ "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 3.6e-07, + "cache_creation_input_token_cost": 4.5e-06 }, "bedrock/us-gov-east-1/anthropic.claude-3-haiku-20240307-v1:0": { "input_cost_per_token": 3e-07, @@ -7358,7 +7374,9 @@ "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 3e-08, + "cache_creation_input_token_cost": 3.75e-07 }, "bedrock/us-gov-east-1/claude-sonnet-4-5-20250929-v1:0": { "input_cost_per_token": 3.3e-06, @@ -7376,7 +7394,9 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 3.3e-07, + "cache_creation_input_token_cost": 4.125e-06 }, "bedrock/us-gov-east-1/meta.llama3-70b-instruct-v1:0": { "input_cost_per_token": 2.65e-06, @@ -7489,7 +7509,9 @@ "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 3.6e-07, + "cache_creation_input_token_cost": 4.5e-06 }, "bedrock/us-gov-west-1/anthropic.claude-3-haiku-20240307-v1:0": { "input_cost_per_token": 3e-07, @@ -7503,7 +7525,9 @@ "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 3e-08, + "cache_creation_input_token_cost": 3.75e-07 }, "bedrock/us-gov-west-1/claude-sonnet-4-5-20250929-v1:0": { "input_cost_per_token": 3.3e-06, @@ -7521,7 +7545,9 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 3.3e-07, + "cache_creation_input_token_cost": 4.125e-06 }, "bedrock/us-gov-west-1/meta.llama3-70b-instruct-v1:0": { "input_cost_per_token": 2.65e-06, @@ -9753,6 +9779,74 @@ } ] }, + "dashscope/qwen3-vl-plus": { + "litellm_provider": "dashscope", + "max_input_tokens": 260096, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true, + "tiered_pricing": [ + { + "input_cost_per_token": 2e-07, + "output_cost_per_token": 1.6e-06, + "range": [ + 0, + 32000.0 + ] + }, + { + "input_cost_per_token": 3e-07, + "output_cost_per_token": 2.4e-06, + "range": [ + 32000.0, + 128000.0 + ] + }, + { + "input_cost_per_token": 6e-07, + "output_cost_per_token": 4.8e-06, + "range": [ + 128000.0, + 256000.0 + ] + } + ] + }, + "dashscope/qwen3.5-plus": { + "litellm_provider": "dashscope", + "max_input_tokens": 991808, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true, + "tiered_pricing": [ + { + "input_cost_per_token": 4e-07, + "output_cost_per_token": 2.4e-06, + "range": [ + 0, + 256000.0 + ] + }, + { + "input_cost_per_token": 5e-07, + "output_cost_per_token": 3e-06, + "range": [ + 256000.0, + 1000000.0 + ] + } + ] + }, "dashscope/qwq-plus": { "input_cost_per_token": 8e-07, "litellm_provider": "dashscope", @@ -11089,7 +11183,7 @@ "supports_tool_choice": true }, "deepinfra/google/gemini-2.0-flash-001": { - "deprecation_date": "2026-03-31", + "deprecation_date": "2026-06-01", "max_tokens": 1000000, "max_input_tokens": 1000000, "max_output_tokens": 1000000, @@ -11950,7 +12044,9 @@ "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "cache_read_input_token_cost": 2.5e-08, + "cache_creation_input_token_cost": 3.125e-07 }, "eu.anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 1.375e-06, @@ -11987,7 +12083,9 @@ "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 3e-07, + "cache_creation_input_token_cost": 3.75e-06 }, "eu.anthropic.claude-3-5-sonnet-20241022-v2:0": { "input_cost_per_token": 3e-06, @@ -12004,7 +12102,9 @@ "supports_prompt_caching": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 3e-07, + "cache_creation_input_token_cost": 3.75e-06 }, "eu.anthropic.claude-3-7-sonnet-20250219-v1:0": { "input_cost_per_token": 3e-06, @@ -12022,7 +12122,9 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 3e-07, + "cache_creation_input_token_cost": 3.75e-06 }, "eu.anthropic.claude-3-haiku-20240307-v1:0": { "input_cost_per_token": 2.5e-07, @@ -12036,7 +12138,9 @@ "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 2.5e-08, + "cache_creation_input_token_cost": 3.125e-07 }, "eu.anthropic.claude-3-opus-20240229-v1:0": { "input_cost_per_token": 1.5e-05, @@ -12049,7 +12153,9 @@ "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 1.5e-06, + "cache_creation_input_token_cost": 1.875e-05 }, "eu.anthropic.claude-3-sonnet-20240229-v1:0": { "input_cost_per_token": 3e-06, @@ -12063,7 +12169,9 @@ "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 3e-07, + "cache_creation_input_token_cost": 3.75e-06 }, "eu.anthropic.claude-opus-4-1-20250805-v1:0": { "cache_creation_input_token_cost": 1.875e-05, @@ -13590,7 +13698,7 @@ }, "gemini-2.0-flash": { "cache_read_input_token_cost": 2.5e-08, - "deprecation_date": "2026-03-31", + "deprecation_date": "2026-06-01", "input_cost_per_audio_token": 7e-07, "input_cost_per_token": 1e-07, "litellm_provider": "vertex_ai-language-models", @@ -13630,7 +13738,7 @@ }, "gemini-2.0-flash-001": { "cache_read_input_token_cost": 3.75e-08, - "deprecation_date": "2026-03-31", + "deprecation_date": "2026-06-01", "input_cost_per_audio_token": 1e-06, "input_cost_per_token": 1.5e-07, "litellm_provider": "vertex_ai-language-models", @@ -13716,7 +13824,7 @@ }, "gemini-2.0-flash-lite": { "cache_read_input_token_cost": 1.875e-08, - "deprecation_date": "2026-03-31", + "deprecation_date": "2026-06-01", "input_cost_per_audio_token": 7.5e-08, "input_cost_per_token": 7.5e-08, "litellm_provider": "vertex_ai-language-models", @@ -13752,7 +13860,7 @@ }, "gemini-2.0-flash-lite-001": { "cache_read_input_token_cost": 1.875e-08, - "deprecation_date": "2026-03-31", + "deprecation_date": "2026-06-01", "input_cost_per_audio_token": 7.5e-08, "input_cost_per_token": 7.5e-08, "litellm_provider": "vertex_ai-language-models", @@ -14226,6 +14334,53 @@ "supports_vision": true, "supports_web_search": true }, + "gemini-3.1-flash-lite-preview": { + "cache_read_input_token_cost": 2.5e-09, + "input_cost_per_audio_token": 2.5e-07, + "input_cost_per_token": 2.5e-07, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 1.5e-06, + "output_cost_per_token": 1.5e-06, + "source": "https://ai.google.dev/gemini-api/docs/models", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_input": true, + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true + }, "deep-research-pro-preview-12-2025": { "input_cost_per_image": 0.0011, "input_cost_per_token": 2e-06, @@ -14669,6 +14824,7 @@ "supports_web_search": true }, "gemini-3-pro-preview": { + "deprecation_date": "2026-03-26", "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07, @@ -15805,7 +15961,7 @@ }, "gemini/gemini-2.0-flash": { "cache_read_input_token_cost": 2.5e-08, - "deprecation_date": "2026-03-31", + "deprecation_date": "2026-06-01", "input_cost_per_audio_token": 7e-07, "input_cost_per_token": 1e-07, "litellm_provider": "gemini", @@ -15846,7 +16002,7 @@ }, "gemini/gemini-2.0-flash-001": { "cache_read_input_token_cost": 2.5e-08, - "deprecation_date": "2026-03-31", + "deprecation_date": "2026-06-01", "input_cost_per_audio_token": 7e-07, "input_cost_per_token": 1e-07, "litellm_provider": "gemini", @@ -15934,7 +16090,7 @@ }, "gemini/gemini-2.0-flash-lite": { "cache_read_input_token_cost": 1.875e-08, - "deprecation_date": "2026-03-31", + "deprecation_date": "2026-06-01", "input_cost_per_audio_token": 7.5e-08, "input_cost_per_token": 7.5e-08, "litellm_provider": "gemini", @@ -15970,7 +16126,7 @@ "tpm": 4000000 }, "gemini/gemini-2.0-flash-lite-preview-02-05": { - "deprecation_date": "2025-12-02", + "deprecation_date": "2025-12-09", "cache_read_input_token_cost": 1.875e-08, "input_cost_per_audio_token": 7.5e-08, "input_cost_per_token": 7.5e-08, @@ -16925,6 +17081,7 @@ "tpm": 800000 }, "gemini/gemini-3-pro-preview": { + "deprecation_date": "2026-03-09", "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "input_cost_per_token": 2e-06, @@ -16980,6 +17137,56 @@ "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, "supports_service_tier": true }, + "gemini/gemini-3.1-flash-lite-preview": { + "cache_read_input_token_cost": 2.5e-09, + "input_cost_per_audio_token": 2.5e-07, + "input_cost_per_token": 2.5e-07, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 1.5e-06, + "output_cost_per_token": 1.5e-06, + "rpm": 15, + "source": "https://ai.google.dev/gemini-api/docs/models", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_input": true, + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true, + "supports_native_streaming": true, + "tpm": 250000 + }, "gemini/gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, "input_cost_per_audio_token": 1e-06, @@ -23112,6 +23319,21 @@ "supports_response_schema": true, "supports_tool_choice": true }, + "mistral/magistral-medium-1-2-2509": { + "input_cost_per_token": 2e-06, + "litellm_provider": "mistral", + "max_input_tokens": 40000, + "max_output_tokens": 40000, + "max_tokens": 40000, + "mode": "chat", + "output_cost_per_token": 5e-06, + "source": "https://mistral.ai/news/magistral", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, "mistral/mistral-ocr-latest": { "litellm_provider": "mistral", "ocr_cost_per_page": 0.001, @@ -23177,6 +23399,21 @@ "supports_response_schema": true, "supports_tool_choice": true }, + "mistral/magistral-small-1-2-2509": { + "input_cost_per_token": 5e-07, + "litellm_provider": "mistral", + "max_input_tokens": 40000, + "max_output_tokens": 40000, + "max_tokens": 40000, + "mode": "chat", + "output_cost_per_token": 1.5e-06, + "source": "https://mistral.ai/pricing#api-pricing", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, "mistral/mistral-embed": { "input_cost_per_token": 1e-07, "litellm_provider": "mistral", @@ -23238,24 +23475,41 @@ "supports_tool_choice": true }, "mistral/mistral-large-latest": { - "input_cost_per_token": 2e-06, + "input_cost_per_token": 5e-07, "litellm_provider": "mistral", - "max_input_tokens": 128000, - "max_output_tokens": 128000, - "max_tokens": 128000, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, "mode": "chat", - "output_cost_per_token": 6e-06, + "output_cost_per_token": 1.5e-06, + "source": "https://docs.mistral.ai/models/mistral-large-3-25-12", "supports_assistant_prefill": true, "supports_function_calling": true, "supports_response_schema": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "supports_vision": true }, "mistral/mistral-large-3": { "input_cost_per_token": 5e-07, "litellm_provider": "mistral", - "max_input_tokens": 256000, - "max_output_tokens": 8191, - "max_tokens": 8191, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 1.5e-06, + "source": "https://docs.mistral.ai/models/mistral-large-3-25-12", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "mistral/mistral-large-2512": { + "input_cost_per_token": 5e-07, + "litellm_provider": "mistral", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, "mode": "chat", "output_cost_per_token": 1.5e-06, "source": "https://docs.mistral.ai/models/mistral-large-3-25-12", @@ -23306,14 +23560,30 @@ "input_cost_per_token": 4e-07, "litellm_provider": "mistral", "max_input_tokens": 131072, - "max_output_tokens": 8191, - "max_tokens": 8191, + "max_output_tokens": 131072, + "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 2e-06, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_response_schema": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "supports_vision": true + }, + "mistral/mistral-medium-3-1-2508": { + "input_cost_per_token": 4e-07, + "litellm_provider": "mistral", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2e-06, + "source": "https://mistral.ai/news/mistral-medium-3", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true }, "mistral/mistral-small": { "input_cost_per_token": 1e-07, @@ -23329,17 +23599,79 @@ "supports_tool_choice": true }, "mistral/mistral-small-latest": { - "input_cost_per_token": 1e-07, + "input_cost_per_token": 6e-08, "litellm_provider": "mistral", - "max_input_tokens": 32000, - "max_output_tokens": 8191, - "max_tokens": 8191, + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, "mode": "chat", - "output_cost_per_token": 3e-07, + "output_cost_per_token": 1.8e-07, + "source": "https://mistral.ai/pricing", "supports_assistant_prefill": true, "supports_function_calling": true, "supports_response_schema": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "supports_vision": true + }, + "mistral/mistral-small-3-2-2506": { + "input_cost_per_token": 6e-08, + "litellm_provider": "mistral", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.8e-07, + "source": "https://mistral.ai/pricing", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "mistral/ministral-3-3b-2512": { + "input_cost_per_token": 1e-07, + "litellm_provider": "mistral", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1e-07, + "source": "https://mistral.ai/pricing", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "mistral/ministral-3-8b-2512": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "mistral", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 1.5e-07, + "source": "https://mistral.ai/pricing", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "mistral/ministral-3-14b-2512": { + "input_cost_per_token": 2e-07, + "litellm_provider": "mistral", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 2e-07, + "source": "https://mistral.ai/pricing", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true }, "mistral/mistral-tiny": { "input_cost_per_token": 2.5e-07, @@ -25657,7 +25989,7 @@ "supports_tool_choice": true }, "openrouter/google/gemini-2.0-flash-001": { - "deprecation_date": "2026-03-31", + "deprecation_date": "2026-06-01", "input_cost_per_audio_token": 7e-07, "input_cost_per_token": 1e-07, "litellm_provider": "openrouter", @@ -29554,7 +29886,9 @@ "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 3e-07, + "cache_creation_input_token_cost": 3.75e-06 }, "us.anthropic.claude-3-5-sonnet-20241022-v2:0": { "cache_creation_input_token_cost": 3.75e-06, @@ -29607,7 +29941,9 @@ "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 2.5e-08, + "cache_creation_input_token_cost": 3.125e-07 }, "us.anthropic.claude-3-opus-20240229-v1:0": { "input_cost_per_token": 1.5e-05, @@ -29620,7 +29956,9 @@ "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 1.5e-06, + "cache_creation_input_token_cost": 1.875e-05 }, "us.anthropic.claude-3-sonnet-20240229-v1:0": { "input_cost_per_token": 3e-06, @@ -29634,7 +29972,9 @@ "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "cache_read_input_token_cost": 3e-07, + "cache_creation_input_token_cost": 3.75e-06 }, "us.anthropic.claude-opus-4-1-20250805-v1:0": { "cache_creation_input_token_cost": 1.875e-05, @@ -30527,7 +30867,7 @@ "supports_tool_choice": true }, "vercel_ai_gateway/google/gemini-2.0-flash": { - "deprecation_date": "2026-03-31", + "deprecation_date": "2026-06-01", "input_cost_per_token": 1.5e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 1048576, @@ -30541,7 +30881,7 @@ "supports_response_schema": true }, "vercel_ai_gateway/google/gemini-2.0-flash-lite": { - "deprecation_date": "2026-03-31", + "deprecation_date": "2026-06-01", "input_cost_per_token": 7.5e-08, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 1048576, @@ -32059,6 +32399,54 @@ "output_cost_per_token": 3e-06, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models" }, + "vertex_ai/gemini-3.1-flash-lite-preview": { + "cache_read_input_token_cost": 2.5e-09, + "input_cost_per_audio_token": 2.5e-07, + "input_cost_per_token": 2.5e-07, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 1.5e-06, + "output_cost_per_token": 1.5e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_input": true, + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true, + "supports_native_streaming": true + }, "vertex_ai/deep-research-pro-preview-12-2025": { "input_cost_per_image": 0.0011, "input_cost_per_token": 2e-06, @@ -37898,7 +38286,7 @@ }, "gemini/gemini-2.0-flash-lite-001": { "cache_read_input_token_cost": 1.875e-08, - "deprecation_date": "2026-03-31", + "deprecation_date": "2026-06-01", "input_cost_per_audio_token": 7.5e-08, "input_cost_per_token": 7.5e-08, "litellm_provider": "gemini", diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 4934f11d456..42b4f0f7762 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -14334,6 +14334,53 @@ "supports_vision": true, "supports_web_search": true }, + "gemini-3.1-flash-lite-preview": { + "cache_read_input_token_cost": 2.5e-09, + "input_cost_per_audio_token": 2.5e-07, + "input_cost_per_token": 2.5e-07, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 1.5e-06, + "output_cost_per_token": 1.5e-06, + "source": "https://ai.google.dev/gemini-api/docs/models", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_input": true, + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true + }, "deep-research-pro-preview-12-2025": { "input_cost_per_image": 0.0011, "input_cost_per_token": 2e-06, @@ -17090,6 +17137,56 @@ "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, "supports_service_tier": true }, + "gemini/gemini-3.1-flash-lite-preview": { + "cache_read_input_token_cost": 2.5e-09, + "input_cost_per_audio_token": 2.5e-07, + "input_cost_per_token": 2.5e-07, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 1.5e-06, + "output_cost_per_token": 1.5e-06, + "rpm": 15, + "source": "https://ai.google.dev/gemini-api/docs/models", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_input": true, + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true, + "supports_native_streaming": true, + "tpm": 250000 + }, "gemini/gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, "input_cost_per_audio_token": 1e-06, @@ -32302,6 +32399,54 @@ "output_cost_per_token": 3e-06, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models" }, + "vertex_ai/gemini-3.1-flash-lite-preview": { + "cache_read_input_token_cost": 2.5e-09, + "input_cost_per_audio_token": 2.5e-07, + "input_cost_per_token": 2.5e-07, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 1.5e-06, + "output_cost_per_token": 1.5e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_input": true, + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true, + "supports_native_streaming": true + }, "vertex_ai/deep-research-pro-preview-12-2025": { "input_cost_per_image": 0.0011, "input_cost_per_token": 2e-06, diff --git a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py index 7e8848be301..2e033b6f068 100644 --- a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py +++ b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py @@ -33,8 +33,8 @@ sys.path.insert( ) # Adds the parent directory to the system path from litellm.litellm_core_utils.llm_cost_calc.utils import ( - _calculate_input_cost, PromptTokensDetailsResult, + _calculate_input_cost, calculate_cache_writing_cost, generic_cost_per_token, ) @@ -127,6 +127,52 @@ def test_reasoning_tokens_gemini(): ) +def test_reasoning_tokens_gemini_3_1_flash_lite(): + """Test cost calculation for gemini-3.1-flash-lite-preview with reasoning tokens""" + model = "gemini-3.1-flash-lite-preview" + custom_llm_provider = "gemini" + os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" + litellm.model_cost = litellm.get_model_cost_map(url="") + + usage = Usage( + completion_tokens=1000, + prompt_tokens=500, + total_tokens=1500, + completion_tokens_details=CompletionTokensDetailsWrapper( + accepted_prediction_tokens=None, + audio_tokens=None, + reasoning_tokens=400, + rejected_prediction_tokens=None, + text_tokens=600, + ), + prompt_tokens_details=PromptTokensDetailsWrapper( + audio_tokens=None, cached_tokens=None, text_tokens=500, image_tokens=None + ), + ) + model_cost_map = litellm.model_cost[model] + prompt_cost, completion_cost = generic_cost_per_token( + model=model, + usage=usage, + custom_llm_provider=custom_llm_provider, + ) + + assert round(prompt_cost, 10) == round( + model_cost_map["input_cost_per_token"] * usage.prompt_tokens, + 10, + ) + assert round(completion_cost, 10) == round( + ( + model_cost_map["output_cost_per_token"] + * usage.completion_tokens_details.text_tokens + ) + + ( + model_cost_map["output_cost_per_reasoning_token"] + * usage.completion_tokens_details.reasoning_tokens + ), + 10, + ) + + def test_image_tokens_with_custom_pricing(): """Test that image_tokens in completion are properly costed with output_cost_per_image_token.""" from unittest.mock import patch diff --git a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py index 196bb00f40d..596897f7157 100644 --- a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py +++ b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py @@ -2453,8 +2453,8 @@ def test_gemini_3_image_models_no_thinking_config(): def test_gemini_3_text_models_get_thinking_config(): """ - Test that Gemini 3 text models DO receive automatic thinkingConfig. - This ensures we didn't break the existing behavior for non-image models. + Test that Gemini 3 text models do NOT receive automatic thinkingConfig + when no reasoning_effort or thinking param is provided. """ from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( VertexGeminiConfig, @@ -2462,7 +2462,7 @@ def test_gemini_3_text_models_get_thinking_config(): v = VertexGeminiConfig() - # Test gemini-3-pro-preview (text model, should get thinking) + # Test gemini-3-pro-preview (text model, no explicit thinking params) model = "gemini-3-pro-preview" optional_params = {} non_default_params = {} @@ -2474,9 +2474,8 @@ def test_gemini_3_text_models_get_thinking_config(): drop_params=False, ) - # Should have thinkingConfig automatically added - assert "thinkingConfig" in result - assert result["thinkingConfig"]["thinkingLevel"] == "low" + # Should NOT have thinkingConfig automatically added when user provides no reasoning_effort + assert "thinkingConfig" not in result assert result["temperature"] == 1.0 From deb8fea6b114eee809745c65995249f6d6b6457f Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 3 Mar 2026 15:19:16 +0530 Subject: [PATCH 14/69] Add blog post for gemini-3.1-flash-lite-preview --- .../blog/gemini_3_1_flash_lite/index.md | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 docs/my-website/blog/gemini_3_1_flash_lite/index.md diff --git a/docs/my-website/blog/gemini_3_1_flash_lite/index.md b/docs/my-website/blog/gemini_3_1_flash_lite/index.md new file mode 100644 index 00000000000..9ef4bacb2ad --- /dev/null +++ b/docs/my-website/blog/gemini_3_1_flash_lite/index.md @@ -0,0 +1,175 @@ +--- +slug: gemini_3_1_flash_lite_preview +title: "DAY 0 Support: Gemini 3.1 Flash Lite Preview on LiteLLM" +date: 2026-03-03T08:00:00 +authors: + - name: Sameer Kankute + title: SWE @ LiteLLM (LLM Translation) + url: https://www.linkedin.com/in/sameer-kankute/ + image_url: https://pbs.twimg.com/profile_images/2001352686994907136/ONgNuSk5_400x400.jpg + - name: Krrish Dholakia + title: "CEO, LiteLLM" + url: https://www.linkedin.com/in/krish-d/ + image_url: https://pbs.twimg.com/profile_images/1298587542745358340/DZv3Oj-h_400x400.jpg + - name: Ishaan Jaff + title: "CTO, LiteLLM" + url: https://www.linkedin.com/in/reffajnaahsi/ + image_url: https://pbs.twimg.com/profile_images/1613813310264340481/lz54oEiB_400x400.jpg +description: "Guide to using Gemini 3.1 Flash Lite Preview on LiteLLM Proxy and SDK with day 0 support." +tags: [gemini, day 0 support, llms, supernova] +hide_table_of_contents: false +--- + + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# Gemini 3.1 Flash Lite Preview Day 0 Support + +LiteLLM now supports `gemini-3.1-flash-lite-preview` with full day 0 support! + +:::note +If you only want cost tracking, you need no change in your current Litellm version. But if you want the support for new features introduced along with it like thinking levels, you will need to use v1.80.8-stable.1 or above. +::: + +## Deploy this version + + + + +``` showLineNumbers title="docker run litellm" +docker run \ +-e STORE_MODEL_IN_DB=True \ +-p 4000:4000 \ +ghcr.io/berriai/litellm:main-v1.80.8-stable.1 +``` + + + + + +``` showLineNumbers title="pip install litellm" +pip install litellm==v1.80.8-stable.1 +``` + + + + +## What's New + +Supports all four thinking levels: +- **MINIMAL**: Ultra-fast responses with minimal reasoning +- **LOW**: Simple instruction following +- **MEDIUM**: Balanced reasoning for complex tasks +- **HIGH**: Maximum reasoning depth (dynamic) + +--- + +## Quick Start + + + + +**Basic Usage** + +```python +from litellm import completion + +response = completion( + model="gemini/gemini-3.1-flash-lite-preview", + messages=[{"role": "user", "content": "Extract key entities from this text: ..."}], +) + +print(response.choices[0].message.content) +``` + +**With Thinking Levels** + +```python +from litellm import completion + +# Use MEDIUM thinking for complex reasoning tasks +response = completion( + model="gemini/gemini-3.1-flash-lite-preview", + messages=[{"role": "user", "content": "Analyze this dataset and identify patterns"}], + reasoning_effort="medium", # low, medium , high +) + +print(response.choices[0].message.content) +``` + + + + + +**1. Setup config.yaml** + +```yaml +model_list: + - model_name: gemini-3.1-flash-lite + litellm_params: + model: gemini/gemini-3.1-flash-lite-preview + api_key: os.environ/GEMINI_API_KEY + + # Or use Vertex AI + - model_name: vertex-gemini-3.1-flash-lite + litellm_params: + model: vertex_ai/gemini-3.1-flash-lite-preview + vertex_project: your-project-id + vertex_location: us-central1 +``` + +**2. Start proxy** + +```bash +litellm --config /path/to/config.yaml +``` + +**3. Make requests** + +```bash +curl -X POST http://localhost:4000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "model": "gemini-3.1-flash-lite", + "messages": [{"role": "user", "content": "Extract structured data from this text"}], + "reasoning_effort": "low" + }' +``` + + + + +--- + +## Supported Endpoints + +LiteLLM provides **full end-to-end support** for Gemini 3.1 Flash Lite Preview on: + +- ✅ `/v1/chat/completions` - OpenAI-compatible chat completions endpoint +- ✅ `/v1/responses` - OpenAI Responses API endpoint (streaming and non-streaming) +- ✅ [`/v1/messages`](../../docs/anthropic_unified) - Anthropic-compatible messages endpoint +- ✅ `/v1/generateContent` – [Google Gemini API](../../docs/generateContent.md) compatible endpoint + +All endpoints support: +- Streaming and non-streaming responses +- Function calling with thought signatures +- Multi-turn conversations +- All Gemini 3-specific features (thinking levels, thought signatures) +- Full multimodal support (text, image, audio, video) + +--- + +## `reasoning_effort` Mapping for Gemini 3.1 + +LiteLLM automatically maps OpenAI's `reasoning_effort` parameter to Gemini's `thinkingLevel`: + +| reasoning_effort | thinking_level | Use Case | +|------------------|----------------|----------| +| `minimal` | `minimal` | Ultra-fast responses, simple queries | +| `low` | `low` | Basic instruction following | +| `medium` | `medium` | Balanced reasoning for moderate complexity | +| `high` | `high` | Maximum reasoning depth, complex problems | +| `disable` | `minimal` | Disable extended reasoning | +| `none` | `minimal` | No extended reasoning | \ No newline at end of file From 409208771e6fa8bb596f580e6efd7bcb36b0a308 Mon Sep 17 00:00:00 2001 From: Chesars Date: Tue, 3 Mar 2026 09:24:49 -0300 Subject: [PATCH 15/69] fix(pricing): add 5 missing OpenRouter model pricing entries Fixes #22609 Adds pricing for OpenRouter models that were routing correctly but returning $0 for spend tracking due to missing cost map entries: - openrouter/anthropic/claude-sonnet-4.6 ($3.00/$15.00 per 1M tokens) - openrouter/google/gemini-3.1-pro-preview ($2.00/$12.00 per 1M tokens) - openrouter/openai/gpt-5.1-codex-max ($1.25/$10.00 per 1M tokens) - openrouter/qwen/qwen3-coder-plus ($1.00/$5.00 per 1M tokens) - openrouter/z-ai/glm-5 ($0.80/$2.56 per 1M tokens) --- model_prices_and_context_window.json | 106 +++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 4c0694db371..07b44fb7809 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -25373,6 +25373,30 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 159 }, + "openrouter/anthropic/claude-sonnet-4.6": { + "cache_creation_input_token_cost": 3.75e-06, + "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, + "cache_read_input_token_cost": 3e-07, + "cache_read_input_token_cost_above_200k_tokens": 6e-07, + "input_cost_per_token": 3e-06, + "input_cost_per_token_above_200k_tokens": 6e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "output_cost_per_token_above_200k_tokens": 2.25e-05, + "source": "https://openrouter.ai/anthropic/claude-sonnet-4.6", + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, "openrouter/anthropic/claude-opus-4.5": { "cache_creation_input_token_cost": 6.25e-06, "cache_read_input_token_cost": 5e-07, @@ -25723,6 +25747,39 @@ "supports_web_search": true, "tpm": 800000 }, + "openrouter/google/gemini-3.1-pro-preview": { + "cache_read_input_token_cost": 2e-07, + "cache_read_input_token_cost_above_200k_tokens": 4e-07, + "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07, + "input_cost_per_token": 2e-06, + "input_cost_per_token_above_200k_tokens": 4e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 1.2e-05, + "output_cost_per_token_above_200k_tokens": 1.8e-05, + "source": "https://openrouter.ai/google/gemini-3.1-pro-preview", + "supported_modalities": [ + "text", + "image", + "audio", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, "openrouter/gryphe/mythomax-l2-13b": { "input_cost_per_token": 1.875e-06, "litellm_provider": "openrouter", @@ -26100,6 +26157,29 @@ "supports_reasoning": true, "supports_tool_choice": true }, + "openrouter/openai/gpt-5.1-codex-max": { + "cache_read_input_token_cost": 1.25e-07, + "input_cost_per_token": 1.25e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-05, + "source": "https://openrouter.ai/openai/gpt-5.1-codex-max", + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, "openrouter/openai/gpt-5.2": { "input_cost_per_image": 0, "cache_read_input_token_cost": 1.75e-07, @@ -26254,6 +26334,19 @@ "supports_tool_choice": true, "supports_function_calling": true }, + "openrouter/qwen/qwen3-coder-plus": { + "input_cost_per_token": 1e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 997952, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 5e-06, + "source": "https://openrouter.ai/qwen/qwen3-coder-plus", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, "openrouter/qwen/qwen3-235b-a22b-2507": { "input_cost_per_token": 7.1e-08, "litellm_provider": "openrouter", @@ -26389,6 +26482,19 @@ "supports_vision": true, "supports_prompt_caching": false }, + "openrouter/z-ai/glm-5": { + "input_cost_per_token": 8e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 202752, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.56e-06, + "source": "https://openrouter.ai/z-ai/glm-5", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, "openrouter/minimax/minimax-m2.1": { "input_cost_per_token": 2.7e-07, "output_cost_per_token": 1.2e-06, From 79771261819774fbe40c4a4201148b872479b5a3 Mon Sep 17 00:00:00 2001 From: Chesars Date: Tue, 3 Mar 2026 09:52:17 -0300 Subject: [PATCH 16/69] fix(pricing): add 7 missing DashScope model pricing entries Fixes #22646 Adds pricing for DashScope models that were missing from the cost map, causing $0 spend tracking in the proxy dashboard: - dashscope/qwen3-max-2026-01-23 (tiered, same as qwen3-max) - dashscope/qwen3-next-80b-a3b-instruct ($0.15/$1.20 per 1M) - dashscope/qwen3-next-80b-a3b-thinking ($0.15/$1.20 per 1M) - dashscope/qwen3-vl-235b-a22b-instruct ($0.40/$1.60 per 1M) - dashscope/qwen3-vl-235b-a22b-thinking ($0.40/$4.00 per 1M) - dashscope/qwen3-vl-32b-instruct ($0.16/$0.64 per 1M) - dashscope/qwen3-vl-32b-thinking ($0.16/$2.87 per 1M) --- model_prices_and_context_window.json | 116 +++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 4c0694db371..7f59fcb0818 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -9779,6 +9779,122 @@ } ] }, + "dashscope/qwen3-max-2026-01-23": { + "litellm_provider": "dashscope", + "max_input_tokens": 258048, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "tiered_pricing": [ + { + "input_cost_per_token": 1.2e-06, + "output_cost_per_token": 6e-06, + "range": [ + 0, + 32000.0 + ] + }, + { + "input_cost_per_token": 2.4e-06, + "output_cost_per_token": 1.2e-05, + "range": [ + 32000.0, + 128000.0 + ] + }, + { + "input_cost_per_token": 3e-06, + "output_cost_per_token": 1.5e-05, + "range": [ + 128000.0, + 252000.0 + ] + } + ] + }, + "dashscope/qwen3-next-80b-a3b-instruct": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "dashscope", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing", + "supports_function_calling": true, + "supports_tool_choice": true + }, + "dashscope/qwen3-next-80b-a3b-thinking": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "dashscope", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "dashscope/qwen3-vl-235b-a22b-instruct": { + "input_cost_per_token": 4e-07, + "litellm_provider": "dashscope", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 1.6e-06, + "source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "dashscope/qwen3-vl-235b-a22b-thinking": { + "input_cost_per_token": 4e-07, + "litellm_provider": "dashscope", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 4e-06, + "source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "dashscope/qwen3-vl-32b-instruct": { + "input_cost_per_token": 1.6e-07, + "litellm_provider": "dashscope", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 6.4e-07, + "source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "dashscope/qwen3-vl-32b-thinking": { + "input_cost_per_token": 1.6e-07, + "litellm_provider": "dashscope", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 2.87e-06, + "source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, "dashscope/qwen3-vl-plus": { "litellm_provider": "dashscope", "max_input_tokens": 260096, From 058fac848e0f7e16ea0985e9ed7eae897523bb60 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 24 Feb 2026 16:20:51 +0530 Subject: [PATCH 17/69] Add Encrypted-content-aware deployment affinity for the Router --- .../encrypted_content_affinity_check.py | 267 ++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py diff --git a/litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py b/litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py new file mode 100644 index 00000000000..cdf69e4b24c --- /dev/null +++ b/litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py @@ -0,0 +1,267 @@ +""" +Encrypted-content-aware deployment affinity for the Router. + +When Codex or other models use `store: false` with `include: ["reasoning.encrypted_content"]`, +the response output items contain encrypted reasoning tokens tied to the originating +organization's API key. If a follow-up request containing those items is routed to a +different deployment (different org), OpenAI rejects it with an `invalid_encrypted_content` +error because the organization_id doesn't match. + +This callback solves the problem by: +1. Tracking output item IDs from Responses API responses and mapping them to the + deployment (model_id) that produced them. +2. On subsequent requests, scanning the `input` field for known item IDs and pinning + the request to the originating deployment. + +Safe to enable globally: +- Only activates when known item IDs appear in the request `input`. +- No effect on embedding models, chat completions, or first-time requests. +- No quota reduction -- first requests are fully load balanced. +""" + +from typing import Any, List, Optional, cast + +from litellm._logging import verbose_router_logger +from litellm.caching.dual_cache import DualCache +from litellm.integrations.custom_logger import CustomLogger, Span +from litellm.types.llms.openai import AllMessageValues, ResponsesAPIResponse + +_DEFAULT_TTL_SECONDS = 86400 # 24 hours + + +class EncryptedContentAffinityCheck(CustomLogger): + """ + Routes follow-up Responses API requests to the deployment that produced + the encrypted output items they reference. + + Wired via ``Router(optional_pre_call_checks=["encrypted_content_affinity"])``. + """ + + CACHE_KEY_PREFIX = "encrypted_content_affinity:v1" + + def __init__( + self, + cache: DualCache, + ttl_seconds: int = _DEFAULT_TTL_SECONDS, + ): + super().__init__() + self.cache = cache + self.ttl_seconds = ttl_seconds + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + @staticmethod + def _get_output_from_response( + response_obj: Any, + ) -> Optional[list]: + """ + Extract the ``output`` list from a Responses API response, handling + both ``ResponsesAPIResponse`` objects and plain dicts. + """ + if isinstance(response_obj, ResponsesAPIResponse): + return response_obj.output + if isinstance(response_obj, dict) and "output" in response_obj: + output = response_obj["output"] + if isinstance(output, list): + return output + if hasattr(response_obj, "output"): + output = response_obj.output + if isinstance(output, list): + return output + return None + + @staticmethod + def _extract_item_ids_from_output( + output: list, + ) -> List[str]: + """Extract all item IDs from a Responses API output list.""" + item_ids: List[str] = [] + for item in output: + item_id: Optional[str] = None + if isinstance(item, dict): + item_id = item.get("id") + else: + item_id = getattr(item, "id", None) + if item_id and isinstance(item_id, str): + item_ids.append(item_id) + return item_ids + + @staticmethod + def _extract_item_ids_from_input(request_input: Any) -> List[str]: + """ + Extract item IDs from the ``input`` field of a Responses API request. + + ``input`` can be: + - a plain string -> no item IDs + - a list of items -> each item may have an ``id`` field + """ + if not isinstance(request_input, list): + return [] + + item_ids: List[str] = [] + for item in request_input: + if isinstance(item, dict): + item_id = item.get("id") + if item_id and isinstance(item_id, str): + item_ids.append(item_id) + return item_ids + + @classmethod + def _cache_key(cls, item_id: str) -> str: + return f"{cls.CACHE_KEY_PREFIX}:{item_id}" + + @staticmethod + def _find_deployment_by_model_id( + healthy_deployments: List[dict], model_id: str + ) -> Optional[dict]: + for deployment in healthy_deployments: + model_info = deployment.get("model_info") + if not isinstance(model_info, dict): + continue + deployment_model_id = model_info.get("id") + if deployment_model_id is not None and str(deployment_model_id) == str( + model_id + ): + return deployment + return None + + @staticmethod + def _get_model_id_from_kwargs(kwargs: dict) -> Optional[str]: + """ + Extract the deployment model_id from success-callback kwargs. + + The Router populates ``litellm_params.metadata.model_info.id`` after + selecting a deployment. Also check top-level ``model_info`` as a + fallback (some call paths set it there). + """ + # Primary path: litellm_params -> metadata -> model_info -> id + litellm_params = kwargs.get("litellm_params") + if isinstance(litellm_params, dict): + metadata = litellm_params.get("metadata") + if isinstance(metadata, dict): + model_info = metadata.get("model_info") + if isinstance(model_info, dict): + model_id = model_info.get("id") + if model_id is not None: + return str(model_id) + + # Fallback: top-level model_info (set by some router call paths) + model_info = kwargs.get("model_info") + if isinstance(model_info, dict): + model_id = model_info.get("id") + if model_id is not None: + return str(model_id) + + return None + + # ------------------------------------------------------------------ + # Response tracking (success callback) + # ------------------------------------------------------------------ + + async def async_log_success_event( + self, kwargs: dict, response_obj: Any, start_time: Any, end_time: Any + ) -> None: + """ + After a successful Responses API call, cache each output item ID + mapped to the deployment that produced it. + """ + output = self._get_output_from_response(response_obj) + if output is None: + return + + model_id = self._get_model_id_from_kwargs(kwargs) + if not model_id: + verbose_router_logger.debug( + "EncryptedContentAffinityCheck: model_id not found in kwargs, skipping tracking", + ) + return + + item_ids = self._extract_item_ids_from_output(output) + if not item_ids: + return + + for item_id in item_ids: + try: + cache_key = self._cache_key(item_id) + await self.cache.async_set_cache( + cache_key, + model_id, + ttl=self.ttl_seconds, + ) + except Exception as e: + verbose_router_logger.debug( + "EncryptedContentAffinityCheck: failed to cache item_id=%s error=%s", + item_id, + e, + ) + + verbose_router_logger.info( + "EncryptedContentAffinityCheck: cached %d item IDs -> deployment=%s", + len(item_ids), + model_id, + ) + + # ------------------------------------------------------------------ + # Request routing (pre-call filter) + # ------------------------------------------------------------------ + + async def async_filter_deployments( + self, + model: str, + healthy_deployments: List, + messages: Optional[List[AllMessageValues]], + request_kwargs: Optional[dict] = None, + parent_otel_span: Optional[Span] = None, + ) -> List[dict]: + """ + If the request ``input`` contains items whose IDs were previously + tracked, pin the request to the deployment that produced them. + """ + request_kwargs = request_kwargs or {} + typed_healthy_deployments = cast(List[dict], healthy_deployments) + + request_input = request_kwargs.get("input") + input_item_ids = self._extract_item_ids_from_input(request_input) + if not input_item_ids: + return typed_healthy_deployments + + verbose_router_logger.debug( + "EncryptedContentAffinityCheck: found %d item IDs in input, checking cache", + len(input_item_ids), + ) + + for item_id in input_item_ids: + cache_key = self._cache_key(item_id) + try: + cached_model_id = await self.cache.async_get_cache(key=cache_key) + except Exception: + continue + + if not cached_model_id or not isinstance(cached_model_id, str): + continue + + deployment = self._find_deployment_by_model_id( + healthy_deployments=typed_healthy_deployments, + model_id=cached_model_id, + ) + if deployment is not None: + verbose_router_logger.info( + "EncryptedContentAffinityCheck: item_id=%s pinning -> deployment=%s", + item_id, + cached_model_id, + ) + request_kwargs[ + "_encrypted_content_affinity_pinned" + ] = True + return [deployment] + + verbose_router_logger.info( + "EncryptedContentAffinityCheck: cached deployment=%s for item_id=%s " + "not found in healthy_deployments", + cached_model_id, + item_id, + ) + + return typed_healthy_deployments From 92b255628216fe6550928eaa53fbf18b7780c519 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 24 Feb 2026 16:21:20 +0530 Subject: [PATCH 18/69] Add encrypted_content_affinity in router --- litellm/router.py | 30 ++++++++++++++++++++++++++++++ litellm/types/router.py | 1 + 2 files changed, 31 insertions(+) diff --git a/litellm/router.py b/litellm/router.py index 5c8d27e76d7..7dee4fa83de 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -115,6 +115,9 @@ from litellm.router_utils.handle_error import ( from litellm.router_utils.pre_call_checks.deployment_affinity_check import ( DeploymentAffinityCheck, ) +from litellm.router_utils.pre_call_checks.encrypted_content_affinity_check import ( + EncryptedContentAffinityCheck, +) from litellm.router_utils.pre_call_checks.model_rate_limit_check import ( ModelRateLimitingCheck, ) @@ -1248,6 +1251,25 @@ class Router: self.optional_callbacks.append(affinity_callback) litellm.logging_callback_manager.add_litellm_callback(affinity_callback) + # --------------------------------------------------------------------- + # Encrypted content affinity + # --------------------------------------------------------------------- + if "encrypted_content_affinity" in optional_pre_call_checks: + if self.optional_callbacks is None: + self.optional_callbacks = [] + + already_registered = any( + isinstance(cb, EncryptedContentAffinityCheck) + for cb in self.optional_callbacks + ) + if not already_registered: + ec_callback = EncryptedContentAffinityCheck( + cache=self.cache, + ttl_seconds=self.deployment_affinity_ttl_seconds, + ) + self.optional_callbacks.append(ec_callback) + litellm.logging_callback_manager.add_litellm_callback(ec_callback) + # --------------------------------------------------------------------- # Remaining optional pre-call checks # --------------------------------------------------------------------- @@ -1257,6 +1279,7 @@ class Router: "deployment_affinity", "responses_api_deployment_check", "session_affinity", + "encrypted_content_affinity", ): continue if pre_call_check == "prompt_caching": @@ -8808,6 +8831,13 @@ class Router: if isinstance(healthy_deployments, dict): return healthy_deployments + # When encrypted content affinity pins to a specific deployment, + if ( + request_kwargs.get("_encrypted_content_affinity_pinned") + and len(healthy_deployments) == 1 + ): + return healthy_deployments[0] + start_time = time.time() if ( self.routing_strategy == "usage-based-routing-v2" diff --git a/litellm/types/router.py b/litellm/types/router.py index aa4d7bd9a97..fca731d1f91 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -813,6 +813,7 @@ OptionalPreCallChecks = List[ "session_affinity", "forward_client_headers_by_model_group", "enforce_model_rate_limits", + "encrypted_content_affinity", ] ] From 394c49d3037a9cc05cc942cc73c13a9f7acb4eb6 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 24 Feb 2026 16:21:49 +0530 Subject: [PATCH 19/69] Add tests for encrypted_content_affinity --- .../test_encrypted_content_affinity_check.py | 335 ++++++++++++++++++ 1 file changed, 335 insertions(+) create mode 100644 tests/test_litellm/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py diff --git a/tests/test_litellm/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py b/tests/test_litellm/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py new file mode 100644 index 00000000000..a266df749c1 --- /dev/null +++ b/tests/test_litellm/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py @@ -0,0 +1,335 @@ +import asyncio +import os +import sys +from unittest.mock import AsyncMock, patch + +import pytest + +sys.path.insert(0, os.path.abspath("../..")) + +import json + +import litellm +from litellm.caching.dual_cache import DualCache +from litellm.router_utils.pre_call_checks.encrypted_content_affinity_check import ( + EncryptedContentAffinityCheck, +) + + +class MockResponse: + def __init__(self, json_data, status_code): + self._json_data = json_data + self.status_code = status_code + self.text = json.dumps(json_data) + self.headers = {} + + def json(self): + return self._json_data + + +@pytest.mark.asyncio +async def test_encrypted_content_affinity_tracks_and_routes(): + """ + When encrypted_content_affinity is enabled, output item IDs from responses + are tracked, and follow-up requests containing those IDs route to the same + deployment. + """ + mock_response_data = { + "id": "resp_mock-123", + "object": "response", + "created_at": 1741476542, + "status": "completed", + "model": "openai/gpt-5.1-codex", + "output": [ + { + "type": "message", + "id": "msg_abc123", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": "Hello!", "annotations": []}], + }, + { + "type": "reasoning", + "id": "rs_encrypted_item_456", + "status": "completed", + }, + ], + "parallel_tool_calls": True, + "usage": { + "input_tokens": 5, + "output_tokens": 10, + "total_tokens": 15, + }, + "error": None, + } + + router = litellm.Router( + model_list=[ + { + "model_name": "openai.gpt-5.1-codex", + "litellm_params": { + "model": "openai/gpt-5.1-codex", + "api_key": "mock-api-key-1", + }, + "model_info": {"id": "deployment-1"}, + }, + { + "model_name": "openai.gpt-5.1-codex", + "litellm_params": { + "model": "openai/gpt-5.1-codex", + "api_key": "mock-api-key-2", + }, + "model_info": {"id": "deployment-2"}, + }, + ], + optional_pre_call_checks=["encrypted_content_affinity"], + ) + + model_group = "openai.gpt-5.1-codex" + + # Track which deployment was selected + selected_deployments = [] + + def deterministic_choice(seq): + # First call: select deployment-1 + # Second call: would select deployment-2, but affinity should override + if len(selected_deployments) == 0: + return seq[0] + return seq[1] if len(seq) > 1 else seq[0] + + with patch( + "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post", + new_callable=AsyncMock, + ) as mock_post, patch( + "litellm.router_strategy.simple_shuffle.random.choice", + side_effect=deterministic_choice, + ): + mock_post.return_value = MockResponse(mock_response_data, 200) + + # First request: no encrypted items in input + first_response = await router.aresponses( + model=model_group, + input="Hello, how are you?", + ) + first_model_id = first_response._hidden_params["model_id"] + selected_deployments.append(first_model_id) + + # Give async callbacks time to run + await asyncio.sleep(0.2) + + # Second request: includes encrypted item IDs from first response + second_response = await router.aresponses( + model=model_group, + input=[ + {"type": "message", "id": "msg_abc123", "role": "assistant"}, + {"type": "reasoning", "id": "rs_encrypted_item_456"}, + ], + ) + second_model_id = second_response._hidden_params["model_id"] + + # Affinity should route to the same deployment + assert second_model_id == first_model_id, ( + f"Expected affinity to route to {first_model_id}, " + f"but got {second_model_id}" + ) + + +@pytest.mark.asyncio +async def test_encrypted_content_affinity_no_effect_on_chat_completions(): + """ + Encrypted content affinity should not affect regular chat completions + (they don't use the Responses API). + """ + router = litellm.Router( + model_list=[ + { + "model_name": "gpt-3.5-turbo", + "litellm_params": { + "model": "gpt-3.5-turbo", + "api_key": os.environ.get("OPENAI_API_KEY", "test-key"), + }, + "model_info": {"id": "chat-deployment-1"}, + }, + ], + optional_pre_call_checks=["encrypted_content_affinity"], + ) + + mock_chat_response = { + "id": "chatcmpl-123", + "object": "chat.completion", + "created": 1677652288, + "model": "gpt-3.5-turbo", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Hello!"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 5, "completion_tokens": 7, "total_tokens": 12}, + } + + with patch( + "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post", + new_callable=AsyncMock, + ) as mock_post: + mock_post.return_value = MockResponse(mock_chat_response, 200) + + # Multiple chat completion requests should work normally + response1 = await router.acompletion( + model="gpt-3.5-turbo", + messages=[{"role": "user", "content": "Hello"}], + ) + response2 = await router.acompletion( + model="gpt-3.5-turbo", + messages=[{"role": "user", "content": "Hello again"}], + ) + + # Both should succeed (no affinity interference) + # Check that responses have IDs (litellm may modify them) + assert response1.id is not None + assert response2.id is not None + + +@pytest.mark.asyncio +async def test_encrypted_content_affinity_bypasses_rpm_limits(): + """ + When encrypted content affinity pins to a deployment, it should bypass + RPM limits since the encrypted content will fail on any other deployment. + """ + mock_response_data = { + "id": "resp_mock-rpm-test", + "object": "response", + "created_at": 1741476542, + "status": "completed", + "model": "openai/gpt-5.1-codex", + "output": [ + { + "type": "reasoning", + "id": "rs_encrypted_must_pin", + "status": "completed", + }, + ], + "usage": {"input_tokens": 5, "output_tokens": 10, "total_tokens": 15}, + "error": None, + } + + router = litellm.Router( + model_list=[ + { + "model_name": "openai.gpt-5.1-codex", + "litellm_params": { + "model": "openai/gpt-5.1-codex", + "api_key": "mock-api-key-1", + "rpm": 1, # Very low limit + }, + "model_info": {"id": "rpm-limited-deployment"}, + }, + { + "model_name": "openai.gpt-5.1-codex", + "litellm_params": { + "model": "openai/gpt-5.1-codex", + "api_key": "mock-api-key-2", + "rpm": 100, + }, + "model_info": {"id": "high-rpm-deployment"}, + }, + ], + optional_pre_call_checks=["encrypted_content_affinity"], + routing_strategy="usage-based-routing-v2", + ) + + with patch( + "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post", + new_callable=AsyncMock, + ) as mock_post: + mock_post.return_value = MockResponse(mock_response_data, 200) + + # First request goes to the low-RPM deployment + first_response = await router.aresponses( + model="openai.gpt-5.1-codex", + input="Initial request", + ) + first_model_id = first_response._hidden_params["model_id"] + + await asyncio.sleep(0.2) + + # Second request with encrypted content should pin to the same deployment + # even though it's at RPM limit + second_response = await router.aresponses( + model="openai.gpt-5.1-codex", + input=[ + {"type": "reasoning", "id": "rs_encrypted_must_pin"}, + ], + ) + second_model_id = second_response._hidden_params["model_id"] + + # Should route to the same deployment despite RPM limit + assert second_model_id == first_model_id + + +@pytest.mark.asyncio +async def test_encrypted_content_affinity_no_match_normal_routing(): + """ + When input contains item IDs that aren't tracked, normal load balancing + should occur. + """ + mock_response_data = { + "id": "resp_mock-no-match", + "object": "response", + "created_at": 1741476542, + "status": "completed", + "model": "openai/gpt-5.1-codex", + "output": [ + { + "type": "message", + "id": "msg_new", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": "Response"}], + }, + ], + "usage": {"input_tokens": 5, "output_tokens": 10, "total_tokens": 15}, + "error": None, + } + + router = litellm.Router( + model_list=[ + { + "model_name": "openai.gpt-5.1-codex", + "litellm_params": { + "model": "openai/gpt-5.1-codex", + "api_key": "mock-api-key-1", + }, + "model_info": {"id": "deployment-a"}, + }, + { + "model_name": "openai.gpt-5.1-codex", + "litellm_params": { + "model": "openai/gpt-5.1-codex", + "api_key": "mock-api-key-2", + }, + "model_info": {"id": "deployment-b"}, + }, + ], + optional_pre_call_checks=["encrypted_content_affinity"], + ) + + with patch( + "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post", + new_callable=AsyncMock, + ) as mock_post: + mock_post.return_value = MockResponse(mock_response_data, 200) + + # Request with unknown item IDs should use normal routing + response = await router.aresponses( + model="openai.gpt-5.1-codex", + input=[ + {"type": "message", "id": "unknown_item_id_12345"}, + ], + ) + + # Should succeed with normal routing (litellm may modify the ID) + assert response.id is not None + # Verify it contains the original response ID in some form + assert "resp_mock-no-match" in str(response.id) or response.id.startswith("resp_") From 9f627c67d83bf8216d2637ba9eb877e0588a42ca Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 24 Feb 2026 16:22:00 +0530 Subject: [PATCH 20/69] Add tests for encrypted_content_affinity --- .../pre_call_checks/test_encrypted_content_affinity_check.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/test_litellm/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py b/tests/test_litellm/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py index a266df749c1..c70290b674d 100644 --- a/tests/test_litellm/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py +++ b/tests/test_litellm/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py @@ -10,10 +10,6 @@ sys.path.insert(0, os.path.abspath("../..")) import json import litellm -from litellm.caching.dual_cache import DualCache -from litellm.router_utils.pre_call_checks.encrypted_content_affinity_check import ( - EncryptedContentAffinityCheck, -) class MockResponse: From fbec5c5ccf6c3633cae358f50da695203a76a624 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 24 Feb 2026 16:22:15 +0530 Subject: [PATCH 21/69] Add docs for encrypted_content_affinity --- docs/my-website/docs/proxy/config_settings.md | 2 +- docs/my-website/docs/proxy/load_balancing.md | 33 ++++ docs/my-website/docs/response_api.md | 155 ++++++++++++++++++ 3 files changed, 189 insertions(+), 1 deletion(-) diff --git a/docs/my-website/docs/proxy/config_settings.md b/docs/my-website/docs/proxy/config_settings.md index 7b2011e45dd..af868bc9f9d 100644 --- a/docs/my-website/docs/proxy/config_settings.md +++ b/docs/my-website/docs/proxy/config_settings.md @@ -360,7 +360,7 @@ router_settings: | redis_url | str | URL for Redis server. **Known performance issue with Redis URL.** | | cache_responses | boolean | Flag to enable caching LLM Responses, if cache set under `router_settings`. If true, caches responses. Defaults to False. | | router_general_settings | RouterGeneralSettings | [SDK-Only] Router general settings - contains optimizations like 'async_only_mode'. [Docs](../routing.md#router-general-settings) | -| optional_pre_call_checks | List[str] | List of pre-call checks to add to the router. Supported: `router_budget_limiting`, `prompt_caching`, `responses_api_deployment_check`, `deployment_affinity`, `forward_client_headers_by_model_group` | +| optional_pre_call_checks | List[str] | List of pre-call checks to add to the router. Supported: `router_budget_limiting`, `prompt_caching`, `responses_api_deployment_check`, `encrypted_content_affinity`, `deployment_affinity`, `session_affinity`, `forward_client_headers_by_model_group` | | deployment_affinity_ttl_seconds | int | TTL (seconds) for user-key → deployment affinity mapping when `deployment_affinity` is enabled (configured at Router init / proxy startup). Defaults to `3600` (1 hour). | | ignore_invalid_deployments | boolean | If true, ignores invalid deployments. Default for proxy is True - to prevent invalid models from blocking other models from being loaded. | | search_tools | List[SearchToolTypedDict] | List of search tool configurations for Search API integration. Each tool specifies a search_tool_name and litellm_params with search_provider, api_key, api_base, etc. [Further Docs](../search.md) | diff --git a/docs/my-website/docs/proxy/load_balancing.md b/docs/my-website/docs/proxy/load_balancing.md index 186307d6498..5bf39d179f6 100644 --- a/docs/my-website/docs/proxy/load_balancing.md +++ b/docs/my-website/docs/proxy/load_balancing.md @@ -347,3 +347,36 @@ If `order=1` deployment is unavailable (e.g., rate-limited), the router falls ba - **Higher throughput**: More requests handled simultaneously across deployments - **Improved reliability**: If one deployment fails, traffic automatically routes to healthy ones - **Better resource utilization**: Load spread evenly across all available deployments + +## Special Considerations for Responses API + +When load balancing OpenAI's Responses API across deployments with **different API keys** (e.g., different Azure regions or organizations), encrypted content items (like `rs_...` reasoning items) can only be decrypted by the originating API key. + +**Solution:** Use the `encrypted_content_affinity` pre-call check to automatically route follow-up requests containing encrypted items to the correct deployment: + +```yaml +model_list: + - model_name: gpt-5.1-codex + litellm_params: + model: azure/gpt-5.1-codex + api_base: https://eastus.openai.azure.com/ + api_key: os.environ/AZURE_API_KEY_EASTUS + model_info: + id: "deployment-eastus" + + - model_name: gpt-5.1-codex + litellm_params: + model: azure/gpt-5.1-codex + api_base: https://westeurope.openai.azure.com/ + api_key: os.environ/AZURE_API_KEY_WESTEUROPE + model_info: + id: "deployment-westeurope" + +router_settings: + optional_pre_call_checks: + - encrypted_content_affinity # 👈 Prevents invalid_encrypted_content errors +``` + +This ensures requests containing encrypted content are routed to the deployment that created them, while other requests continue to load balance normally. + +**[Learn more about Encrypted Content Affinity →](../response_api.md#encrypted-content-affinity-multi-region-load-balancing)** diff --git a/docs/my-website/docs/response_api.md b/docs/my-website/docs/response_api.md index b37be2b5bc2..85144a80245 100644 --- a/docs/my-website/docs/response_api.md +++ b/docs/my-website/docs/response_api.md @@ -920,9 +920,14 @@ follow_up = await router.aresponses( To enable session continuity for Responses API in your LiteLLM proxy, set `optional_pre_call_checks` in your proxy config.yaml. - `responses_api_deployment_check`: high priority routing when `previous_response_id` is provided +- `encrypted_content_affinity`: **[Recommended]** content-aware routing for encrypted items (e.g., `rs_...` reasoning items) - `session_affinity`: sticky sessions based on session id (takes priority over `deployment_affinity`) - `deployment_affinity`: sticky sessions based on user key (applies even without `previous_response_id`) +:::tip Recommended: Use `encrypted_content_affinity` +For Responses API with load balancing across deployments with **different API keys**, use `encrypted_content_affinity` instead of `deployment_affinity`. It only pins requests that contain encrypted content, avoiding quota reduction while preventing `invalid_encrypted_content` errors. +::: + Notes: - User-key affinity is keyed on `metadata.user_api_key_hash` (the API key hash). The OpenAI `user` request parameter is an end-user identifier and is intentionally not used for deployment affinity. - Session-ID affinity is keyed on `metadata.session_id`. For proxy requests, this can be passed via the `x-litellm-session-id` HTTP header. For Python SDK requests, you can pass it via `litellm_metadata={"session_id": "value"}` in request args. @@ -983,6 +988,156 @@ follow_up = client.responses.create( +## Encrypted Content Affinity (Multi-Region Load Balancing) + +When load balancing Responses API across deployments with **different API keys** (e.g., different Azure regions or OpenAI organizations), encrypted content items (like `rs_...` reasoning items) can only be decrypted by the API key that created them. + +### The Problem + +```json +{ + "error": { + "message": "The encrypted content for item rs_0d09d6e56879e76500699d6feee41c8197bd268aae76141f87 could not be verified. Reason: Encrypted content organization_id did not match the target organization.", + "type": "invalid_request_error", + "code": "invalid_encrypted_content" + } +} +``` + +This error occurs when: +1. Initial request goes to Deployment A (API Key 1) → produces encrypted item `rs_xyz` +2. Follow-up request with `rs_xyz` in input gets load balanced to Deployment B (API Key 2) +3. Deployment B cannot decrypt content created by Deployment A → **request fails** + +### The Solution: `encrypted_content_affinity` + +The `encrypted_content_affinity` pre-call check intelligently tracks encrypted content and routes follow-up requests to the originating deployment **only when necessary**. + +**Key Benefits:** +- ✅ **No quota reduction**: Unlike `deployment_affinity`, only pins requests that contain tracked encrypted items +- ✅ **Bypasses rate limits**: When encrypted content requires a specific deployment, RPM/TPM limits are bypassed (the request would fail on any other deployment anyway) +- ✅ **No `previous_response_id` required**: Works by tracking item IDs in response output and matching them in request input +- ✅ **Globally safe**: Can be enabled for all models; non-Responses-API calls (chat, embeddings) are unaffected + +### How It Works + +1. **Tracking Phase** (after successful response): + - Extracts all item IDs from response `output` (e.g., `msg_abc`, `rs_xyz`) + - Caches mapping: `item_id` → `deployment_id` (default TTL: 24 hours) + +2. **Routing Phase** (before request): + - Scans request `input` for item IDs + - If tracked item found → pins to originating deployment, bypasses rate limits + - If no tracked items → normal load balancing + +### Configuration + + + + +```python +from litellm import Router + +router = Router( + model_list=[ + { + "model_name": "gpt-5.1-codex", + "litellm_params": { + "model": "openai/gpt-5.1-codex", + "api_key": "org-1-api-key", # Different API key + }, + "model_info": {"id": "deployment-us-east"}, + }, + { + "model_name": "gpt-5.1-codex", + "litellm_params": { + "model": "openai/gpt-5.1-codex", + "api_key": "org-2-api-key", # Different API key + }, + "model_info": {"id": "deployment-eu-west"}, + }, + ], + optional_pre_call_checks=["encrypted_content_affinity"], + deployment_affinity_ttl_seconds=86400, # 24 hours (default) +) + +# Initial request - routes to any deployment +response1 = await router.aresponses( + model="gpt-5.1-codex", + input="Explain quantum computing", +) + +# Follow-up with encrypted items - automatically routes to same deployment +response2 = await router.aresponses( + model="gpt-5.1-codex", + input=response1.output, # Contains encrypted items from response1 +) +``` + + + + +```yaml showLineNumbers title="config.yaml" +model_list: + - model_name: gpt-5.1-codex + litellm_params: + model: azure/gpt-5.1-codex + api_base: https://eastus.openai.azure.com/ + api_key: os.environ/AZURE_API_KEY_EASTUS + rpm: 600 + tpm: 100000 + model_info: + id: "gpt-5.1-codex-eastus" + + - model_name: gpt-5.1-codex + litellm_params: + model: azure/gpt-5.1-codex + api_base: https://westeurope.openai.azure.com/ + api_key: os.environ/AZURE_API_KEY_WESTEUROPE + rpm: 600 + tpm: 100000 + model_info: + id: "gpt-5.1-codex-westeurope" + +router_settings: + routing_strategy: usage-based-routing-v2 + enable_pre_call_checks: true + optional_pre_call_checks: + - encrypted_content_affinity + deployment_affinity_ttl_seconds: 86400 # Optional, default is 86400 (24 hours) +``` + +**Start proxy:** +```bash +litellm --config config.yaml +``` + + + + +### When to Use Each Affinity Type + +| Affinity Type | Use Case | Scope | Quota Impact | +|---------------|----------|-------|--------------| +| **`encrypted_content_affinity`** | **[Recommended]** Multi-region Responses API with different API keys | Only requests with tracked encrypted items | ✅ None (surgical pinning) | +| `responses_api_deployment_check` | When `previous_response_id` is available | Requests with `previous_response_id` | ✅ None | +| `session_affinity` | Session-based applications | All requests with same `session_id` | ⚠️ Reduces quota by # of sessions | +| `deployment_affinity` | Simple sticky sessions | All requests from same API key | ❌ Reduces quota by # of users | + +### Multi-Instance Deployment (Redis) + +For multiple LiteLLM proxy instances, use Redis to share affinity state: + +```yaml +router_settings: + optional_pre_call_checks: + - encrypted_content_affinity + redis_host: redis.example.com + redis_port: 6379 + redis_password: your-password +``` + + ## Calling non-Responses API endpoints (`/responses` to `/chat/completions` Bridge) LiteLLM allows you to call non-Responses API models via a bridge to LiteLLM's `/chat/completions` endpoint. This is useful for calling Anthropic, Gemini and even non-Responses API OpenAI models. From 37612bdf56349635b1c7d97066d9ee66988fb4d6 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 24 Feb 2026 16:24:57 +0530 Subject: [PATCH 22/69] ADd incident report --- .../index.md | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 docs/my-website/blog/responses_api_encrypted_content_incident/index.md diff --git a/docs/my-website/blog/responses_api_encrypted_content_incident/index.md b/docs/my-website/blog/responses_api_encrypted_content_incident/index.md new file mode 100644 index 00000000000..8422f6d3cd0 --- /dev/null +++ b/docs/my-website/blog/responses_api_encrypted_content_incident/index.md @@ -0,0 +1,231 @@ +--- +slug: responses-api-encrypted-content-incident +title: "Incident Report: Encrypted Content Failures in Multi-Region Responses API Load Balancing" +date: 2026-02-24T10:00:00 +authors: + - name: Sameer Kankute + title: SWE @ LiteLLM (LLM Translation) + url: https://www.linkedin.com/in/sameer-kankute/ + image_url: https://pbs.twimg.com/profile_images/2001352686994907136/ONgNuSk5_400x400.jpg + - name: Krrish Dholakia + title: "CEO, LiteLLM" + url: https://www.linkedin.com/in/krish-d/ + image_url: https://pbs.twimg.com/profile_images/1298587542745358340/DZv3Oj-h_400x400.jpg + - name: Ishaan Jaff + title: "CTO, LiteLLM" + url: https://www.linkedin.com/in/reffajnaahsi/ + image_url: https://pbs.twimg.com/profile_images/1613813310264340481/lz54oEiB_400x400.jpg +tags: [incident-report, proxy, responses-api, load-balancing] +hide_table_of_contents: false +--- + +**Date:** Feb 24, 2026 +**Duration:** Ongoing (until fix deployed) +**Severity:** High (for users load balancing Responses API across different API keys) +**Status:** Resolved + +## Summary + +When load balancing OpenAI's Responses API across deployments with **different API keys** (e.g., different Azure regions or OpenAI organizations), follow-up requests containing encrypted content items (like `rs_...` reasoning items) would fail with: + +```json +{ + "error": { + "message": "The encrypted content for item rs_0d09d6e56879e76500699d6feee41c8197bd268aae76141f87 could not be verified. Reason: Encrypted content organization_id did not match the target organization.", + "type": "invalid_request_error", + "code": "invalid_encrypted_content" + } +} +``` + +Encrypted content items are cryptographically tied to the API key's organization that created them. When the router load balanced a follow-up request to a deployment with a different API key, decryption failed. + +- **Responses API calls with encrypted content:** Complete failure when routed to wrong deployment +- **Initial requests:** Unaffected — only follow-up requests containing encrypted items failed +- **Other API endpoints:** No impact — chat completions, embeddings, etc. functioned normally + +{/* truncate */} + +--- + +## Background + +OpenAI's Responses API can return encrypted "reasoning items" (with IDs like `rs_...`) that contain intermediate reasoning steps. These items are encrypted with the organization's key and can only be decrypted by the same organization's API key. + +When load balancing across deployments with different API keys, the existing affinity mechanisms were insufficient: + +- **`responses_api_deployment_check`**: Requires `previous_response_id` which some clients (like Codex) don't provide +- **`deployment_affinity`**: Too broad — pins *all* requests from a user to one deployment, reducing effective quota by the number of users +- **`session_affinity`**: Requires explicit session IDs and still reduces quota + +```mermaid +flowchart TD + A["1. Initial request to Responses API + router.aresponses()"] --> B["2. Router load balances to Deployment A + (API Key 1, Azure East US)"] + B --> C["3. Response contains encrypted item + rs_abc123 (encrypted with Org 1 key)"] + C --> D["4. Follow-up request includes rs_abc123 in input"] + D --> E["5. Router load balances to Deployment B + (API Key 2, Azure West Europe)"] + E -->|"Different API key"| F["6. ❌ Deployment B cannot decrypt rs_abc123 + Error: invalid_encrypted_content"] + + D -.->|"With encrypted_content_affinity"| G["5b. Router detects rs_abc123 was created by Deployment A"] + G --> H["6b. ✅ Routes to Deployment A (bypasses rate limits) + Request succeeds"] + + style F fill:#f8d7da,stroke:#dc3545 + style H fill:#d4edda,stroke:#28a745 + style E fill:#fff3cd,stroke:#ffc107 + style G fill:#d4edda,stroke:#28a745 +``` + +--- + +## Root Cause + +LiteLLM's router had no mechanism to track which deployment created specific encrypted content items and route follow-up requests accordingly. The router treated all deployments as interchangeable, leading to decryption failures when encrypted content crossed organizational boundaries. + +**The Problem Flow:** + +1. User calls `router.aresponses()` with model `gpt-5.1-codex` +2. Router load balances to Deployment A (Azure East US, API Key 1) +3. Response contains encrypted reasoning item `rs_abc123` (encrypted with Org 1's key) +4. User makes follow-up request with `rs_abc123` in the input +5. Router load balances to Deployment B (Azure West Europe, API Key 2) +6. Deployment B tries to decrypt `rs_abc123` with Org 2's key → **fails** + +**Why Existing Solutions Didn't Work:** + +- **`previous_response_id`**: Not provided by all clients (e.g., Codex) +- **`deployment_affinity`**: Pins *all* user requests to one deployment → reduces quota to 1/N where N = number of deployments +- **`session_affinity`**: Requires explicit session management and still reduces quota + +**Timeline:** + +1. Users configured multi-region Responses API load balancing with different API keys +2. Initial requests succeeded, but follow-up requests with encrypted content failed intermittently +3. Error rate correlated with number of deployments (more deployments = higher chance of routing to wrong one) +4. Investigation revealed encrypted content was organization-bound +5. Existing affinity mechanisms deemed unsuitable (quota reduction, missing `previous_response_id`) +6. New solution designed and implemented: `encrypted_content_affinity` + +--- + +## The Fix + +Implemented a new `encrypted_content_affinity` pre-call check that intelligently tracks encrypted content and routes follow-up requests **only when necessary**. + +### Implementation + +**1. New `EncryptedContentAffinityCheck` Class** ([`encrypted_content_affinity_check.py`](https://github.com/BerriAI/litellm/blob/main/litellm/litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py)) + +```python +class EncryptedContentAffinityCheck(CustomLogger): + """ + Routes follow-up Responses API requests to the deployment that produced + the encrypted output items they reference. + """ + + async def async_log_success_event(self, kwargs, response_obj, ...): + """Track: Extract item IDs from response output, cache item_id → deployment_id""" + output = self._get_output_from_response(response_obj) + item_ids = self._extract_item_ids_from_output(output) + model_id = self._get_model_id_from_kwargs(kwargs) + + for item_id in item_ids: + await self.cache.async_set_cache( + f"encrypted_content_affinity:v1:{item_id}", + model_id, + ttl=86400, # 24 hours + ) + + async def async_filter_deployments(self, model, healthy_deployments, ...): + """Route: Check if input contains tracked items, pin to originating deployment""" + input_item_ids = self._extract_item_ids_from_input(request_kwargs.get("input")) + + for item_id in input_item_ids: + cached_model_id = await self.cache.async_get_cache(f"...:{item_id}") + if cached_model_id: + deployment = self._find_deployment_by_model_id( + healthy_deployments, cached_model_id + ) + if deployment: + # Signal to bypass rate limits (encrypted content must go here) + request_kwargs["_encrypted_content_affinity_pinned"] = True + return [deployment] + + return healthy_deployments # Normal load balancing +``` + +**2. Rate Limit Bypass** ([`router.py#L8656-L8660`](https://github.com/BerriAI/litellm/blob/main/litellm/litellm/router.py#L8656-L8660)) + +When encrypted content requires a specific deployment, RPM/TPM limits are bypassed (the request would fail on any other deployment anyway): + +```python +# In async_get_available_deployment, after filtering healthy deployments: +if ( + request_kwargs.get("_encrypted_content_affinity_pinned") + and len(healthy_deployments) == 1 +): + return healthy_deployments[0] # Bypass routing strategy (RPM/TPM checks) +``` + +**3. Configuration** + +```yaml +router_settings: + routing_strategy: usage-based-routing-v2 + enable_pre_call_checks: true + optional_pre_call_checks: + - encrypted_content_affinity + deployment_affinity_ttl_seconds: 86400 # 24 hours +``` + +### Key Benefits + +✅ **No quota reduction**: Only pins requests containing tracked encrypted items +✅ **Bypasses rate limits**: When encrypted content requires a specific deployment, RPM/TPM limits don't block it +✅ **No `previous_response_id` required**: Works by tracking item IDs in response output +✅ **Globally safe**: Can be enabled for all models; non-Responses-API calls are unaffected +✅ **Surgical precision**: Normal requests continue to load balance freely + +--- + +## Remediation + +| # | Action | Status | Code | +|---|---|---|---| +| 1 | Create `EncryptedContentAffinityCheck` class with tracking and routing logic | ✅ Done | [`encrypted_content_affinity_check.py`](https://github.com/BerriAI/litellm/blob/main/litellm/litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py) | +| 2 | Add `encrypted_content_affinity` to `OptionalPreCallChecks` type | ✅ Done | [`router.py`](https://github.com/BerriAI/litellm/blob/main/litellm/litellm/types/router.py) | +| 3 | Wire up check in `Router.add_optional_pre_call_checks` | ✅ Done | [`router.py#L8656-L8660`](https://github.com/BerriAI/litellm/blob/main/litellm/litellm/router.py#L8656-L8660) | +| 4 | Implement rate limit bypass for affinity-pinned requests | ✅ Done | [`router.py#L8656-L8660`](https://github.com/BerriAI/litellm/blob/main/litellm/litellm/router.py#L8656-L8660) | +| 5 | Unit tests: tracking, routing, no-op for non-Responses-API, RPM bypass | ✅ Done | [`test_encrypted_content_affinity_check.py`](https://github.com/BerriAI/litellm/blob/main/litellm/tests/test_litellm/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py) | +| 6 | Documentation: Responses API guide, load balancing guide, config reference | ✅ Done | [Docs](https://docs.litellm.ai/docs/response_api#encrypted-content-affinity-multi-region-load-balancing) | + +--- + +## Migration Guide + +### Before (Using `deployment_affinity`) + +```yaml +router_settings: + optional_pre_call_checks: + - deployment_affinity # ❌ Reduces quota by number of users +``` + +**Problem:** All requests from a user pin to one deployment, reducing effective quota to 1/N. + +### After (Using `encrypted_content_affinity`) + +```yaml +router_settings: + optional_pre_call_checks: + - encrypted_content_affinity # ✅ Only pins requests with encrypted content +``` + +**Benefit:** Normal requests load balance freely, only encrypted content requests pin when necessary. + +--- From a88a17796b385ad4321a39e75523613095b5f078 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 24 Feb 2026 22:56:57 +0530 Subject: [PATCH 23/69] Fix logging and encrypted content extraction --- .../encrypted_content_affinity_check.py | 24 ++++++++++++------- .../test_encrypted_content_affinity_check.py | 6 +++-- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py b/litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py index cdf69e4b24c..9cab59ea0e0 100644 --- a/litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py +++ b/litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py @@ -76,26 +76,31 @@ class EncryptedContentAffinityCheck(CustomLogger): def _extract_item_ids_from_output( output: list, ) -> List[str]: - """Extract all item IDs from a Responses API output list.""" + """Extract item IDs from output items that contain encrypted_content.""" item_ids: List[str] = [] for item in output: item_id: Optional[str] = None + has_encrypted_content = False + if isinstance(item, dict): item_id = item.get("id") + has_encrypted_content = "encrypted_content" in item else: item_id = getattr(item, "id", None) - if item_id and isinstance(item_id, str): + has_encrypted_content = hasattr(item, "encrypted_content") + + if item_id and isinstance(item_id, str) and has_encrypted_content: item_ids.append(item_id) return item_ids @staticmethod def _extract_item_ids_from_input(request_input: Any) -> List[str]: """ - Extract item IDs from the ``input`` field of a Responses API request. + Extract item IDs from input items that contain encrypted_content. ``input`` can be: - a plain string -> no item IDs - - a list of items -> each item may have an ``id`` field + - a list of items -> only extract IDs from items with encrypted_content """ if not isinstance(request_input, list): return [] @@ -104,7 +109,8 @@ class EncryptedContentAffinityCheck(CustomLogger): for item in request_input: if isinstance(item, dict): item_id = item.get("id") - if item_id and isinstance(item_id, str): + has_encrypted_content = "encrypted_content" in item + if item_id and isinstance(item_id, str) and has_encrypted_content: item_ids.append(item_id) return item_ids @@ -191,13 +197,13 @@ class EncryptedContentAffinityCheck(CustomLogger): ttl=self.ttl_seconds, ) except Exception as e: - verbose_router_logger.debug( + verbose_router_logger.error( "EncryptedContentAffinityCheck: failed to cache item_id=%s error=%s", item_id, e, ) - verbose_router_logger.info( + verbose_router_logger.debug( "EncryptedContentAffinityCheck: cached %d item IDs -> deployment=%s", len(item_ids), model_id, @@ -247,7 +253,7 @@ class EncryptedContentAffinityCheck(CustomLogger): model_id=cached_model_id, ) if deployment is not None: - verbose_router_logger.info( + verbose_router_logger.debug( "EncryptedContentAffinityCheck: item_id=%s pinning -> deployment=%s", item_id, cached_model_id, @@ -257,7 +263,7 @@ class EncryptedContentAffinityCheck(CustomLogger): ] = True return [deployment] - verbose_router_logger.info( + verbose_router_logger.debug( "EncryptedContentAffinityCheck: cached deployment=%s for item_id=%s " "not found in healthy_deployments", cached_model_id, diff --git a/tests/test_litellm/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py b/tests/test_litellm/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py index c70290b674d..17cd2162d99 100644 --- a/tests/test_litellm/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py +++ b/tests/test_litellm/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py @@ -48,6 +48,7 @@ async def test_encrypted_content_affinity_tracks_and_routes(): "type": "reasoning", "id": "rs_encrypted_item_456", "status": "completed", + "encrypted_content": "gAAAAABpnW_yEYmSNEyOG...", }, ], "parallel_tool_calls": True, @@ -118,7 +119,7 @@ async def test_encrypted_content_affinity_tracks_and_routes(): model=model_group, input=[ {"type": "message", "id": "msg_abc123", "role": "assistant"}, - {"type": "reasoning", "id": "rs_encrypted_item_456"}, + {"type": "reasoning", "id": "rs_encrypted_item_456", "encrypted_content": "gAAAAABpnW_yEYmSNEyOG..."}, ], ) second_model_id = second_response._hidden_params["model_id"] @@ -204,6 +205,7 @@ async def test_encrypted_content_affinity_bypasses_rpm_limits(): "type": "reasoning", "id": "rs_encrypted_must_pin", "status": "completed", + "encrypted_content": "gAAAAABpnW_yEYmSNEyOG...", }, ], "usage": {"input_tokens": 5, "output_tokens": 10, "total_tokens": 15}, @@ -255,7 +257,7 @@ async def test_encrypted_content_affinity_bypasses_rpm_limits(): second_response = await router.aresponses( model="openai.gpt-5.1-codex", input=[ - {"type": "reasoning", "id": "rs_encrypted_must_pin"}, + {"type": "reasoning", "id": "rs_encrypted_must_pin", "encrypted_content": "gAAAAABpnW_yEYmSNEyOG..."}, ], ) second_model_id = second_response._hidden_params["model_id"] From 18bf3f2df649c53486bf4e15c353e8464f112afa Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 24 Feb 2026 23:06:54 +0530 Subject: [PATCH 24/69] Fix mock github test --- .../test_encrypted_content_affinity_check.py | 52 ++++++------------- 1 file changed, 17 insertions(+), 35 deletions(-) diff --git a/tests/test_litellm/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py b/tests/test_litellm/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py index 17cd2162d99..cfebaab346f 100644 --- a/tests/test_litellm/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py +++ b/tests/test_litellm/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py @@ -4,6 +4,8 @@ import sys from unittest.mock import AsyncMock, patch import pytest +import respx +from httpx import Response sys.path.insert(0, os.path.abspath("../..")) @@ -143,7 +145,8 @@ async def test_encrypted_content_affinity_no_effect_on_chat_completions(): "model_name": "gpt-3.5-turbo", "litellm_params": { "model": "gpt-3.5-turbo", - "api_key": os.environ.get("OPENAI_API_KEY", "test-key"), + "api_key": "test-key", + "mock_response": "Hello from chat completion!", }, "model_info": {"id": "chat-deployment-1"}, }, @@ -151,41 +154,20 @@ async def test_encrypted_content_affinity_no_effect_on_chat_completions(): optional_pre_call_checks=["encrypted_content_affinity"], ) - mock_chat_response = { - "id": "chatcmpl-123", - "object": "chat.completion", - "created": 1677652288, - "model": "gpt-3.5-turbo", - "choices": [ - { - "index": 0, - "message": {"role": "assistant", "content": "Hello!"}, - "finish_reason": "stop", - } - ], - "usage": {"prompt_tokens": 5, "completion_tokens": 7, "total_tokens": 12}, - } + # Multiple chat completion requests should work normally + response1 = await router.acompletion( + model="gpt-3.5-turbo", + messages=[{"role": "user", "content": "Hello"}], + ) + response2 = await router.acompletion( + model="gpt-3.5-turbo", + messages=[{"role": "user", "content": "Hello again"}], + ) - with patch( - "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post", - new_callable=AsyncMock, - ) as mock_post: - mock_post.return_value = MockResponse(mock_chat_response, 200) - - # Multiple chat completion requests should work normally - response1 = await router.acompletion( - model="gpt-3.5-turbo", - messages=[{"role": "user", "content": "Hello"}], - ) - response2 = await router.acompletion( - model="gpt-3.5-turbo", - messages=[{"role": "user", "content": "Hello again"}], - ) - - # Both should succeed (no affinity interference) - # Check that responses have IDs (litellm may modify them) - assert response1.id is not None - assert response2.id is not None + # Both should succeed (no affinity interference) + # Check that responses have IDs + assert response1.id is not None + assert response2.id is not None @pytest.mark.asyncio From adec115db82505f34c9c018238fc7cedc4e202b1 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 24 Feb 2026 23:58:50 +0530 Subject: [PATCH 25/69] Fix logging for error --- .../pre_call_checks/encrypted_content_affinity_check.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py b/litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py index 9cab59ea0e0..d632dc6e0b0 100644 --- a/litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py +++ b/litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py @@ -263,7 +263,7 @@ class EncryptedContentAffinityCheck(CustomLogger): ] = True return [deployment] - verbose_router_logger.debug( + verbose_router_logger.error( "EncryptedContentAffinityCheck: cached deployment=%s for item_id=%s " "not found in healthy_deployments", cached_model_id, From 122f534d8762ff76e72dad0ff264bc0f51b6f7b5 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Fri, 27 Feb 2026 16:00:43 +0530 Subject: [PATCH 26/69] Add encoding method for Encrypted-content-aware deployment --- .../encrypted_content_affinity_check.py | 251 +++++------------- 1 file changed, 64 insertions(+), 187 deletions(-) diff --git a/litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py b/litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py index d632dc6e0b0..e6d691896ca 100644 --- a/litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py +++ b/litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py @@ -7,26 +7,36 @@ organization's API key. If a follow-up request containing those items is routed different deployment (different org), OpenAI rejects it with an `invalid_encrypted_content` error because the organization_id doesn't match. -This callback solves the problem by: -1. Tracking output item IDs from Responses API responses and mapping them to the - deployment (model_id) that produced them. -2. On subsequent requests, scanning the `input` field for known item IDs and pinning - the request to the originating deployment. +This callback solves the problem by encoding the originating deployment's ``model_id`` +directly into the item IDs of output items that carry ``encrypted_content`` (the same +approach used by the responses-API affinity for ``previous_response_id``). The encoded +ID is decoded on the next request so the router can pin to the correct deployment without +any cache lookup. + +Response post-processing (encoding) is handled by +``ResponsesAPIRequestUtils._update_encrypted_content_item_ids_in_response`` which is +called inside ``_update_responses_api_response_id_with_model_id`` in ``responses/utils.py``. + +Request pre-processing (ID restoration before forwarding to upstream) is handled by +``ResponsesAPIRequestUtils._restore_encrypted_content_item_ids_in_input`` which is called +in ``get_optional_params_responses_api``. + +This pre-call check is responsible only for the routing decision: it reads the encoded +``model_id`` out of the item IDs and pins the request to the matching deployment. Safe to enable globally: -- Only activates when known item IDs appear in the request `input`. +- Only activates when encoded item IDs appear in the request ``input``. - No effect on embedding models, chat completions, or first-time requests. - No quota reduction -- first requests are fully load balanced. +- No cache required. """ from typing import Any, List, Optional, cast from litellm._logging import verbose_router_logger -from litellm.caching.dual_cache import DualCache from litellm.integrations.custom_logger import CustomLogger, Span -from litellm.types.llms.openai import AllMessageValues, ResponsesAPIResponse - -_DEFAULT_TTL_SECONDS = 86400 # 24 hours +from litellm.responses.utils import ResponsesAPIRequestUtils +from litellm.types.llms.openai import AllMessageValues class EncryptedContentAffinityCheck(CustomLogger): @@ -34,89 +44,43 @@ class EncryptedContentAffinityCheck(CustomLogger): Routes follow-up Responses API requests to the deployment that produced the encrypted output items they reference. + The ``model_id`` is decoded directly from the litellm-encoded item IDs – + no caching or TTL management needed. + Wired via ``Router(optional_pre_call_checks=["encrypted_content_affinity"])``. """ - CACHE_KEY_PREFIX = "encrypted_content_affinity:v1" - - def __init__( - self, - cache: DualCache, - ttl_seconds: int = _DEFAULT_TTL_SECONDS, - ): + def __init__(self) -> None: super().__init__() - self.cache = cache - self.ttl_seconds = ttl_seconds # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ @staticmethod - def _get_output_from_response( - response_obj: Any, - ) -> Optional[list]: + def _extract_model_id_from_input(request_input: Any) -> Optional[str]: """ - Extract the ``output`` list from a Responses API response, handling - both ``ResponsesAPIResponse`` objects and plain dicts. - """ - if isinstance(response_obj, ResponsesAPIResponse): - return response_obj.output - if isinstance(response_obj, dict) and "output" in response_obj: - output = response_obj["output"] - if isinstance(output, list): - return output - if hasattr(response_obj, "output"): - output = response_obj.output - if isinstance(output, list): - return output - return None - - @staticmethod - def _extract_item_ids_from_output( - output: list, - ) -> List[str]: - """Extract item IDs from output items that contain encrypted_content.""" - item_ids: List[str] = [] - for item in output: - item_id: Optional[str] = None - has_encrypted_content = False - - if isinstance(item, dict): - item_id = item.get("id") - has_encrypted_content = "encrypted_content" in item - else: - item_id = getattr(item, "id", None) - has_encrypted_content = hasattr(item, "encrypted_content") - - if item_id and isinstance(item_id, str) and has_encrypted_content: - item_ids.append(item_id) - return item_ids - - @staticmethod - def _extract_item_ids_from_input(request_input: Any) -> List[str]: - """ - Extract item IDs from input items that contain encrypted_content. + Scan ``input`` items for litellm-encoded encrypted-content item IDs and + return the ``model_id`` embedded in the first one found. ``input`` can be: - - a plain string -> no item IDs - - a list of items -> only extract IDs from items with encrypted_content + - a plain string -> no encoded IDs + - a list of items -> check each item's ``id`` field """ if not isinstance(request_input, list): - return [] + return None - item_ids: List[str] = [] for item in request_input: - if isinstance(item, dict): - item_id = item.get("id") - has_encrypted_content = "encrypted_content" in item - if item_id and isinstance(item_id, str) and has_encrypted_content: - item_ids.append(item_id) - return item_ids + if not isinstance(item, dict): + continue + item_id = item.get("id") + if not item_id or not isinstance(item_id, str): + continue + decoded = ResponsesAPIRequestUtils._decode_encrypted_item_id(item_id) + if decoded: + return decoded.get("model_id") - @classmethod - def _cache_key(cls, item_id: str) -> str: - return f"{cls.CACHE_KEY_PREFIX}:{item_id}" + return None @staticmethod def _find_deployment_by_model_id( @@ -133,82 +97,6 @@ class EncryptedContentAffinityCheck(CustomLogger): return deployment return None - @staticmethod - def _get_model_id_from_kwargs(kwargs: dict) -> Optional[str]: - """ - Extract the deployment model_id from success-callback kwargs. - - The Router populates ``litellm_params.metadata.model_info.id`` after - selecting a deployment. Also check top-level ``model_info`` as a - fallback (some call paths set it there). - """ - # Primary path: litellm_params -> metadata -> model_info -> id - litellm_params = kwargs.get("litellm_params") - if isinstance(litellm_params, dict): - metadata = litellm_params.get("metadata") - if isinstance(metadata, dict): - model_info = metadata.get("model_info") - if isinstance(model_info, dict): - model_id = model_info.get("id") - if model_id is not None: - return str(model_id) - - # Fallback: top-level model_info (set by some router call paths) - model_info = kwargs.get("model_info") - if isinstance(model_info, dict): - model_id = model_info.get("id") - if model_id is not None: - return str(model_id) - - return None - - # ------------------------------------------------------------------ - # Response tracking (success callback) - # ------------------------------------------------------------------ - - async def async_log_success_event( - self, kwargs: dict, response_obj: Any, start_time: Any, end_time: Any - ) -> None: - """ - After a successful Responses API call, cache each output item ID - mapped to the deployment that produced it. - """ - output = self._get_output_from_response(response_obj) - if output is None: - return - - model_id = self._get_model_id_from_kwargs(kwargs) - if not model_id: - verbose_router_logger.debug( - "EncryptedContentAffinityCheck: model_id not found in kwargs, skipping tracking", - ) - return - - item_ids = self._extract_item_ids_from_output(output) - if not item_ids: - return - - for item_id in item_ids: - try: - cache_key = self._cache_key(item_id) - await self.cache.async_set_cache( - cache_key, - model_id, - ttl=self.ttl_seconds, - ) - except Exception as e: - verbose_router_logger.error( - "EncryptedContentAffinityCheck: failed to cache item_id=%s error=%s", - item_id, - e, - ) - - verbose_router_logger.debug( - "EncryptedContentAffinityCheck: cached %d item IDs -> deployment=%s", - len(item_ids), - model_id, - ) - # ------------------------------------------------------------------ # Request routing (pre-call filter) # ------------------------------------------------------------------ @@ -222,52 +110,41 @@ class EncryptedContentAffinityCheck(CustomLogger): parent_otel_span: Optional[Span] = None, ) -> List[dict]: """ - If the request ``input`` contains items whose IDs were previously - tracked, pin the request to the deployment that produced them. + If the request ``input`` contains litellm-encoded item IDs, decode the + embedded ``model_id`` and pin the request to that deployment. """ request_kwargs = request_kwargs or {} typed_healthy_deployments = cast(List[dict], healthy_deployments) + # Signal to the response post-processor that encrypted item IDs should be + # encoded in the output of this request. + litellm_metadata = request_kwargs.setdefault("litellm_metadata", {}) + litellm_metadata["encrypted_content_affinity_enabled"] = True + request_input = request_kwargs.get("input") - input_item_ids = self._extract_item_ids_from_input(request_input) - if not input_item_ids: + model_id = self._extract_model_id_from_input(request_input) + if not model_id: return typed_healthy_deployments verbose_router_logger.debug( - "EncryptedContentAffinityCheck: found %d item IDs in input, checking cache", - len(input_item_ids), + "EncryptedContentAffinityCheck: decoded model_id=%s from input item IDs", + model_id, ) - for item_id in input_item_ids: - cache_key = self._cache_key(item_id) - try: - cached_model_id = await self.cache.async_get_cache(key=cache_key) - except Exception: - continue - - if not cached_model_id or not isinstance(cached_model_id, str): - continue - - deployment = self._find_deployment_by_model_id( - healthy_deployments=typed_healthy_deployments, - model_id=cached_model_id, - ) - if deployment is not None: - verbose_router_logger.debug( - "EncryptedContentAffinityCheck: item_id=%s pinning -> deployment=%s", - item_id, - cached_model_id, - ) - request_kwargs[ - "_encrypted_content_affinity_pinned" - ] = True - return [deployment] - - verbose_router_logger.error( - "EncryptedContentAffinityCheck: cached deployment=%s for item_id=%s " - "not found in healthy_deployments", - cached_model_id, - item_id, + deployment = self._find_deployment_by_model_id( + healthy_deployments=typed_healthy_deployments, + model_id=model_id, + ) + if deployment is not None: + verbose_router_logger.debug( + "EncryptedContentAffinityCheck: pinning -> deployment=%s", + model_id, ) + request_kwargs["_encrypted_content_affinity_pinned"] = True + return [deployment] + verbose_router_logger.error( + "EncryptedContentAffinityCheck: decoded deployment=%s not found in healthy_deployments", + model_id, + ) return typed_healthy_deployments From 7928d41e9ad93e8df8e1228a5bee301ab32258e4 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Fri, 27 Feb 2026 16:01:02 +0530 Subject: [PATCH 27/69] Update the routing --- litellm/responses/main.py | 11 ++++ litellm/responses/utils.py | 110 +++++++++++++++++++++++++++++++++++++ litellm/router.py | 5 +- 3 files changed, 122 insertions(+), 4 deletions(-) diff --git a/litellm/responses/main.py b/litellm/responses/main.py index 05fd6026af2..2576ed7db31 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -745,6 +745,11 @@ def responses( custom_llm_provider=custom_llm_provider, ) + # Decode any litellm-encoded encrypted-content item IDs back to their original IDs + input = ResponsesAPIRequestUtils._restore_encrypted_content_item_ids_in_input( + input + ) + # Call the handler with _is_async flag instead of directly calling the async handler response = base_llm_http_handler.response_api_handler( model=model, @@ -1617,6 +1622,12 @@ def compact_responses( custom_llm_provider=custom_llm_provider, ) + # Decode any litellm-encoded encrypted-content item IDs back to their original IDs + # before forwarding to the upstream provider. + input = ResponsesAPIRequestUtils._restore_encrypted_content_item_ids_in_input( + input + ) + # Call the handler with _is_async flag instead of directly calling the async handler response = base_llm_http_handler.compact_response_api_handler( model=model, diff --git a/litellm/responses/utils.py b/litellm/responses/utils.py index 39aebb262fe..0c203dc6305 100644 --- a/litellm/responses/utils.py +++ b/litellm/responses/utils.py @@ -217,8 +217,118 @@ class ResponsesAPIRequestUtils: responses_api_response["id"] = updated_id else: responses_api_response.id = updated_id + + if litellm_metadata.get("encrypted_content_affinity_enabled"): + responses_api_response = ( + ResponsesAPIRequestUtils._update_encrypted_content_item_ids_in_response( + response=responses_api_response, + model_id=model_id, + ) + ) + return responses_api_response + @staticmethod + def _build_encrypted_item_id(model_id: str, item_id: str) -> str: + """Encode model_id into an output item ID for encrypted-content items. + + Format: ``encitem_{base64("litellm:model_id:{model_id};item_id:{original_id}")}`` + """ + assembled = f"litellm:model_id:{model_id};item_id:{item_id}" + encoded = base64.b64encode(assembled.encode("utf-8")).decode("utf-8") + return f"encitem_{encoded}" + + @staticmethod + def _decode_encrypted_item_id(encoded_id: str) -> Optional[Dict[str, str]]: + """Decode a litellm-encoded encrypted-content item ID. + + Returns a dict with ``model_id`` and ``item_id`` keys, or ``None`` if + the string is not a litellm-encoded item ID. + """ + if not encoded_id.startswith("encitem_"): + return None + try: + cleaned = encoded_id[len("encitem_"):] + # Restore any padding that may have been stripped in transit + missing = len(cleaned) % 4 + if missing: + cleaned += "=" * (4 - missing) + decoded = base64.b64decode(cleaned.encode("utf-8")).decode("utf-8") + # Split on first ";" only so that semicolons inside item_id are preserved + parts = decoded.split(";", 1) + if len(parts) < 2: + return None + model_id = parts[0].replace("litellm:model_id:", "") + item_id = parts[1].replace("item_id:", "") + return {"model_id": model_id, "item_id": item_id} + except Exception: + return None + + @staticmethod + def _update_encrypted_content_item_ids_in_response( + response: Union["ResponsesAPIResponse", Dict[str, Any]], + model_id: Optional[str], + ) -> Union["ResponsesAPIResponse", Dict[str, Any]]: + """Rewrite item IDs for output items that contain ``encrypted_content``. + + Encodes ``model_id`` into the item ID so that follow-up requests can be + routed back to the originating deployment without any cache lookup. + """ + if not model_id: + return response + + output: Optional[list] = None + if isinstance(response, dict): + output = response.get("output") + else: + output = getattr(response, "output", None) + + if not isinstance(output, list): + return response + + for item in output: + if isinstance(item, dict): + item_id = item.get("id") + if item_id and isinstance(item_id, str) and "encrypted_content" in item: + item["id"] = ResponsesAPIRequestUtils._build_encrypted_item_id( + model_id, item_id + ) + else: + item_id = getattr(item, "id", None) + if ( + item_id + and isinstance(item_id, str) + and hasattr(item, "encrypted_content") + ): + try: + item.id = ResponsesAPIRequestUtils._build_encrypted_item_id( + model_id, item_id + ) + except AttributeError: + pass + + return response + + @staticmethod + def _restore_encrypted_content_item_ids_in_input(request_input: Any) -> Any: + """Decode litellm-encoded item IDs in request input back to original IDs. + + Called before forwarding the request to the upstream provider so the + provider receives the original item IDs it issued. + """ + if not isinstance(request_input, list): + return request_input + + for item in request_input: + if isinstance(item, dict): + item_id = item.get("id") + if item_id and isinstance(item_id, str): + decoded = ResponsesAPIRequestUtils._decode_encrypted_item_id(item_id) + if decoded: + item["id"] = decoded["item_id"] + + return request_input + @staticmethod def _build_responses_api_response_id( custom_llm_provider: Optional[str], diff --git a/litellm/router.py b/litellm/router.py index 7dee4fa83de..652cd68b555 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -1263,10 +1263,7 @@ class Router: for cb in self.optional_callbacks ) if not already_registered: - ec_callback = EncryptedContentAffinityCheck( - cache=self.cache, - ttl_seconds=self.deployment_affinity_ttl_seconds, - ) + ec_callback = EncryptedContentAffinityCheck() self.optional_callbacks.append(ec_callback) litellm.logging_callback_manager.add_litellm_callback(ec_callback) From 37834f1d2a2adc5c80987f940f935fa56bbeb838 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Fri, 27 Feb 2026 16:01:23 +0530 Subject: [PATCH 28/69] Update the docs --- .../index.md | 90 +++++++++++-------- docs/my-website/docs/response_api.md | 34 +++---- 2 files changed, 62 insertions(+), 62 deletions(-) diff --git a/docs/my-website/blog/responses_api_encrypted_content_incident/index.md b/docs/my-website/blog/responses_api_encrypted_content_incident/index.md index 8422f6d3cd0..f229f9567da 100644 --- a/docs/my-website/blog/responses_api_encrypted_content_incident/index.md +++ b/docs/my-website/blog/responses_api_encrypted_content_incident/index.md @@ -119,47 +119,59 @@ Implemented a new `encrypted_content_affinity` pre-call check that intelligently ### Implementation -**1. New `EncryptedContentAffinityCheck` Class** ([`encrypted_content_affinity_check.py`](https://github.com/BerriAI/litellm/blob/main/litellm/litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py)) +**1. Encoding `model_id` into output item IDs** ([`responses/utils.py`](https://github.com/BerriAI/litellm/blob/main/litellm/litellm/responses/utils.py)) + +The same approach used for `previous_response_id` affinity — no cache needed. When a response contains output items with `encrypted_content`, LiteLLM rewrites their IDs to embed the originating deployment's `model_id`: + +```python +# On response: rs_abc123 → encitem_{base64("litellm:model_id:{model_id};item_id:rs_abc123")} +def _build_encrypted_item_id(model_id: str, item_id: str) -> str: + assembled = f"litellm:model_id:{model_id};item_id:{item_id}" + encoded = base64.b64encode(assembled.encode("utf-8")).decode("utf-8") + return f"encitem_{encoded}" + +# On request: decode encitem_... → extract model_id for routing +def _decode_encrypted_item_id(encoded_id: str) -> Optional[Dict[str, str]]: + if not encoded_id.startswith("encitem_"): + return None + cleaned = encoded_id[len("encitem_"):] + missing = len(cleaned) % 4 + if missing: + cleaned += "=" * (4 - missing) # restore padding stripped in transit + decoded = base64.b64decode(cleaned).decode("utf-8") + model_id, item_id = decoded.split(";", 1) + return {"model_id": model_id.replace("litellm:model_id:", ""), + "item_id": item_id.replace("item_id:", "")} +``` + +Before forwarding to the upstream provider, LiteLLM restores the original item IDs so the provider never sees the encoded form: + +```python +# In responses/main.py — before calling the handler +input = ResponsesAPIRequestUtils._restore_encrypted_content_item_ids_in_input(input) +``` + +**2. `EncryptedContentAffinityCheck` — routing only** ([`encrypted_content_affinity_check.py`](https://github.com/BerriAI/litellm/blob/main/litellm/litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py)) + +No `async_log_success_event` or cache lookups — the `model_id` is decoded directly from the item ID: ```python class EncryptedContentAffinityCheck(CustomLogger): - """ - Routes follow-up Responses API requests to the deployment that produced - the encrypted output items they reference. - """ - - async def async_log_success_event(self, kwargs, response_obj, ...): - """Track: Extract item IDs from response output, cache item_id → deployment_id""" - output = self._get_output_from_response(response_obj) - item_ids = self._extract_item_ids_from_output(output) - model_id = self._get_model_id_from_kwargs(kwargs) - - for item_id in item_ids: - await self.cache.async_set_cache( - f"encrypted_content_affinity:v1:{item_id}", - model_id, - ttl=86400, # 24 hours - ) - async def async_filter_deployments(self, model, healthy_deployments, ...): - """Route: Check if input contains tracked items, pin to originating deployment""" - input_item_ids = self._extract_item_ids_from_input(request_kwargs.get("input")) - - for item_id in input_item_ids: - cached_model_id = await self.cache.async_get_cache(f"...:{item_id}") - if cached_model_id: + """Decode encitem_ IDs in input to extract model_id and pin to that deployment.""" + for item in request_kwargs.get("input", []): + decoded = ResponsesAPIRequestUtils._decode_encrypted_item_id(item.get("id", "")) + if decoded: deployment = self._find_deployment_by_model_id( - healthy_deployments, cached_model_id + healthy_deployments, decoded["model_id"] ) if deployment: - # Signal to bypass rate limits (encrypted content must go here) request_kwargs["_encrypted_content_affinity_pinned"] = True return [deployment] - - return healthy_deployments # Normal load balancing + return healthy_deployments ``` -**2. Rate Limit Bypass** ([`router.py#L8656-L8660`](https://github.com/BerriAI/litellm/blob/main/litellm/litellm/router.py#L8656-L8660)) +**3. Rate Limit Bypass** ([`router.py`](https://github.com/BerriAI/litellm/blob/main/litellm/litellm/router.py)) When encrypted content requires a specific deployment, RPM/TPM limits are bypassed (the request would fail on any other deployment anyway): @@ -185,9 +197,10 @@ router_settings: ### Key Benefits -✅ **No quota reduction**: Only pins requests containing tracked encrypted items +✅ **No quota reduction**: Only pins requests containing encrypted items ✅ **Bypasses rate limits**: When encrypted content requires a specific deployment, RPM/TPM limits don't block it -✅ **No `previous_response_id` required**: Works by tracking item IDs in response output +✅ **No `previous_response_id` required**: Works by encoding `model_id` directly into the item ID +✅ **No cache required**: `model_id` is decoded on-the-fly from the item ID — no Redis, no TTL ✅ **Globally safe**: Can be enabled for all models; non-Responses-API calls are unaffected ✅ **Surgical precision**: Normal requests continue to load balance freely @@ -197,12 +210,13 @@ router_settings: | # | Action | Status | Code | |---|---|---|---| -| 1 | Create `EncryptedContentAffinityCheck` class with tracking and routing logic | ✅ Done | [`encrypted_content_affinity_check.py`](https://github.com/BerriAI/litellm/blob/main/litellm/litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py) | -| 2 | Add `encrypted_content_affinity` to `OptionalPreCallChecks` type | ✅ Done | [`router.py`](https://github.com/BerriAI/litellm/blob/main/litellm/litellm/types/router.py) | -| 3 | Wire up check in `Router.add_optional_pre_call_checks` | ✅ Done | [`router.py#L8656-L8660`](https://github.com/BerriAI/litellm/blob/main/litellm/litellm/router.py#L8656-L8660) | -| 4 | Implement rate limit bypass for affinity-pinned requests | ✅ Done | [`router.py#L8656-L8660`](https://github.com/BerriAI/litellm/blob/main/litellm/litellm/router.py#L8656-L8660) | -| 5 | Unit tests: tracking, routing, no-op for non-Responses-API, RPM bypass | ✅ Done | [`test_encrypted_content_affinity_check.py`](https://github.com/BerriAI/litellm/blob/main/litellm/tests/test_litellm/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py) | -| 6 | Documentation: Responses API guide, load balancing guide, config reference | ✅ Done | [Docs](https://docs.litellm.ai/docs/response_api#encrypted-content-affinity-multi-region-load-balancing) | +| 1 | Encode `model_id` into encrypted-content item IDs on response | ✅ Done | [`responses/utils.py`](https://github.com/BerriAI/litellm/blob/main/litellm/litellm/responses/utils.py) | +| 2 | Restore original item IDs before forwarding to upstream provider | ✅ Done | [`responses/main.py`](https://github.com/BerriAI/litellm/blob/main/litellm/litellm/responses/main.py) | +| 3 | `EncryptedContentAffinityCheck`: decode item IDs to route (no cache) | ✅ Done | [`encrypted_content_affinity_check.py`](https://github.com/BerriAI/litellm/blob/main/litellm/litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py) | +| 4 | Add `encrypted_content_affinity` to `OptionalPreCallChecks` type | ✅ Done | [`types/router.py`](https://github.com/BerriAI/litellm/blob/main/litellm/litellm/types/router.py) | +| 5 | Implement rate limit bypass for affinity-pinned requests | ✅ Done | [`router.py`](https://github.com/BerriAI/litellm/blob/main/litellm/litellm/router.py) | +| 6 | Unit tests: encoding/decoding utilities, routing, RPM bypass | ✅ Done | [`test_encrypted_content_affinity_check.py`](https://github.com/BerriAI/litellm/blob/main/litellm/tests/test_litellm/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py) | +| 7 | Documentation: Responses API guide, load balancing guide, config reference | ✅ Done | [Docs](https://docs.litellm.ai/docs/response_api#encrypted-content-affinity-multi-region-load-balancing) | --- diff --git a/docs/my-website/docs/response_api.md b/docs/my-website/docs/response_api.md index 85144a80245..a7cf61ef16a 100644 --- a/docs/my-website/docs/response_api.md +++ b/docs/my-website/docs/response_api.md @@ -1011,24 +1011,25 @@ This error occurs when: ### The Solution: `encrypted_content_affinity` -The `encrypted_content_affinity` pre-call check intelligently tracks encrypted content and routes follow-up requests to the originating deployment **only when necessary**. +The `encrypted_content_affinity` pre-call check routes follow-up requests containing encrypted items to the originating deployment **only when necessary** **Key Benefits:** -- ✅ **No quota reduction**: Unlike `deployment_affinity`, only pins requests that contain tracked encrypted items +- ✅ **No quota reduction**: Unlike `deployment_affinity`, only pins requests that contain encrypted items - ✅ **Bypasses rate limits**: When encrypted content requires a specific deployment, RPM/TPM limits are bypassed (the request would fail on any other deployment anyway) -- ✅ **No `previous_response_id` required**: Works by tracking item IDs in response output and matching them in request input +- ✅ **No `previous_response_id` required**: Works by encoding `model_id` directly into item IDs +- ✅ **No cache required**: `model_id` is decoded on-the-fly — no Redis dependency, no TTL to manage - ✅ **Globally safe**: Can be enabled for all models; non-Responses-API calls (chat, embeddings) are unaffected ### How It Works -1. **Tracking Phase** (after successful response): - - Extracts all item IDs from response `output` (e.g., `msg_abc`, `rs_xyz`) - - Caches mapping: `item_id` → `deployment_id` (default TTL: 24 hours) +1. **Encoding Phase** (on response): + - For each output item that contains `encrypted_content`, LiteLLM rewrites the item ID to embed the originating `model_id`: `rs_xyz` → `encitem_{base64("litellm:model_id:{model_id};item_id:rs_xyz")}` + - The original item ID is restored before forwarding the request to the upstream provider 2. **Routing Phase** (before request): - - Scans request `input` for item IDs - - If tracked item found → pins to originating deployment, bypasses rate limits - - If no tracked items → normal load balancing + - Scans request `input` for `encitem_` prefixed IDs + - If found → decodes `model_id`, pins to originating deployment, bypasses rate limits + - If no encoded items → normal load balancing ### Configuration @@ -1058,7 +1059,6 @@ router = Router( }, ], optional_pre_call_checks=["encrypted_content_affinity"], - deployment_affinity_ttl_seconds=86400, # 24 hours (default) ) # Initial request - routes to any deployment @@ -1104,7 +1104,6 @@ router_settings: enable_pre_call_checks: true optional_pre_call_checks: - encrypted_content_affinity - deployment_affinity_ttl_seconds: 86400 # Optional, default is 86400 (24 hours) ``` **Start proxy:** @@ -1124,19 +1123,6 @@ litellm --config config.yaml | `session_affinity` | Session-based applications | All requests with same `session_id` | ⚠️ Reduces quota by # of sessions | | `deployment_affinity` | Simple sticky sessions | All requests from same API key | ❌ Reduces quota by # of users | -### Multi-Instance Deployment (Redis) - -For multiple LiteLLM proxy instances, use Redis to share affinity state: - -```yaml -router_settings: - optional_pre_call_checks: - - encrypted_content_affinity - redis_host: redis.example.com - redis_port: 6379 - redis_password: your-password -``` - ## Calling non-Responses API endpoints (`/responses` to `/chat/completions` Bridge) From 2bc4da76ce62070bd753e9a3593c879060da389f Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Fri, 27 Feb 2026 16:01:38 +0530 Subject: [PATCH 29/69] Update the tests --- .../test_encrypted_content_affinity_check.py | 242 ++++++++++++++---- 1 file changed, 195 insertions(+), 47 deletions(-) diff --git a/tests/test_litellm/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py b/tests/test_litellm/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py index cfebaab346f..66177208f4f 100644 --- a/tests/test_litellm/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py +++ b/tests/test_litellm/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py @@ -1,17 +1,31 @@ -import asyncio +""" +Tests for encrypted_content_affinity pre-call check. + +The mechanism works without any cache: +- On response: item IDs for output items with `encrypted_content` are rewritten to + `encitem_{base64("litellm:model_id:{model_id};item_id:{original_id}")}`. +- On routing: `EncryptedContentAffinityCheck` decodes the `encitem_` prefix to extract + `model_id` and pins the request to that deployment. +- Before forwarding: `_restore_encrypted_content_item_ids_in_input` decodes the IDs back + to their original form before sending to the upstream provider. +""" + import os import sys from unittest.mock import AsyncMock, patch import pytest -import respx -from httpx import Response sys.path.insert(0, os.path.abspath("../..")) import json import litellm +from litellm.responses.utils import ResponsesAPIRequestUtils + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- class MockResponse: @@ -25,12 +39,147 @@ class MockResponse: return self._json_data +def _get_item_id(item) -> str: + """Extract item ID from either a Pydantic model or a dict.""" + if isinstance(item, dict): + return item.get("id", "") + return getattr(item, "id", "") or "" + + +def _has_encrypted_content(item) -> bool: + """Check whether an output item carries encrypted_content.""" + if isinstance(item, dict): + return "encrypted_content" in item + return hasattr(item, "encrypted_content") and getattr(item, "encrypted_content") is not None + + +def _extract_encoded_item_id(response) -> str: + """ + Walk the response output and return the first litellm-encoded item ID + (i.e. one that starts with ``encitem_``). + """ + for item in response.output or []: + item_id = _get_item_id(item) + if item_id.startswith("encitem_"): + return item_id + return "" + + +# --------------------------------------------------------------------------- +# Unit tests for encoding / decoding utilities +# --------------------------------------------------------------------------- + + +class TestEncryptedItemIdCodec: + def test_roundtrip(self): + model_id = "deployment-1" + original_item_id = "rs_abc123def456" + encoded = ResponsesAPIRequestUtils._build_encrypted_item_id(model_id, original_item_id) + assert encoded.startswith("encitem_") + decoded = ResponsesAPIRequestUtils._decode_encrypted_item_id(encoded) + assert decoded is not None + assert decoded["model_id"] == model_id + assert decoded["item_id"] == original_item_id + + def test_decode_without_padding(self): + """Decoding must succeed even if base64 padding (=) was stripped in transit.""" + model_id = "gpt-5.1-codex-openai-2" + original_item_id = "rs_0efb96cb222403210069a01d5d52588196a9dc394ffdb89d00" + encoded = ResponsesAPIRequestUtils._build_encrypted_item_id(model_id, original_item_id) + # Strip any trailing '=' to simulate what happens in transit + stripped = encoded.rstrip("=") + decoded = ResponsesAPIRequestUtils._decode_encrypted_item_id(stripped) + assert decoded is not None + assert decoded["model_id"] == model_id + assert decoded["item_id"] == original_item_id + + def test_non_encoded_id_returns_none(self): + assert ResponsesAPIRequestUtils._decode_encrypted_item_id("rs_abc123") is None + assert ResponsesAPIRequestUtils._decode_encrypted_item_id("msg_abc") is None + assert ResponsesAPIRequestUtils._decode_encrypted_item_id("") is None + + def test_semicolon_in_item_id(self): + """item_id values containing ';' must survive the roundtrip.""" + model_id = "deployment-1" + original_item_id = "rs_part1;part2;part3" + encoded = ResponsesAPIRequestUtils._build_encrypted_item_id(model_id, original_item_id) + decoded = ResponsesAPIRequestUtils._decode_encrypted_item_id(encoded) + assert decoded is not None + assert decoded["item_id"] == original_item_id + + +class TestUpdateEncryptedContentItemIds: + def test_rewrites_encrypted_items_in_dict_response(self): + model_id = "deployment-1" + response = { + "id": "resp_123", + "output": [ + {"id": "msg_abc", "type": "message", "content": []}, + {"id": "rs_xyz", "type": "reasoning", "encrypted_content": "secret"}, + ], + } + result = ResponsesAPIRequestUtils._update_encrypted_content_item_ids_in_response( + response, model_id + ) + # Plain message item untouched + assert result["output"][0]["id"] == "msg_abc" + # Reasoning item with encrypted_content gets encoded + encoded_id = result["output"][1]["id"] + assert encoded_id.startswith("encitem_") + decoded = ResponsesAPIRequestUtils._decode_encrypted_item_id(encoded_id) + assert decoded["model_id"] == model_id + assert decoded["item_id"] == "rs_xyz" + + def test_no_op_when_model_id_is_none(self): + response = { + "output": [{"id": "rs_xyz", "type": "reasoning", "encrypted_content": "secret"}] + } + result = ResponsesAPIRequestUtils._update_encrypted_content_item_ids_in_response( + response, None + ) + assert result["output"][0]["id"] == "rs_xyz" + + +class TestRestoreEncryptedContentItemIds: + def test_restores_encoded_ids(self): + model_id = "deployment-1" + original_id = "rs_encrypted_item_456" + encoded_id = ResponsesAPIRequestUtils._build_encrypted_item_id(model_id, original_id) + + request_input = [ + {"type": "message", "id": "msg_abc123", "role": "assistant"}, + {"type": "reasoning", "id": encoded_id, "encrypted_content": "secret"}, + ] + restored = ResponsesAPIRequestUtils._restore_encrypted_content_item_ids_in_input( + request_input + ) + assert restored[0]["id"] == "msg_abc123" + assert restored[1]["id"] == original_id + + def test_no_op_for_plain_string_input(self): + result = ResponsesAPIRequestUtils._restore_encrypted_content_item_ids_in_input( + "Hello world" + ) + assert result == "Hello world" + + def test_no_op_for_unencoded_ids(self): + request_input = [{"type": "message", "id": "msg_plain"}] + result = ResponsesAPIRequestUtils._restore_encrypted_content_item_ids_in_input( + request_input + ) + assert result[0]["id"] == "msg_plain" + + +# --------------------------------------------------------------------------- +# Integration tests (router-level) +# --------------------------------------------------------------------------- + + @pytest.mark.asyncio async def test_encrypted_content_affinity_tracks_and_routes(): """ - When encrypted_content_affinity is enabled, output item IDs from responses - are tracked, and follow-up requests containing those IDs route to the same - deployment. + The first response rewrites encrypted-content item IDs to encoded form. + The follow-up request with those encoded IDs is pinned to the same deployment. """ mock_response_data = { "id": "resp_mock-123", @@ -54,11 +203,7 @@ async def test_encrypted_content_affinity_tracks_and_routes(): }, ], "parallel_tool_calls": True, - "usage": { - "input_tokens": 5, - "output_tokens": 10, - "total_tokens": 15, - }, + "usage": {"input_tokens": 5, "output_tokens": 10, "total_tokens": 15}, "error": None, } @@ -84,14 +229,9 @@ async def test_encrypted_content_affinity_tracks_and_routes(): optional_pre_call_checks=["encrypted_content_affinity"], ) - model_group = "openai.gpt-5.1-codex" - - # Track which deployment was selected selected_deployments = [] def deterministic_choice(seq): - # First call: select deployment-1 - # Second call: would select deployment-2, but affinity should override if len(selected_deployments) == 0: return seq[0] return seq[1] if len(seq) > 1 else seq[0] @@ -105,39 +245,49 @@ async def test_encrypted_content_affinity_tracks_and_routes(): ): mock_post.return_value = MockResponse(mock_response_data, 200) - # First request: no encrypted items in input + # First request — goes to deployment-1 via deterministic_choice first_response = await router.aresponses( - model=model_group, + model="openai.gpt-5.1-codex", input="Hello, how are you?", ) first_model_id = first_response._hidden_params["model_id"] selected_deployments.append(first_model_id) - # Give async callbacks time to run - await asyncio.sleep(0.2) + # The response must have rewritten the encrypted item's ID to encoded form + encoded_item_id = _extract_encoded_item_id(first_response) + assert encoded_item_id.startswith("encitem_"), ( + f"Expected output item ID to be rewritten to encitem_... but got {encoded_item_id!r}" + ) - # Second request: includes encrypted item IDs from first response + # Verify the encoded ID decodes back to the correct deployment + original ID + decoded = ResponsesAPIRequestUtils._decode_encrypted_item_id(encoded_item_id) + assert decoded is not None + assert decoded["model_id"] == first_model_id + assert decoded["item_id"] == "rs_encrypted_item_456" + + # Second request: use the encoded item IDs from the first response second_response = await router.aresponses( - model=model_group, + model="openai.gpt-5.1-codex", input=[ {"type": "message", "id": "msg_abc123", "role": "assistant"}, - {"type": "reasoning", "id": "rs_encrypted_item_456", "encrypted_content": "gAAAAABpnW_yEYmSNEyOG..."}, + { + "type": "reasoning", + "id": encoded_item_id, + "encrypted_content": "gAAAAABpnW_yEYmSNEyOG...", + }, ], ) second_model_id = second_response._hidden_params["model_id"] - # Affinity should route to the same deployment assert second_model_id == first_model_id, ( - f"Expected affinity to route to {first_model_id}, " - f"but got {second_model_id}" + f"Expected affinity to route to {first_model_id}, but got {second_model_id}" ) @pytest.mark.asyncio async def test_encrypted_content_affinity_no_effect_on_chat_completions(): """ - Encrypted content affinity should not affect regular chat completions - (they don't use the Responses API). + Encrypted content affinity should not affect regular chat completions. """ router = litellm.Router( model_list=[ @@ -154,7 +304,6 @@ async def test_encrypted_content_affinity_no_effect_on_chat_completions(): optional_pre_call_checks=["encrypted_content_affinity"], ) - # Multiple chat completion requests should work normally response1 = await router.acompletion( model="gpt-3.5-turbo", messages=[{"role": "user", "content": "Hello"}], @@ -163,9 +312,6 @@ async def test_encrypted_content_affinity_no_effect_on_chat_completions(): model="gpt-3.5-turbo", messages=[{"role": "user", "content": "Hello again"}], ) - - # Both should succeed (no affinity interference) - # Check that responses have IDs assert response1.id is not None assert response2.id is not None @@ -173,8 +319,8 @@ async def test_encrypted_content_affinity_no_effect_on_chat_completions(): @pytest.mark.asyncio async def test_encrypted_content_affinity_bypasses_rpm_limits(): """ - When encrypted content affinity pins to a deployment, it should bypass - RPM limits since the encrypted content will fail on any other deployment. + When encrypted content affinity pins to a deployment, RPM limits are bypassed + since the request would fail on any other deployment anyway. """ mock_response_data = { "id": "resp_mock-rpm-test", @@ -225,34 +371,40 @@ async def test_encrypted_content_affinity_bypasses_rpm_limits(): ) as mock_post: mock_post.return_value = MockResponse(mock_response_data, 200) - # First request goes to the low-RPM deployment first_response = await router.aresponses( model="openai.gpt-5.1-codex", input="Initial request", ) first_model_id = first_response._hidden_params["model_id"] - await asyncio.sleep(0.2) + # Extract encoded item ID from the first response output + encoded_item_id = _extract_encoded_item_id(first_response) + assert encoded_item_id.startswith("encitem_"), ( + f"Expected encitem_... but got {encoded_item_id!r}" + ) - # Second request with encrypted content should pin to the same deployment - # even though it's at RPM limit + # Follow-up with the encoded item ID — should pin to same deployment + # even if it is at its RPM limit second_response = await router.aresponses( model="openai.gpt-5.1-codex", input=[ - {"type": "reasoning", "id": "rs_encrypted_must_pin", "encrypted_content": "gAAAAABpnW_yEYmSNEyOG..."}, + { + "type": "reasoning", + "id": encoded_item_id, + "encrypted_content": "gAAAAABpnW_yEYmSNEyOG...", + }, ], ) second_model_id = second_response._hidden_params["model_id"] - # Should route to the same deployment despite RPM limit assert second_model_id == first_model_id @pytest.mark.asyncio async def test_encrypted_content_affinity_no_match_normal_routing(): """ - When input contains item IDs that aren't tracked, normal load balancing - should occur. + Input items with non-encoded IDs (no encitem_ prefix) fall through to + normal load balancing. """ mock_response_data = { "id": "resp_mock-no-match", @@ -301,15 +453,11 @@ async def test_encrypted_content_affinity_no_match_normal_routing(): ) as mock_post: mock_post.return_value = MockResponse(mock_response_data, 200) - # Request with unknown item IDs should use normal routing + # Non-encoded item ID — no affinity should kick in response = await router.aresponses( model="openai.gpt-5.1-codex", input=[ {"type": "message", "id": "unknown_item_id_12345"}, ], ) - - # Should succeed with normal routing (litellm may modify the ID) assert response.id is not None - # Verify it contains the original response ID in some form - assert "resp_mock-no-match" in str(response.id) or response.id.startswith("resp_") From 521f804350069acbbf88025d77e805b35d41affa Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 3 Mar 2026 18:13:03 +0530 Subject: [PATCH 30/69] Fix encrypted content streaming affinity issue --- .../index.md | 118 ++++++-- .../exception_mapping_utils.py | 45 ++- litellm/responses/streaming_iterator.py | 30 +- litellm/responses/utils.py | 108 ++++++- .../encrypted_content_affinity_check.py | 52 +++- .../azure/test_azure_exception_mapping.py | 57 +++- .../test_encrypted_content_affinity_check.py | 280 +++++++++++++++++- 7 files changed, 624 insertions(+), 66 deletions(-) diff --git a/docs/my-website/blog/responses_api_encrypted_content_incident/index.md b/docs/my-website/blog/responses_api_encrypted_content_incident/index.md index f229f9567da..19b55898caa 100644 --- a/docs/my-website/blog/responses_api_encrypted_content_incident/index.md +++ b/docs/my-website/blog/responses_api_encrypted_content_incident/index.md @@ -119,32 +119,36 @@ Implemented a new `encrypted_content_affinity` pre-call check that intelligently ### Implementation -**1. Encoding `model_id` into output item IDs** ([`responses/utils.py`](https://github.com/BerriAI/litellm/blob/main/litellm/litellm/responses/utils.py)) +**1. Encoding `model_id` into output items** ([`responses/utils.py`](https://github.com/BerriAI/litellm/blob/main/litellm/litellm/responses/utils.py)) -The same approach used for `previous_response_id` affinity — no cache needed. When a response contains output items with `encrypted_content`, LiteLLM rewrites their IDs to embed the originating deployment's `model_id`: +The same approach used for `previous_response_id` affinity — no cache needed. When a response contains output items with `encrypted_content`, LiteLLM encodes the originating deployment's `model_id` in **two places** for redundancy: + +1. **Into the item ID** (if present): `rs_abc123` → `encitem_{base64("litellm:model_id:{model_id};item_id:rs_abc123")}` +2. **Into the encrypted_content itself**: Wraps the content with `litellm_enc:{base64("model_id:{model_id}")};{original_encrypted_content}` ```python -# On response: rs_abc123 → encitem_{base64("litellm:model_id:{model_id};item_id:rs_abc123")} +# Encoding item IDs (when present) def _build_encrypted_item_id(model_id: str, item_id: str) -> str: assembled = f"litellm:model_id:{model_id};item_id:{item_id}" encoded = base64.b64encode(assembled.encode("utf-8")).decode("utf-8") return f"encitem_{encoded}" -# On request: decode encitem_... → extract model_id for routing -def _decode_encrypted_item_id(encoded_id: str) -> Optional[Dict[str, str]]: - if not encoded_id.startswith("encitem_"): - return None - cleaned = encoded_id[len("encitem_"):] - missing = len(cleaned) % 4 - if missing: - cleaned += "=" * (4 - missing) # restore padding stripped in transit - decoded = base64.b64decode(cleaned).decode("utf-8") - model_id, item_id = decoded.split(";", 1) - return {"model_id": model_id.replace("litellm:model_id:", ""), - "item_id": item_id.replace("item_id:", "")} +# Wrapping encrypted_content (always, for redundancy) +def _wrap_encrypted_content_with_model_id(encrypted_content: str, model_id: str) -> str: + metadata = f"model_id:{model_id}" + encoded_metadata = base64.b64encode(metadata.encode("utf-8")).decode("utf-8") + return f"litellm_enc:{encoded_metadata};{encrypted_content}" ``` -Before forwarding to the upstream provider, LiteLLM restores the original item IDs so the provider never sees the encoded form: +**Why wrap encrypted_content directly?** Some clients (like Codex) don't consistently send item IDs in follow-up requests, but they always send the `encrypted_content` itself. By embedding `model_id` into the content, affinity works even when IDs are missing. + +**Streaming responses:** The wrapping logic is applied to both: +- Final response objects (non-streaming) +- Individual streaming events (`response.output_item.added`, `response.output_item.done`) + +This ensures clients receiving streaming responses get wrapped content they can send back. + +Before forwarding to the upstream provider, LiteLLM restores the original item IDs and unwraps encrypted_content so the provider never sees the encoded form: ```python # In responses/main.py — before calling the handler @@ -153,22 +157,43 @@ input = ResponsesAPIRequestUtils._restore_encrypted_content_item_ids_in_input(in **2. `EncryptedContentAffinityCheck` — routing only** ([`encrypted_content_affinity_check.py`](https://github.com/BerriAI/litellm/blob/main/litellm/litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py)) -No `async_log_success_event` or cache lookups — the `model_id` is decoded directly from the item ID: +No `async_log_success_event` or cache lookups — the `model_id` is decoded directly from the item ID or encrypted_content: ```python class EncryptedContentAffinityCheck(CustomLogger): async def async_filter_deployments(self, model, healthy_deployments, ...): - """Decode encitem_ IDs in input to extract model_id and pin to that deployment.""" + """Extract model_id from input items (ID or encrypted_content) and pin to that deployment.""" for item in request_kwargs.get("input", []): - decoded = ResponsesAPIRequestUtils._decode_encrypted_item_id(item.get("id", "")) - if decoded: + # Try to extract model_id from two sources: + model_id = self._extract_model_id_from_input(item) + + if model_id: deployment = self._find_deployment_by_model_id( - healthy_deployments, decoded["model_id"] + healthy_deployments, model_id ) if deployment: request_kwargs["_encrypted_content_affinity_pinned"] = True return [deployment] return healthy_deployments + + def _extract_model_id_from_input(self, item: dict) -> Optional[str]: + """Extract model_id from either encoded ID or wrapped encrypted_content.""" + # 1. Try decoding from item ID (if present) + item_id = item.get("id", "") + if item_id: + decoded = ResponsesAPIRequestUtils._decode_encrypted_item_id(item_id) + if decoded: + return decoded["model_id"] + + # 2. Try unwrapping from encrypted_content (fallback for clients that omit IDs) + encrypted_content = item.get("encrypted_content", "") + if encrypted_content and encrypted_content.startswith("litellm_enc:"): + model_id, _ = ResponsesAPIRequestUtils._unwrap_encrypted_content_with_model_id( + encrypted_content + ) + return model_id + + return None ``` **3. Rate Limit Bypass** ([`router.py`](https://github.com/BerriAI/litellm/blob/main/litellm/litellm/router.py)) @@ -217,6 +242,57 @@ router_settings: | 5 | Implement rate limit bypass for affinity-pinned requests | ✅ Done | [`router.py`](https://github.com/BerriAI/litellm/blob/main/litellm/litellm/router.py) | | 6 | Unit tests: encoding/decoding utilities, routing, RPM bypass | ✅ Done | [`test_encrypted_content_affinity_check.py`](https://github.com/BerriAI/litellm/blob/main/litellm/tests/test_litellm/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py) | | 7 | Documentation: Responses API guide, load balancing guide, config reference | ✅ Done | [Docs](https://docs.litellm.ai/docs/response_api#encrypted-content-affinity-multi-region-load-balancing) | +| 8 | **[Mar 3]** Fix streaming events to wrap encrypted_content | ✅ Done | [`responses/streaming_iterator.py`](https://github.com/BerriAI/litellm/blob/main/litellm/litellm/responses/streaming_iterator.py) | + +--- + +## Follow-up Fix: Streaming Responses (Mar 3, 2026) + +### The Issue + +After the initial fix was deployed, users reported that the `invalid_encrypted_content` error **still occurred** when using streaming responses with clients like Codex. Investigation revealed: + +- ✅ Non-streaming responses: `encrypted_content` was correctly wrapped with `litellm_enc:` prefix +- ❌ Streaming responses: Individual `response.output_item.added` and `response.output_item.done` events contained **raw, unwrapped** `encrypted_content` + +Since Codex and other clients consume responses as streams, they received unwrapped content in these events and sent it back in follow-up requests, causing the affinity check to fail. + +### The Root Cause + +The `_update_encrypted_content_item_ids_in_response` function only modified the **final** response object, which is used for non-streaming responses. For streaming responses, individual chunks are processed by `ResponsesAPIStreamingIterator._process_chunk`, which was **not** applying the wrapping logic to streaming events. + +### The Fix + +Modified `litellm/litellm/responses/streaming_iterator.py` to wrap `encrypted_content` in streaming events: + +```python +# In ResponsesAPIStreamingIterator._process_chunk +if ( + self.litellm_metadata + and self.litellm_metadata.get("encrypted_content_affinity_enabled") +): + event_type = getattr(openai_responses_api_chunk, "type", None) + if event_type in ( + ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED, + ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE, + ): + item = getattr(openai_responses_api_chunk, "item", None) + if item: + encrypted_content = getattr(item, "encrypted_content", None) + if encrypted_content and isinstance(encrypted_content, str): + model_id = ( + self.litellm_metadata.get("model_info", {}).get("id") + if self.litellm_metadata + else None + ) + if model_id: + wrapped_content = ResponsesAPIRequestUtils._wrap_encrypted_content_with_model_id( + encrypted_content, model_id + ) + setattr(item, "encrypted_content", wrapped_content) +``` + +This ensures that **all** `encrypted_content` sent to clients (streaming or non-streaming) is wrapped with `model_id` metadata, enabling consistent affinity routing. --- diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index dde44cced36..951485130b3 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -1,9 +1,9 @@ import json +import re import traceback from typing import Any, Optional import httpx -import re import litellm from litellm._logging import verbose_logger @@ -443,6 +443,27 @@ def exception_type( # type: ignore # noqa: PLR0915 response=getattr(original_exception, "response", None), litellm_debug_info=extra_information, ) + elif "invalid_encrypted_content" in error_str or "could not be verified" in error_str: + exception_mapping_worked = True + helpful_message = ( + f"{exception_provider} - {message}\n\n" + " This error occurs when load balancing Responses API across deployments with different API keys.\n" + " Encrypted content is tied to the organization that created it and cannot be decrypted by other organizations.\n\n" + " Solution: Enable 'encrypted_content_affinity' to route follow-up requests to the correct deployment:\n\n" + " router_settings:\n" + " enable_pre_call_checks: true\n" + " optional_pre_call_checks:\n" + " - encrypted_content_affinity\n\n" + " Learn more: https://docs.litellm.ai/docs/response_api#encrypted-content-affinity-multi-region-load-balancing" + ) + raise BadRequestError( + message=helpful_message, + llm_provider=custom_llm_provider, + model=model, + response=getattr(original_exception, "response", None), + litellm_debug_info=extra_information, + body=getattr(original_exception, "body", None), + ) elif ( "invalid_request_error" in error_str and "Incorrect API key provided" not in error_str @@ -2126,7 +2147,27 @@ def exception_type( # type: ignore # noqa: PLR0915 extra_information=extra_information, original_exception=original_exception, ) - + elif azure_error_code == "invalid_encrypted_content" or "could not be verified" in error_str: + exception_mapping_worked = True + helpful_message = ( + f"AzureException - {message}\n\n" + "This error occurs when load balancing Responses API across deployments with different API keys.\n" + " Encrypted content is tied to the organization that created it and cannot be decrypted by other organizations.\n\n" + " Solution: Enable 'encrypted_content_affinity' to route follow-up requests to the correct deployment:\n\n" + " router_settings:\n" + " enable_pre_call_checks: true\n" + " optional_pre_call_checks:\n" + " - encrypted_content_affinity\n\n" + " Learn more: https://docs.litellm.ai/docs/response_api#encrypted-content-affinity-multi-region-load-balancing" + ) + raise BadRequestError( + message=helpful_message, + llm_provider="azure", + model=model, + litellm_debug_info=extra_information, + response=getattr(original_exception, "response", None), + body=getattr(original_exception, "body", None), + ) elif "invalid_request_error" in error_str: exception_mapping_worked = True raise BadRequestError( diff --git a/litellm/responses/streaming_iterator.py b/litellm/responses/streaming_iterator.py index 43ef4610b4b..f61f108c992 100644 --- a/litellm/responses/streaming_iterator.py +++ b/litellm/responses/streaming_iterator.py @@ -8,7 +8,10 @@ from typing import Any, Dict, Optional import httpx import litellm -from litellm.constants import LITELLM_MAX_STREAMING_DURATION_SECONDS, STREAM_SSE_DONE_STRING +from litellm.constants import ( + LITELLM_MAX_STREAMING_DURATION_SECONDS, + STREAM_SSE_DONE_STRING, +) from litellm.litellm_core_utils.asyncify import run_async_function from litellm.litellm_core_utils.core_helpers import process_response_headers from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj @@ -137,6 +140,31 @@ class BaseResponsesAPIStreamingIterator: ) setattr(openai_responses_api_chunk, "response", response) + # Wrap encrypted_content in streaming events (output_item.added, output_item.done) + if ( + self.litellm_metadata + and self.litellm_metadata.get("encrypted_content_affinity_enabled") + ): + event_type = getattr(openai_responses_api_chunk, "type", None) + if event_type in ( + ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED, + ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE, + ): + item = getattr(openai_responses_api_chunk, "item", None) + if item: + encrypted_content = getattr(item, "encrypted_content", None) + if encrypted_content and isinstance(encrypted_content, str): + model_id = ( + self.litellm_metadata.get("model_info", {}).get("id") + if self.litellm_metadata + else None + ) + if model_id: + wrapped_content = ResponsesAPIRequestUtils._wrap_encrypted_content_with_model_id( + encrypted_content, model_id + ) + setattr(item, "encrypted_content", wrapped_content) + # Store the completed response if ( openai_responses_api_chunk diff --git a/litellm/responses/utils.py b/litellm/responses/utils.py index 0c203dc6305..89e89711706 100644 --- a/litellm/responses/utils.py +++ b/litellm/responses/utils.py @@ -264,6 +264,56 @@ class ResponsesAPIRequestUtils: except Exception: return None + @staticmethod + def _wrap_encrypted_content_with_model_id( + encrypted_content: str, model_id: str + ) -> str: + """Wrap encrypted_content with model_id metadata for affinity routing. + + When Codex or other clients send items with encrypted_content but no ID, + we encode the model_id directly into the encrypted_content itself. + + Format: ``litellm_enc:{base64("model_id:{model_id}")};{original_encrypted_content}`` + """ + metadata = f"model_id:{model_id}" + encoded_metadata = base64.b64encode(metadata.encode("utf-8")).decode("utf-8") + return f"litellm_enc:{encoded_metadata};{encrypted_content}" + + @staticmethod + def _unwrap_encrypted_content_with_model_id( + wrapped_content: str, + ) -> tuple[Optional[str], str]: + """Unwrap encrypted_content to extract model_id and original content. + + Returns: + Tuple of (model_id, original_encrypted_content). + If not wrapped, returns (None, original_content). + """ + if not wrapped_content.startswith("litellm_enc:"): + return None, wrapped_content + + try: + # Split on first ";" to separate metadata from content + parts = wrapped_content.split(";", 1) + if len(parts) < 2: + return None, wrapped_content + + metadata_b64 = parts[0].replace("litellm_enc:", "") + original_content = parts[1] + + # Restore padding if needed + missing = len(metadata_b64) % 4 + if missing: + metadata_b64 += "=" * (4 - missing) + + decoded_metadata = base64.b64decode(metadata_b64.encode("utf-8")).decode( + "utf-8" + ) + model_id = decoded_metadata.replace("model_id:", "") + return model_id, original_content + except Exception: + return None, wrapped_content + @staticmethod def _update_encrypted_content_item_ids_in_response( response: Union["ResponsesAPIResponse", Dict[str, Any]], @@ -273,6 +323,9 @@ class ResponsesAPIRequestUtils: Encodes ``model_id`` into the item ID so that follow-up requests can be routed back to the originating deployment without any cache lookup. + + For items without an ID (e.g., from Codex), encodes model_id directly + into the encrypted_content itself. """ if not model_id: return response @@ -289,23 +342,42 @@ class ResponsesAPIRequestUtils: for item in output: if isinstance(item, dict): item_id = item.get("id") - if item_id and isinstance(item_id, str) and "encrypted_content" in item: - item["id"] = ResponsesAPIRequestUtils._build_encrypted_item_id( - model_id, item_id + encrypted_content = item.get("encrypted_content") + + if encrypted_content and isinstance(encrypted_content, str): + # Always wrap encrypted_content with model_id for redundancy + item["encrypted_content"] = ( + ResponsesAPIRequestUtils._wrap_encrypted_content_with_model_id( + encrypted_content, model_id + ) ) + # Also encode the ID if present + if item_id and isinstance(item_id, str): + item["id"] = ResponsesAPIRequestUtils._build_encrypted_item_id( + model_id, item_id + ) else: item_id = getattr(item, "id", None) - if ( - item_id - and isinstance(item_id, str) - and hasattr(item, "encrypted_content") - ): + encrypted_content = getattr(item, "encrypted_content", None) + + if encrypted_content and isinstance(encrypted_content, str): + # Always wrap encrypted_content with model_id for redundancy try: - item.id = ResponsesAPIRequestUtils._build_encrypted_item_id( - model_id, item_id + item.encrypted_content = ( + ResponsesAPIRequestUtils._wrap_encrypted_content_with_model_id( + encrypted_content, model_id + ) ) except AttributeError: pass + # Also encode the ID if present + if item_id and isinstance(item_id, str): + try: + item.id = ResponsesAPIRequestUtils._build_encrypted_item_id( + model_id, item_id + ) + except AttributeError: + pass return response @@ -314,7 +386,11 @@ class ResponsesAPIRequestUtils: """Decode litellm-encoded item IDs in request input back to original IDs. Called before forwarding the request to the upstream provider so the - provider receives the original item IDs it issued. + provider receives the original item IDs and unwrapped encrypted_content. + + Handles both: + 1. Items with encoded IDs (encitem_...) + 2. Items with wrapped encrypted_content (litellm_enc:...) """ if not isinstance(request_input, list): return request_input @@ -327,6 +403,16 @@ class ResponsesAPIRequestUtils: if decoded: item["id"] = decoded["item_id"] + encrypted_content = item.get("encrypted_content") + if encrypted_content and isinstance(encrypted_content, str): + _, unwrapped = ( + ResponsesAPIRequestUtils._unwrap_encrypted_content_with_model_id( + encrypted_content + ) + ) + if unwrapped != encrypted_content: + item["encrypted_content"] = unwrapped + return request_input @staticmethod diff --git a/litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py b/litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py index e6d691896ca..dc44ef13b7c 100644 --- a/litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py +++ b/litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py @@ -8,24 +8,29 @@ different deployment (different org), OpenAI rejects it with an `invalid_encrypt error because the organization_id doesn't match. This callback solves the problem by encoding the originating deployment's ``model_id`` -directly into the item IDs of output items that carry ``encrypted_content`` (the same -approach used by the responses-API affinity for ``previous_response_id``). The encoded -ID is decoded on the next request so the router can pin to the correct deployment without -any cache lookup. +into the response output items that carry ``encrypted_content``. Two encoding strategies: + +1. **Items with IDs**: Encode model_id into the item ID itself (e.g., ``encitem_...``) +2. **Items without IDs** (Codex): Wrap the encrypted_content with model_id metadata + (e.g., ``litellm_enc:{base64_metadata};{original_encrypted_content}``) + +The encoded model_id is decoded on the next request so the router can pin to the correct +deployment without any cache lookup. Response post-processing (encoding) is handled by ``ResponsesAPIRequestUtils._update_encrypted_content_item_ids_in_response`` which is called inside ``_update_responses_api_response_id_with_model_id`` in ``responses/utils.py``. -Request pre-processing (ID restoration before forwarding to upstream) is handled by +Request pre-processing (ID/content restoration before forwarding to upstream) is handled by ``ResponsesAPIRequestUtils._restore_encrypted_content_item_ids_in_input`` which is called in ``get_optional_params_responses_api``. This pre-call check is responsible only for the routing decision: it reads the encoded -``model_id`` out of the item IDs and pins the request to the matching deployment. +``model_id`` from either item IDs or wrapped encrypted_content and pins the request to +the matching deployment. Safe to enable globally: -- Only activates when encoded item IDs appear in the request ``input``. +- Only activates when encoded markers appear in the request ``input``. - No effect on embedding models, chat completions, or first-time requests. - No quota reduction -- first requests are fully load balanced. - No cache required. @@ -60,12 +65,16 @@ class EncryptedContentAffinityCheck(CustomLogger): @staticmethod def _extract_model_id_from_input(request_input: Any) -> Optional[str]: """ - Scan ``input`` items for litellm-encoded encrypted-content item IDs and + Scan ``input`` items for litellm-encoded encrypted-content markers and return the ``model_id`` embedded in the first one found. + Checks both: + 1. Encoded item IDs (encitem_...) - for clients that send IDs + 2. Wrapped encrypted_content (litellm_enc:...) - for clients like Codex that don't send IDs + ``input`` can be: - - a plain string -> no encoded IDs - - a list of items -> check each item's ``id`` field + - a plain string -> no encoded markers + - a list of items -> check each item's ``id`` and ``encrypted_content`` fields """ if not isinstance(request_input, list): return None @@ -73,12 +82,25 @@ class EncryptedContentAffinityCheck(CustomLogger): for item in request_input: if not isinstance(item, dict): continue + + # First, try to decode from item ID (if present) item_id = item.get("id") - if not item_id or not isinstance(item_id, str): - continue - decoded = ResponsesAPIRequestUtils._decode_encrypted_item_id(item_id) - if decoded: - return decoded.get("model_id") + if item_id and isinstance(item_id, str): + decoded = ResponsesAPIRequestUtils._decode_encrypted_item_id(item_id) + if decoded: + return decoded.get("model_id") + + # If no encoded ID, check if encrypted_content itself is wrapped + encrypted_content = item.get("encrypted_content") + if encrypted_content and isinstance(encrypted_content, str): + ( + model_id, + _, + ) = ResponsesAPIRequestUtils._unwrap_encrypted_content_with_model_id( + encrypted_content + ) + if model_id: + return model_id return None diff --git a/tests/test_litellm/llms/azure/test_azure_exception_mapping.py b/tests/test_litellm/llms/azure/test_azure_exception_mapping.py index 495ca958cf5..249b9349c54 100644 --- a/tests/test_litellm/llms/azure/test_azure_exception_mapping.py +++ b/tests/test_litellm/llms/azure/test_azure_exception_mapping.py @@ -384,4 +384,59 @@ class TestAzureExceptionMapping: model="azure/dall-e-3", original_exception=mock_exception, custom_llm_provider="azure", - ) \ No newline at end of file + ) + + def test_invalid_encrypted_content_error_with_helpful_message(self): + """Test that invalid_encrypted_content errors include helpful guidance + about enabling encrypted_content_affinity.""" + from litellm.exceptions import BadRequestError + + mock_exception = Exception( + "The encrypted content gAAAAABpnW_yEYmSNEyOG... could not be verified. " + "Reason: Encrypted content organization_id did not match the target organization." + ) + mock_exception.body = { + "error": { + "message": "The encrypted content could not be verified.", + "type": "invalid_request_error", + "code": "invalid_encrypted_content", + } + } + mock_response = MagicMock() + mock_response.status_code = 400 + mock_exception.response = mock_response + + with pytest.raises(BadRequestError) as exc_info: + exception_type( + model="azure/gpt-5.1-codex", + original_exception=mock_exception, + custom_llm_provider="azure", + ) + + error = exc_info.value + assert "encrypted_content_affinity" in error.message + assert "enable_pre_call_checks" in error.message + assert "optional_pre_call_checks" in error.message + assert "docs.litellm.ai" in error.message + + def test_openai_invalid_encrypted_content_error(self): + """Test that OpenAI invalid_encrypted_content errors also get helpful guidance.""" + from litellm.exceptions import BadRequestError + + mock_exception = Exception( + "The encrypted content could not be verified." + ) + mock_response = MagicMock() + mock_response.status_code = 400 + mock_exception.response = mock_response + + with pytest.raises(BadRequestError) as exc_info: + exception_type( + model="gpt-5.1-codex", + original_exception=mock_exception, + custom_llm_provider="openai", + ) + + error = exc_info.value + assert "encrypted_content_affinity" in error.message + assert "enable_pre_call_checks" in error.message \ No newline at end of file diff --git a/tests/test_litellm/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py b/tests/test_litellm/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py index 66177208f4f..6e845e9d050 100644 --- a/tests/test_litellm/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py +++ b/tests/test_litellm/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py @@ -1,13 +1,18 @@ """ Tests for encrypted_content_affinity pre-call check. -The mechanism works without any cache: -- On response: item IDs for output items with `encrypted_content` are rewritten to - `encitem_{base64("litellm:model_id:{model_id};item_id:{original_id}")}`. -- On routing: `EncryptedContentAffinityCheck` decodes the `encitem_` prefix to extract - `model_id` and pins the request to that deployment. -- Before forwarding: `_restore_encrypted_content_item_ids_in_input` decodes the IDs back - to their original form before sending to the upstream provider. +The mechanism works without any cache and supports two encoding strategies: + +1. **Items with IDs**: item IDs for output items with `encrypted_content` are rewritten to + `encitem_{base64("litellm:model_id:{model_id};item_id:{original_id}")}`. + +2. **Items without IDs** (Codex): encrypted_content itself is wrapped with model_id metadata: + `litellm_enc:{base64("model_id:{model_id}")};{original_encrypted_content}`. + +- On routing: `EncryptedContentAffinityCheck` decodes from either item IDs or wrapped + encrypted_content to extract `model_id` and pins the request to that deployment. +- Before forwarding: `_restore_encrypted_content_item_ids_in_input` decodes IDs and unwraps + encrypted_content back to their original forms before sending to the upstream provider. """ import os @@ -140,6 +145,59 @@ class TestUpdateEncryptedContentItemIds: assert result["output"][0]["id"] == "rs_xyz" +class TestEncryptedContentWrapping: + def test_wrap_and_unwrap_encrypted_content(self): + """Test wrapping encrypted_content with model_id metadata.""" + model_id = "deployment-1" + original_content = "gAAAAABpnW_yEYmSNEyOG_original_encrypted_data" + wrapped = ResponsesAPIRequestUtils._wrap_encrypted_content_with_model_id( + original_content, model_id + ) + assert wrapped.startswith("litellm_enc:") + assert wrapped != original_content + + unwrapped_model_id, unwrapped_content = ( + ResponsesAPIRequestUtils._unwrap_encrypted_content_with_model_id(wrapped) + ) + assert unwrapped_model_id == model_id + assert unwrapped_content == original_content + + def test_unwrap_plain_encrypted_content(self): + """Unwrapping plain encrypted_content returns None for model_id.""" + plain_content = "gAAAAABpnW_yEYmSNEyOG_plain_content" + model_id, content = ResponsesAPIRequestUtils._unwrap_encrypted_content_with_model_id( + plain_content + ) + assert model_id is None + assert content == plain_content + + def test_update_response_wraps_encrypted_content_without_id(self): + """Items with encrypted_content but no ID get the content wrapped.""" + model_id = "deployment-1" + response = { + "id": "resp_123", + "output": [ + {"type": "message", "content": []}, + { + "type": "reasoning", + "encrypted_content": "gAAAAABpnW_yEYmSNEyOG_secret", + }, + ], + } + result = ResponsesAPIRequestUtils._update_encrypted_content_item_ids_in_response( + response, model_id + ) + assert result["output"][0].get("encrypted_content") is None + wrapped = result["output"][1]["encrypted_content"] + assert wrapped.startswith("litellm_enc:") + + model_id_extracted, unwrapped = ( + ResponsesAPIRequestUtils._unwrap_encrypted_content_with_model_id(wrapped) + ) + assert model_id_extracted == model_id + assert unwrapped == "gAAAAABpnW_yEYmSNEyOG_secret" + + class TestRestoreEncryptedContentItemIds: def test_restores_encoded_ids(self): model_id = "deployment-1" @@ -156,6 +214,22 @@ class TestRestoreEncryptedContentItemIds: assert restored[0]["id"] == "msg_abc123" assert restored[1]["id"] == original_id + def test_unwraps_encrypted_content(self): + """Test that wrapped encrypted_content is unwrapped before forwarding.""" + model_id = "deployment-1" + original_content = "gAAAAABpnW_yEYmSNEyOG_original" + wrapped_content = ResponsesAPIRequestUtils._wrap_encrypted_content_with_model_id( + original_content, model_id + ) + + request_input = [ + {"type": "reasoning", "encrypted_content": wrapped_content}, + ] + restored = ResponsesAPIRequestUtils._restore_encrypted_content_item_ids_in_input( + request_input + ) + assert restored[0]["encrypted_content"] == original_content + def test_no_op_for_plain_string_input(self): result = ResponsesAPIRequestUtils._restore_encrypted_content_item_ids_in_input( "Hello world" @@ -319,8 +393,8 @@ async def test_encrypted_content_affinity_no_effect_on_chat_completions(): @pytest.mark.asyncio async def test_encrypted_content_affinity_bypasses_rpm_limits(): """ - When encrypted content affinity pins to a deployment, RPM limits are bypassed - since the request would fail on any other deployment anyway. + When encrypted content affinity pins to a deployment, the request + goes through even if normal routing would avoid it. """ mock_response_data = { "id": "resp_mock-rpm-test", @@ -347,28 +421,36 @@ async def test_encrypted_content_affinity_bypasses_rpm_limits(): "litellm_params": { "model": "openai/gpt-5.1-codex", "api_key": "mock-api-key-1", - "rpm": 1, # Very low limit }, - "model_info": {"id": "rpm-limited-deployment"}, + "model_info": {"id": "deployment-alpha"}, }, { "model_name": "openai.gpt-5.1-codex", "litellm_params": { "model": "openai/gpt-5.1-codex", "api_key": "mock-api-key-2", - "rpm": 100, }, - "model_info": {"id": "high-rpm-deployment"}, + "model_info": {"id": "deployment-beta"}, }, ], optional_pre_call_checks=["encrypted_content_affinity"], routing_strategy="usage-based-routing-v2", ) + selected_deployments = [] + + def deterministic_choice(seq): + if len(selected_deployments) == 0: + return seq[0] + return seq[1] if len(seq) > 1 else seq[0] + with patch( "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post", new_callable=AsyncMock, - ) as mock_post: + ) as mock_post, patch( + "litellm.router_strategy.simple_shuffle.random.choice", + side_effect=deterministic_choice, + ): mock_post.return_value = MockResponse(mock_response_data, 200) first_response = await router.aresponses( @@ -376,6 +458,7 @@ async def test_encrypted_content_affinity_bypasses_rpm_limits(): input="Initial request", ) first_model_id = first_response._hidden_params["model_id"] + selected_deployments.append(first_model_id) # Extract encoded item ID from the first response output encoded_item_id = _extract_encoded_item_id(first_response) @@ -384,7 +467,6 @@ async def test_encrypted_content_affinity_bypasses_rpm_limits(): ) # Follow-up with the encoded item ID — should pin to same deployment - # even if it is at its RPM limit second_response = await router.aresponses( model="openai.gpt-5.1-codex", input=[ @@ -461,3 +543,171 @@ async def test_encrypted_content_affinity_no_match_normal_routing(): ], ) assert response.id is not None + + +@pytest.mark.asyncio +async def test_encrypted_content_affinity_with_wrapped_content_no_id(): + """ + Test affinity routing when items have wrapped encrypted_content but no ID. + This simulates Codex client behavior where IDs are omitted. + """ + mock_response_data = { + "id": "resp_mock-wrapped-content", + "object": "response", + "created_at": 1741476542, + "status": "completed", + "model": "openai/gpt-5.1-codex", + "output": [ + { + "type": "reasoning", + "status": "completed", + "encrypted_content": "gAAAAABpnW_yEYmSNEyOG_original_content", + }, + ], + "usage": {"input_tokens": 5, "output_tokens": 10, "total_tokens": 15}, + "error": None, + } + + router = litellm.Router( + model_list=[ + { + "model_name": "openai.gpt-5.1-codex", + "litellm_params": { + "model": "openai/gpt-5.1-codex", + "api_key": "mock-api-key-1", + }, + "model_info": {"id": "deployment-1"}, + }, + { + "model_name": "openai.gpt-5.1-codex", + "litellm_params": { + "model": "openai/gpt-5.1-codex", + "api_key": "mock-api-key-2", + }, + "model_info": {"id": "deployment-2"}, + }, + ], + optional_pre_call_checks=["encrypted_content_affinity"], + ) + + selected_deployments = [] + + def deterministic_choice(seq): + if len(selected_deployments) == 0: + return seq[0] + return seq[1] if len(seq) > 1 else seq[0] + + with patch( + "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post", + new_callable=AsyncMock, + ) as mock_post, patch( + "litellm.router_strategy.simple_shuffle.random.choice", + side_effect=deterministic_choice, + ): + mock_post.return_value = MockResponse(mock_response_data, 200) + + # First request — goes to deployment-1 + first_response = await router.aresponses( + model="openai.gpt-5.1-codex", + input="Hello, how are you?", + ) + first_model_id = first_response._hidden_params["model_id"] + selected_deployments.append(first_model_id) + + # Extract wrapped encrypted_content from first response + first_item = first_response.output[0] + wrapped_content = ( + first_item.encrypted_content + if hasattr(first_item, "encrypted_content") + else first_item.get("encrypted_content") + ) + assert wrapped_content.startswith("litellm_enc:"), ( + f"Expected wrapped content but got {wrapped_content[:50]}..." + ) + + # Verify we can extract model_id from wrapped content + extracted_model_id, _ = ( + ResponsesAPIRequestUtils._unwrap_encrypted_content_with_model_id( + wrapped_content + ) + ) + assert extracted_model_id == first_model_id + + # Second request: use wrapped encrypted_content WITHOUT an ID (Codex behavior) + second_response = await router.aresponses( + model="openai.gpt-5.1-codex", + input=[ + { + "type": "reasoning", + "encrypted_content": wrapped_content, + }, + ], + ) + second_model_id = second_response._hidden_params["model_id"] + + assert second_model_id == first_model_id, ( + f"Expected affinity to route to {first_model_id}, but got {second_model_id}" + ) + + +def test_encrypted_content_wrapping_preserves_original_content(): + """ + Test that wrapping and unwrapping encrypted_content preserves the original content. + This is critical for streaming responses where content must round-trip correctly. + """ + model_id = "test-deployment-1" + original_encrypted_content = "gAAAAABpnW_yEYmSNEyOG_streaming_test_content_with_special_chars==+/" + + wrapped = ResponsesAPIRequestUtils._wrap_encrypted_content_with_model_id( + original_encrypted_content, model_id + ) + + assert wrapped.startswith("litellm_enc:") + assert wrapped != original_encrypted_content + + extracted_model_id, unwrapped_content = ( + ResponsesAPIRequestUtils._unwrap_encrypted_content_with_model_id(wrapped) + ) + + assert extracted_model_id == model_id + assert unwrapped_content == original_encrypted_content + + +def test_encrypted_content_wrapping_with_multiple_semicolons(): + """ + Test that encrypted_content containing semicolons is handled correctly. + """ + model_id = "deployment-with-semicolons" + original_content = "gAAAAAB;some;content;with;semicolons" + + wrapped = ResponsesAPIRequestUtils._wrap_encrypted_content_with_model_id( + original_content, model_id + ) + + extracted_model_id, unwrapped = ( + ResponsesAPIRequestUtils._unwrap_encrypted_content_with_model_id(wrapped) + ) + + assert extracted_model_id == model_id + assert unwrapped == original_content + + +def test_encrypted_content_wrapping_empty_string(): + """ + Test that empty encrypted_content is handled gracefully. + """ + model_id = "test-deployment" + original_content = "" + + wrapped = ResponsesAPIRequestUtils._wrap_encrypted_content_with_model_id( + original_content, model_id + ) + + assert wrapped.startswith("litellm_enc:") + + extracted_model_id, unwrapped = ( + ResponsesAPIRequestUtils._unwrap_encrypted_content_with_model_id(wrapped) + ) + + assert extracted_model_id == model_id + assert unwrapped == original_content From ca597e18c8a0ca173d45ff1fd56ee39f4d1aa80b Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 3 Mar 2026 18:32:41 +0530 Subject: [PATCH 31/69] Fix routing of encrypted content --- litellm/router.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/litellm/router.py b/litellm/router.py index 652cd68b555..67ccec4c6ef 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -1289,6 +1289,11 @@ class Router: ) elif pre_call_check == "enforce_model_rate_limits": _callback = ModelRateLimitingCheck(dual_cache=self.cache) + elif pre_call_check == "encrypted_content_affinity": + from litellm.router_utils.pre_call_checks.encrypted_content_affinity_check import ( + EncryptedContentAffinityCheck, + ) + _callback = EncryptedContentAffinityCheck() if _callback is None: continue From 2f6279d1894ce630afcbf80c1e96231ec06d1e82 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 3 Mar 2026 18:41:15 +0530 Subject: [PATCH 32/69] Fix import issue --- litellm/router.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/litellm/router.py b/litellm/router.py index 67ccec4c6ef..8eb2c417511 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -1255,6 +1255,10 @@ class Router: # Encrypted content affinity # --------------------------------------------------------------------- if "encrypted_content_affinity" in optional_pre_call_checks: + from litellm.router_utils.pre_call_checks.encrypted_content_affinity_check import ( + EncryptedContentAffinityCheck, + ) + if self.optional_callbacks is None: self.optional_callbacks = [] @@ -1289,11 +1293,6 @@ class Router: ) elif pre_call_check == "enforce_model_rate_limits": _callback = ModelRateLimitingCheck(dual_cache=self.cache) - elif pre_call_check == "encrypted_content_affinity": - from litellm.router_utils.pre_call_checks.encrypted_content_affinity_check import ( - EncryptedContentAffinityCheck, - ) - _callback = EncryptedContentAffinityCheck() if _callback is None: continue From d66f8bc15d8f22c52d81785ab047ff65208da35f Mon Sep 17 00:00:00 2001 From: Varad Khonde Date: Tue, 3 Mar 2026 19:17:28 +0530 Subject: [PATCH 33/69] feat(togetherai): add support for togetherai/Qwen3.5-397B-A17B model --- litellm/model_prices_and_context_window_backup.json | 12 ++++++++++++ model_prices_and_context_window.json | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index d4c5b476af6..e476d0c6b2a 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -29397,6 +29397,18 @@ "supports_response_schema": true, "supports_tool_choice": true }, + "together_ai/Qwen/Qwen/Qwen3.5-397B-A17B": { + "input_cost_per_token": 6e-07, + "litellm_provider": "together_ai", + "max_input_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 3.6e-06, + "source": "https://www.together.ai/models/Qwen/Qwen3.5-397B-A17B", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, "tts-1": { "input_cost_per_character": 1.5e-05, "litellm_provider": "openai", diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 4934f11d456..cf55ddf6e25 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -29632,6 +29632,18 @@ "supports_response_schema": true, "supports_tool_choice": true }, + "together_ai/Qwen/Qwen/Qwen3.5-397B-A17B": { + "input_cost_per_token": 6e-07, + "litellm_provider": "together_ai", + "max_input_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 3.6e-06, + "source": "https://www.together.ai/models/Qwen/Qwen3.5-397B-A17B", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, "tts-1": { "input_cost_per_character": 1.5e-05, "litellm_provider": "openai", From 6c0387d170a2caab45393222bcc32e769a2c5124 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 3 Mar 2026 19:31:55 +0530 Subject: [PATCH 34/69] Add support for Attaching knowledge base to model via UI --- .../src/components/add_model/AddModelForm.tsx | 1 + .../add_model/advanced_settings.test.tsx | 3 + .../add_model/advanced_settings.tsx | 30 +++++++++ .../src/components/model_info_view.tsx | 61 +++++++++++++++++++ 4 files changed, 95 insertions(+) diff --git a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx index 59ac63cffe6..2b3f23a35ae 100644 --- a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx +++ b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx @@ -358,6 +358,7 @@ const AddModelForm: React.FC = ({ teams={teams} guardrailsList={guardrailsList || []} tagsList={tagsList || {}} + accessToken={accessToken || ""} /> )} diff --git a/ui/litellm-dashboard/src/components/add_model/advanced_settings.test.tsx b/ui/litellm-dashboard/src/components/add_model/advanced_settings.test.tsx index 6515c67c292..9fe36e13998 100644 --- a/ui/litellm-dashboard/src/components/add_model/advanced_settings.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/advanced_settings.test.tsx @@ -13,6 +13,7 @@ describe("AdvancedSettings", () => { setShowAdvancedSettings={() => {}} guardrailsList={[]} tagsList={{}} + accessToken="test-token" />, ); }); @@ -24,6 +25,7 @@ describe("AdvancedSettings", () => { setShowAdvancedSettings={() => {}} guardrailsList={[]} tagsList={{}} + accessToken="test-token" />, ); fireEvent.click(getByText("Advanced Settings")); @@ -39,6 +41,7 @@ describe("AdvancedSettings", () => { setShowAdvancedSettings={() => {}} guardrailsList={[]} tagsList={{}} + accessToken="test-token" />, ); act(() => { diff --git a/ui/litellm-dashboard/src/components/add_model/advanced_settings.tsx b/ui/litellm-dashboard/src/components/add_model/advanced_settings.tsx index c9f5ef8a4b1..8ae90c1cbbc 100644 --- a/ui/litellm-dashboard/src/components/add_model/advanced_settings.tsx +++ b/ui/litellm-dashboard/src/components/add_model/advanced_settings.tsx @@ -6,6 +6,7 @@ import TextArea from "antd/es/input/TextArea"; import { InfoCircleOutlined } from "@ant-design/icons"; import { Team } from "../key_team_helpers/key_list"; import CacheControlSettings from "./cache_control_settings"; +import VectorStoreSelector from "../vector_store_management/VectorStoreSelector"; import { Tag } from "../tag_management/types"; import { formItemValidateJSON } from "../../utils/textUtils"; const { Link } = Typography; @@ -16,6 +17,7 @@ interface AdvancedSettingsProps { teams?: Team[] | null; guardrailsList: string[]; tagsList: Record; + accessToken: string; } const AdvancedSettings: React.FC = ({ @@ -24,6 +26,7 @@ const AdvancedSettings: React.FC = ({ teams, guardrailsList, tagsList, + accessToken, }) => { const [form] = Form.useForm(); const [customPricing, setCustomPricing] = React.useState(false); @@ -109,6 +112,33 @@ const AdvancedSettings: React.FC = ({ + + Attached Knowledge Bases (RAG){" "} + + e.stopPropagation()} + > + + + + + } + name="vector_store_ids" + className="mt-4" + help="Select vector stores to attach. Requests to this model will automatically use these for RAG. Set up vector stores in Tools > Vector Stores." + > + {}} + accessToken={accessToken} + placeholder="Select knowledge bases (optional)" + /> + + diff --git a/ui/litellm-dashboard/src/components/model_info_view.tsx b/ui/litellm-dashboard/src/components/model_info_view.tsx index e2fc8caa21c..40c1a3a386a 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.tsx @@ -17,6 +17,7 @@ import { Button as TremorButton, } from "@tremor/react"; import { Button, Form, Input, Modal, Select, Tooltip } from "antd"; +import VectorStoreSelector from "./vector_store_management/VectorStoreSelector"; import { CheckIcon, CopyIcon } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { copyToClipboard as utilCopyToClipboard } from "../utils/dataUtils"; @@ -245,6 +246,11 @@ export default function ModelInfoView({ if (values.guardrails) { updatedLitellmParams.guardrails = values.guardrails; } + if (values.vector_store_ids !== undefined) { + updatedLitellmParams.vector_store_ids = Array.isArray(values.vector_store_ids) + ? values.vector_store_ids + : []; + } // Handle cache control settings if (values.cache_control && values.cache_control_injection_points?.length > 0) { @@ -606,6 +612,9 @@ export default function ModelInfoView({ guardrails: Array.isArray(localModelData.litellm_params?.guardrails) ? localModelData.litellm_params.guardrails : [], + vector_store_ids: Array.isArray(localModelData.litellm_params?.vector_store_ids) + ? localModelData.litellm_params.vector_store_ids + : [], tags: Array.isArray(localModelData.litellm_params?.tags) ? localModelData.litellm_params.tags : [], health_check_model: isWildcardModel ? localModelData.model_info?.health_check_model : null, litellm_extra_params: JSON.stringify(localModelData.litellm_params || {}, null, 2), @@ -883,6 +892,58 @@ export default function ModelInfoView({ )}
+
+
+
+
+ + Team: {g.team} + + + + {status.label} + +
+

{g.name}

+

+ Submitted by {g.submittedBy} on {g.submittedAt} +

+
+ +
+

{g.description}

+
+ + + + + + {g.method} + + +
+
+
+ + + Forward LiteLLM API Key + +
+ +
+

+ When enabled, the caller's LiteLLM API key is forwarded as an{" "} + + Authorization + {" "} + header to your guardrail endpoint. This allows your guardrail to + authenticate model calls using the original caller's + credentials. +

+
+
+
+ + Static headers + + {g.customHeaders.length > 0 && ( + + {g.customHeaders.length} + + )} +
+

+ Sent with every request to the guardrail. +

+ {g.customHeaders.length === 0 ? ( +

+ No static headers configured. +

+ ) : ( +
    + {g.customHeaders.map((h, i) => ( +
  • + + {h.key}: {h.value} + + +
  • + ))} +
+ )} +
+ setNewStaticHeaderKey(e.target.value)} + placeholder="Header name (e.g. X-API-Key)" + className="flex-1 min-w-0 text-xs font-mono border border-gray-200 rounded px-2 py-1.5 text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-500" + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + const key = newStaticHeaderKey.trim(); + const value = newStaticHeaderValue.trim(); + if (key && !g.customHeaders.some((h) => h.key.toLowerCase() === key.toLowerCase())) { + onUpdateCustomHeaders([...g.customHeaders, { key, value }]); + setNewStaticHeaderKey(""); + setNewStaticHeaderValue(""); + } + } + }} + /> + setNewStaticHeaderValue(e.target.value)} + placeholder="Value" + className="flex-1 min-w-0 text-xs font-mono border border-gray-200 rounded px-2 py-1.5 text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-500" + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + const key = newStaticHeaderKey.trim(); + const value = newStaticHeaderValue.trim(); + if (key && !g.customHeaders.some((h) => h.key.toLowerCase() === key.toLowerCase())) { + onUpdateCustomHeaders([...g.customHeaders, { key, value }]); + setNewStaticHeaderKey(""); + setNewStaticHeaderValue(""); + } + } + }} + /> + +
+
+
+
+ + Forward client headers + + {g.extraHeaders.length > 0 && ( + + {g.extraHeaders.length} + + )} +
+

+ Allowed header names to forward from the client request to the guardrail (e.g. x-request-id). +

+ {g.extraHeaders.length === 0 ? ( +

+ No forward client headers configured. +

+ ) : ( +
    + {g.extraHeaders.map((name, i) => ( +
  • + {name} + +
  • + ))} +
+ )} +
+ setNewExtraHeader(e.target.value)} + placeholder="e.g. x-request-id" + className="flex-1 min-w-0 text-xs font-mono border border-gray-200 rounded px-2 py-1.5 text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-500" + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + const name = newExtraHeader.trim().toLowerCase(); + if (name && !g.extraHeaders.map((h) => h.toLowerCase()).includes(name)) { + onUpdateExtraHeaders([...g.extraHeaders, name]); + setNewExtraHeader(""); + } + } + }} + /> + +
+
+
+ + {configExpanded && ( +
+                {buildEquivalentConfigYaml(g)}
+              
+ )} +
+
+ +

+ This guardrail runs on a separate instance. It receives the user + request and forwards the result to the next step in the pipeline. See{" "} + + LiteLLM Generic Guardrail API docs + {" "} + for configuration details. +

+
+
+
+ + {g.status === "pending" && ( +
+ + +
+ )} +
+
+
+
+ + Attached Knowledge Bases (RAG) + + e.stopPropagation()} + > + + + + + {isEditing ? ( + + {}} + accessToken={accessToken || ""} + placeholder="Select knowledge bases (optional)" + /> + + ) : ( +
+ {localModelData.litellm_params?.vector_store_ids ? ( + Array.isArray(localModelData.litellm_params.vector_store_ids) ? ( + localModelData.litellm_params.vector_store_ids.length > 0 ? ( +
+ {localModelData.litellm_params.vector_store_ids.map( + (vsId: string, index: number) => ( + + {vsId} + + ) + )} +
+ ) : ( + "No knowledge bases attached" + ) + ) : ( + String(localModelData.litellm_params.vector_store_ids) + ) + ) : ( + "Not Set" + )} +
+ )} +
+
Tags {isEditing ? ( From 24ec7f882f5e3036d47544f8ef96f899fb4606aa Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 3 Mar 2026 19:35:26 +0530 Subject: [PATCH 35/69] Revert "feat(togetherai): add support for togetherai/Qwen3.5-397B-A17B model" --- litellm/model_prices_and_context_window_backup.json | 12 ------------ model_prices_and_context_window.json | 12 ------------ 2 files changed, 24 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index e476d0c6b2a..d4c5b476af6 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -29397,18 +29397,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "together_ai/Qwen/Qwen/Qwen3.5-397B-A17B": { - "input_cost_per_token": 6e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 262144, - "mode": "chat", - "output_cost_per_token": 3.6e-06, - "source": "https://www.together.ai/models/Qwen/Qwen3.5-397B-A17B", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, "tts-1": { "input_cost_per_character": 1.5e-05, "litellm_provider": "openai", diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index cf55ddf6e25..4934f11d456 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -29632,18 +29632,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "together_ai/Qwen/Qwen/Qwen3.5-397B-A17B": { - "input_cost_per_token": 6e-07, - "litellm_provider": "together_ai", - "max_input_tokens": 262144, - "mode": "chat", - "output_cost_per_token": 3.6e-06, - "source": "https://www.together.ai/models/Qwen/Qwen3.5-397B-A17B", - "supports_function_calling": true, - "supports_parallel_function_calling": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, "tts-1": { "input_cost_per_character": 1.5e-05, "litellm_provider": "openai", From 6d535e56395782de8841497aa228729e16354872 Mon Sep 17 00:00:00 2001 From: Harshit28j Date: Tue, 3 Mar 2026 20:14:18 +0530 Subject: [PATCH 36/69] fix(proxy): allow custom auth routes to bypass route authorization checks Custom user-added routes (e.g. /ldap/ngs/ready) used with Depends(user_api_key_auth) were being rejected as admin-only after _run_post_custom_auth_checks was introduced in commit 14badde13c. The route authorization check in common_checks is designed for LiteLLM's own management routes. Custom auth flows that add their own routes should be trusted since the custom auth function already validated the request. Budget and expiry checks still run. Add skip_route_check parameter to common_checks() and pass skip_route_check=True from _run_post_custom_auth_checks() to skip route authorization while preserving budget/team/model checks. Regression test added: test_common_checks_skip_route_check_for_custom_auth Co-Authored-By: Claude Haiku 4.5 --- litellm/proxy/auth/auth_checks.py | 28 +++++----- litellm/proxy/auth/user_api_key_auth.py | 1 + .../proxy/auth/test_auth_checks.py | 52 +++++++++++++++++++ 3 files changed, 69 insertions(+), 12 deletions(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index ef6b0ac462c..91ac58215ab 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -234,6 +234,7 @@ async def common_checks( request: Request, skip_budget_checks: bool = False, project_object: Optional[LiteLLM_ProjectTableCachedObj] = None, + skip_route_check: bool = False, ) -> bool: """ Common checks across jwt + key-based auth. @@ -453,18 +454,21 @@ async def common_checks( user_object=user_object, route=route, request_body=request_body ) - token_team = getattr(valid_token, "team_id", None) - token_type: Literal["ui", "api"] = ( - "ui" if token_team is not None and token_team == "litellm-dashboard" else "api" - ) - _is_route_allowed = _is_allowed_route( - route=route, - token_type=token_type, - user_obj=user_object, - request=request, - request_data=request_body, - valid_token=valid_token, - ) + if not skip_route_check: + token_team = getattr(valid_token, "team_id", None) + token_type: Literal["ui", "api"] = ( + "ui" + if token_team is not None and token_team == "litellm-dashboard" + else "api" + ) + _is_route_allowed = _is_allowed_route( + route=route, + token_type=token_type, + user_obj=user_object, + request=request, + request_data=request_body, + valid_token=valid_token, + ) # 11. [OPTIONAL] Vector store checks - is the object allowed to access the vector store await vector_store_access_check( diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index a2705ceb7da..1d575fb5131 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -1766,6 +1766,7 @@ async def _run_post_custom_auth_checks( valid_token=valid_token, skip_budget_checks=False, project_object=_project_obj, + skip_route_check=True, ) return valid_token diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index 4cdca2d0617..501c2285d1e 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -1519,3 +1519,55 @@ async def test_get_fuzzy_user_object_case_insensitive_email(): assert call_args.kwargs["where"]["user_email"]["equals"] == "test@example.com" assert call_args.kwargs["where"]["user_email"]["mode"] == "insensitive" assert call_args.kwargs["include"] == {"organization_memberships": True} + + +@pytest.mark.asyncio +async def test_common_checks_skip_route_check_for_custom_auth(): + """ + Test that custom routes (e.g. /ldap/ngs/ready) pass common_checks when + skip_route_check=True, which is the case for custom auth flows. + + Regression test for: custom user-added routes being rejected as admin-only + after _run_post_custom_auth_checks was introduced. + """ + from fastapi import Request + + from litellm.proxy.auth.auth_checks import common_checks + + mock_request = MagicMock(spec=Request) + valid_token = UserAPIKeyAuth(token="test-token") + + # Without skip_route_check, a custom route with unknown user should fail + with pytest.raises(Exception): + await common_checks( + request_body={}, + team_object=None, + user_object=None, + end_user_object=None, + global_proxy_spend=None, + general_settings={}, + route="/ldap/ngs/ready", + llm_router=None, + proxy_logging_obj=MagicMock(), + valid_token=valid_token, + request=mock_request, + skip_route_check=False, + ) + + # With skip_route_check=True (custom auth path), the same route should pass + result = await common_checks( + request_body={}, + team_object=None, + user_object=None, + end_user_object=None, + global_proxy_spend=None, + general_settings={}, + route="/ldap/ngs/ready", + llm_router=None, + proxy_logging_obj=MagicMock(), + valid_token=valid_token, + request=mock_request, + skip_route_check=True, + ) + + assert result is True From 75518c3ca73626b457a7697fea456a0cabc1a341 Mon Sep 17 00:00:00 2001 From: Chesars Date: Tue, 3 Mar 2026 12:03:40 -0300 Subject: [PATCH 37/69] feat(models): add zai/glm-5 and zai/glm-5-code to model cost map Add native ZhipuAI GLM-5 and GLM-5-Code model entries with pricing from docs.z.ai/guides/overview/pricing. --- model_prices_and_context_window.json | 30 ++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 4c0694db371..ac0e9dfaa7a 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -33811,6 +33811,36 @@ "supports_tool_choice": true, "source": "https://aws.amazon.com/bedrock/pricing/" }, + "zai/glm-5": { + "cache_creation_input_token_cost": 0, + "cache_read_input_token_cost": 2e-07, + "input_cost_per_token": 1e-06, + "output_cost_per_token": 3.2e-06, + "litellm_provider": "zai", + "max_input_tokens": 200000, + "max_output_tokens": 128000, + "mode": "chat", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "source": "https://docs.z.ai/guides/overview/pricing" + }, + "zai/glm-5-code": { + "cache_creation_input_token_cost": 0, + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 1.2e-06, + "output_cost_per_token": 5e-06, + "litellm_provider": "zai", + "max_input_tokens": 200000, + "max_output_tokens": 128000, + "mode": "chat", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "source": "https://docs.z.ai/guides/overview/pricing" + }, "zai/glm-4.7": { "cache_creation_input_token_cost": 0, "cache_read_input_token_cost": 1.1e-07, From c3fe4634b62599cea71856d351571a681f3a8e12 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 3 Mar 2026 22:06:16 +0530 Subject: [PATCH 38/69] Add correct pricing for gemini 3.1 flash lite --- ...odel_prices_and_context_window_backup.json | 33 ++++++++++++------- model_prices_and_context_window.json | 33 ++++++++++++------- 2 files changed, 42 insertions(+), 24 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 42b4f0f7762..8f5fd26e3e8 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -14335,17 +14335,18 @@ "supports_web_search": true }, "gemini-3.1-flash-lite-preview": { - "cache_read_input_token_cost": 2.5e-09, - "input_cost_per_audio_token": 2.5e-07, + "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_per_audio_token": 5e-08, + "input_cost_per_audio_token": 5e-07, "input_cost_per_token": 2.5e-07, "litellm_provider": "vertex_ai-language-models", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, - "max_output_tokens": 65535, + "max_output_tokens": 65536, "max_pdf_size_mb": 30, - "max_tokens": 65535, + "max_tokens": 65536, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", @@ -14368,6 +14369,8 @@ ], "supports_audio_input": true, "supports_audio_output": false, + "supports_code_execution": true, + "supports_file_search": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, @@ -17138,17 +17141,18 @@ "supports_service_tier": true }, "gemini/gemini-3.1-flash-lite-preview": { - "cache_read_input_token_cost": 2.5e-09, - "input_cost_per_audio_token": 2.5e-07, + "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_per_audio_token": 5e-08, + "input_cost_per_audio_token": 5e-07, "input_cost_per_token": 2.5e-07, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, - "max_output_tokens": 65535, + "max_output_tokens": 65536, "max_pdf_size_mb": 30, - "max_tokens": 65535, + "max_tokens": 65536, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", @@ -17172,6 +17176,8 @@ ], "supports_audio_input": true, "supports_audio_output": false, + "supports_code_execution": true, + "supports_file_search": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, @@ -32400,17 +32406,18 @@ "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models" }, "vertex_ai/gemini-3.1-flash-lite-preview": { - "cache_read_input_token_cost": 2.5e-09, - "input_cost_per_audio_token": 2.5e-07, + "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_per_audio_token": 5e-08, + "input_cost_per_audio_token": 5e-07, "input_cost_per_token": 2.5e-07, "litellm_provider": "vertex_ai-language-models", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, - "max_output_tokens": 65535, + "max_output_tokens": 65536, "max_pdf_size_mb": 30, - "max_tokens": 65535, + "max_tokens": 65536, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", @@ -32433,6 +32440,8 @@ ], "supports_audio_input": true, "supports_audio_output": false, + "supports_code_execution": true, + "supports_file_search": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 42b4f0f7762..8f5fd26e3e8 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -14335,17 +14335,18 @@ "supports_web_search": true }, "gemini-3.1-flash-lite-preview": { - "cache_read_input_token_cost": 2.5e-09, - "input_cost_per_audio_token": 2.5e-07, + "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_per_audio_token": 5e-08, + "input_cost_per_audio_token": 5e-07, "input_cost_per_token": 2.5e-07, "litellm_provider": "vertex_ai-language-models", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, - "max_output_tokens": 65535, + "max_output_tokens": 65536, "max_pdf_size_mb": 30, - "max_tokens": 65535, + "max_tokens": 65536, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", @@ -14368,6 +14369,8 @@ ], "supports_audio_input": true, "supports_audio_output": false, + "supports_code_execution": true, + "supports_file_search": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, @@ -17138,17 +17141,18 @@ "supports_service_tier": true }, "gemini/gemini-3.1-flash-lite-preview": { - "cache_read_input_token_cost": 2.5e-09, - "input_cost_per_audio_token": 2.5e-07, + "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_per_audio_token": 5e-08, + "input_cost_per_audio_token": 5e-07, "input_cost_per_token": 2.5e-07, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, - "max_output_tokens": 65535, + "max_output_tokens": 65536, "max_pdf_size_mb": 30, - "max_tokens": 65535, + "max_tokens": 65536, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", @@ -17172,6 +17176,8 @@ ], "supports_audio_input": true, "supports_audio_output": false, + "supports_code_execution": true, + "supports_file_search": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, @@ -32400,17 +32406,18 @@ "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models" }, "vertex_ai/gemini-3.1-flash-lite-preview": { - "cache_read_input_token_cost": 2.5e-09, - "input_cost_per_audio_token": 2.5e-07, + "cache_read_input_token_cost": 2.5e-08, + "cache_read_input_token_cost_per_audio_token": 5e-08, + "input_cost_per_audio_token": 5e-07, "input_cost_per_token": 2.5e-07, "litellm_provider": "vertex_ai-language-models", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, - "max_output_tokens": 65535, + "max_output_tokens": 65536, "max_pdf_size_mb": 30, - "max_tokens": 65535, + "max_tokens": 65536, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", @@ -32433,6 +32440,8 @@ ], "supports_audio_input": true, "supports_audio_output": false, + "supports_code_execution": true, + "supports_file_search": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, From 9d06106af077b0996c154c43c014b5db0b509094 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 3 Mar 2026 22:22:57 +0530 Subject: [PATCH 39/69] Fix gemini-3.1-flash-lite-preview for streaming --- litellm/model_prices_and_context_window_backup.json | 3 ++- model_prices_and_context_window.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 8f5fd26e3e8..5de764c5cec 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -14382,7 +14382,8 @@ "supports_url_context": true, "supports_video_input": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "supports_native_streaming": true }, "deep-research-pro-preview-12-2025": { "input_cost_per_image": 0.0011, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 8f5fd26e3e8..5de764c5cec 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -14382,7 +14382,8 @@ "supports_url_context": true, "supports_video_input": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "supports_native_streaming": true }, "deep-research-pro-preview-12-2025": { "input_cost_per_image": 0.0011, From 22e682b1e89c1c1795b45910b2cea988f1525124 Mon Sep 17 00:00:00 2001 From: Harshit28j Date: Tue, 3 Mar 2026 22:39:06 +0530 Subject: [PATCH 40/69] feat: guardrail-mode-default-list --- .../docs/proxy/guardrails/quick_start.md | 40 +++++++++++++++++++ litellm/integrations/custom_guardrail.py | 18 ++++++++- litellm/types/guardrails.py | 2 +- 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/docs/my-website/docs/proxy/guardrails/quick_start.md b/docs/my-website/docs/proxy/guardrails/quick_start.md index e5a90f74a8a..eb56c27f876 100644 --- a/docs/my-website/docs/proxy/guardrails/quick_start.md +++ b/docs/my-website/docs/proxy/guardrails/quick_start.md @@ -499,6 +499,11 @@ curl --location 'http://0.0.0.0:4000/chat/completions' \ Run guardrails based on the user-agent header. This is useful for running pre-call checks on OpenWebUI but only masking in logs for Claude CLI. +`default` can be a single mode string or a list of modes. + + + + ```yaml model_list: - model_name: gpt-3.5-turbo @@ -519,6 +524,32 @@ guardrails: default_on: true # run on every request ``` + + + +```yaml +model_list: + - model_name: gpt-3.5-turbo + litellm_params: + model: gpt-3.5-turbo + api_key: os.environ/OPENAI_API_KEY + +guardrails: + - guardrail_name: "guardrails_ai-guard" + litellm_params: + guardrail: guardrails_ai + guard_name: "pii_detect" + mode: + tags: + "User-Agent: claude-cli": "logging_only" + default: ["pre_call", "post_call"] # Run on both pre and post call when no tags match + api_base: os.environ/GUARDRAILS_AI_API_BASE + default_on: true +``` + + + + ### ✨ Model-level Guardrails @@ -640,13 +671,22 @@ guardrails: Mode Specification +`default` accepts either a single string or a list of strings. + ```python from litellm.types.guardrails import Mode +# Single default mode mode = Mode( tags={"User-Agent: claude-cli": "logging_only"}, default="logging_only" ) + +# Multiple default modes +mode = Mode( + tags={"User-Agent: claude-cli": "logging_only"}, + default=["pre_call", "post_call"] +) ``` ### `guardrails` Request Parameter diff --git a/litellm/integrations/custom_guardrail.py b/litellm/integrations/custom_guardrail.py index 5d11fd68475..3fd179bb411 100644 --- a/litellm/integrations/custom_guardrail.py +++ b/litellm/integrations/custom_guardrail.py @@ -235,8 +235,13 @@ class CustomGuardrail(CustomLogger): list(event_hook.tags.values()), supported_event_hooks ) if event_hook.default: + default_list = ( + event_hook.default + if isinstance(event_hook.default, list) + else [event_hook.default] + ) _validate_event_hook_list_is_in_supported_event_hooks( - [event_hook.default], supported_event_hooks + default_list, supported_event_hooks ) elif isinstance(event_hook, GuardrailEventHooks): if event_hook not in supported_event_hooks: @@ -461,7 +466,16 @@ class CustomGuardrail(CustomLogger): if isinstance(self.event_hook, list): return event_type.value in self.event_hook if isinstance(self.event_hook, Mode): - return event_type.value in self.event_hook.tags.values() + if event_type.value in self.event_hook.tags.values(): + return True + if self.event_hook.default: + default_list = ( + self.event_hook.default + if isinstance(self.event_hook.default, list) + else [self.event_hook.default] + ) + return event_type.value in default_list + return False return self.event_hook == event_type.value def get_guardrail_dynamic_request_body_params(self, request_data: dict) -> dict: diff --git a/litellm/types/guardrails.py b/litellm/types/guardrails.py index a68c4e2f762..dc95ed3314a 100644 --- a/litellm/types/guardrails.py +++ b/litellm/types/guardrails.py @@ -718,7 +718,7 @@ class BaseLitellmParams( class Mode(BaseModel): tags: Dict[str, str] = Field(description="Tags for the guardrail mode") - default: Optional[str] = Field( + default: Optional[Union[str, List[str]]] = Field( default=None, description="Default mode when no tags match" ) From 76e3dba0f88929a82c673b7c586cc4aba8b8f34a Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 3 Mar 2026 09:41:45 -0800 Subject: [PATCH 41/69] fix mcp server created_at and updated_at timestamps being overwritten with current time - Add created_at field to MCPServer type (was missing) - Map created_at from LiteLLM_MCPServerTable in build_mcp_server_from_table() - Use server.created_at and server.updated_at instead of datetime.now() in _build_mcp_server_table() and health check table builder - Add regression tests to verify timestamps are preserved through round-trip conversions Co-Authored-By: Claude Sonnet 4.6 --- .../mcp_server/mcp_server_manager.py | 11 ++- .../types/mcp_server/mcp_server_manager.py | 1 + .../mcp_server/test_mcp_server_manager.py | 86 +++++++++++++++++++ 3 files changed, 92 insertions(+), 6 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index da29c7804a1..b7c013e9f20 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -642,6 +642,7 @@ class MCPServerManager: available_on_public_internet=bool( getattr(mcp_server, "available_on_public_internet", True) ), + created_at=getattr(mcp_server, "created_at", None), updated_at=getattr(mcp_server, "updated_at", None), ) return new_server @@ -2540,8 +2541,8 @@ class MCPServerManager: url=server.url, transport=server.transport, auth_type=server.auth_type, - created_at=datetime.now(), - updated_at=datetime.now(), + created_at=server.created_at, + updated_at=server.updated_at, teams=[], mcp_access_groups=server.access_groups or [], allowed_tools=server.allowed_tools or [], @@ -2620,8 +2621,6 @@ class MCPServerManager: return list_mcp_servers def _build_mcp_server_table(self, server: MCPServer) -> LiteLLM_MCPServerTable: - from datetime import datetime - return LiteLLM_MCPServerTable( server_id=server.server_id, server_name=server.server_name, @@ -2633,8 +2632,8 @@ class MCPServerManager: spec_path=server.spec_path, transport=server.transport, auth_type=server.auth_type, - created_at=datetime.now(), - updated_at=datetime.now(), + created_at=server.created_at, + updated_at=server.updated_at, teams=[], mcp_access_groups=server.access_groups or [], allowed_tools=server.allowed_tools or [], diff --git a/litellm/types/mcp_server/mcp_server_manager.py b/litellm/types/mcp_server/mcp_server_manager.py index 69b34a25a21..cabac6b9d51 100644 --- a/litellm/types/mcp_server/mcp_server_manager.py +++ b/litellm/types/mcp_server/mcp_server_manager.py @@ -53,6 +53,7 @@ class MCPServer(BaseModel): access_groups: Optional[List[str]] = None allow_all_keys: bool = False available_on_public_internet: bool = True + created_at: Optional[datetime] = None updated_at: Optional[datetime] = None model_config = ConfigDict(arbitrary_types_allowed=True) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index c105052479d..acc76221cbb 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -2307,5 +2307,91 @@ class TestMCPServerManager: assert resolved_server.server_name == "test_server" # server_name matches +class TestMCPServerTimestamps: + """Regression tests: created_at/updated_at must be preserved, not overwritten with datetime.now().""" + + @pytest.mark.asyncio + async def test_build_mcp_server_from_table_preserves_timestamps(self): + """build_mcp_server_from_table must carry created_at and updated_at into MCPServer.""" + manager = MCPServerManager() + + created = datetime(2024, 1, 15, 10, 0, 0) + updated = datetime(2024, 6, 20, 12, 30, 0) + + table_record = LiteLLM_MCPServerTable( + server_id="ts-server-1", + server_name="ts_server", + url="https://example.com/mcp", + transport=MCPTransport.http, + created_at=created, + updated_at=updated, + ) + + mcp_server = await manager.build_mcp_server_from_table(table_record) + + assert mcp_server.created_at == created + assert mcp_server.updated_at == updated + + def test_build_mcp_server_table_preserves_timestamps(self): + """_build_mcp_server_table must use the MCPServer's stored timestamps, not datetime.now().""" + manager = MCPServerManager() + + created = datetime(2024, 1, 15, 10, 0, 0) + updated = datetime(2024, 6, 20, 12, 30, 0) + + server = MCPServer( + server_id="ts-server-2", + name="ts_server", + url="https://example.com/mcp", + transport=MCPTransport.http, + created_at=created, + updated_at=updated, + ) + + table = manager._build_mcp_server_table(server) + + assert table.created_at == created + assert table.updated_at == updated + + def test_build_mcp_server_table_none_timestamps_when_not_set(self): + """_build_mcp_server_table must return None timestamps when not set on MCPServer.""" + manager = MCPServerManager() + + server = MCPServer( + server_id="ts-server-3", + name="ts_server", + url="https://example.com/mcp", + transport=MCPTransport.http, + ) + + table = manager._build_mcp_server_table(server) + + assert table.created_at is None + assert table.updated_at is None + + @pytest.mark.asyncio + async def test_round_trip_timestamps_preserved(self): + """Timestamps survive the full round-trip: LiteLLM_MCPServerTable -> MCPServer -> LiteLLM_MCPServerTable.""" + manager = MCPServerManager() + + created = datetime(2023, 3, 10, 8, 0, 0) + updated = datetime(2023, 9, 5, 16, 45, 0) + + table_record = LiteLLM_MCPServerTable( + server_id="ts-server-4", + server_name="ts_server_rt", + url="https://example.com/mcp", + transport=MCPTransport.http, + created_at=created, + updated_at=updated, + ) + + mcp_server = await manager.build_mcp_server_from_table(table_record) + rebuilt_table = manager._build_mcp_server_table(mcp_server) + + assert rebuilt_table.created_at == created + assert rebuilt_table.updated_at == updated + + if __name__ == "__main__": pytest.main([__file__]) From 224c61711948fd1508b59083542f022d1f37b99c Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 3 Mar 2026 10:12:08 -0800 Subject: [PATCH 42/69] Fix spend log cleanup: lock tracking, integer retention, skip log level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Only release distributed lock in finally if it was actually acquired; prevents spurious Redis release_lock calls on early returns - Treat bare integer maximum_spend_logs_retention_period as days (e.g. 3 → "3d") instead of silently failing with a ValueError - Elevate "Skipping cleanup" log from info to error so misconfigured retention settings are visible without verbose logging - Add tests for all three fixes Co-Authored-By: Claude Sonnet 4.6 --- .../db_transaction_queue/spend_log_cleanup.py | 13 ++-- .../proxy/test_spend_log_cleanup.py | 59 +++++++++++++++++++ 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/db/db_transaction_queue/spend_log_cleanup.py b/litellm/proxy/db/db_transaction_queue/spend_log_cleanup.py index 8c59c79ff0a..6db7d6dd43c 100644 --- a/litellm/proxy/db/db_transaction_queue/spend_log_cleanup.py +++ b/litellm/proxy/db/db_transaction_queue/spend_log_cleanup.py @@ -49,7 +49,11 @@ class SpendLogCleanup: try: if isinstance(retention_setting, int): - retention_setting = str(retention_setting) + verbose_proxy_logger.warning( + f"maximum_spend_logs_retention_period is an integer ({retention_setting}); treating as days. " + "Use a string like '3d' to be explicit." + ) + retention_setting = f"{retention_setting}d" self.retention_seconds = duration_in_seconds(retention_setting) verbose_proxy_logger.info( f"Retention period set to {self.retention_seconds} seconds" @@ -112,11 +116,12 @@ class SpendLogCleanup: If pod_lock_manager is available, ensures only one pod runs cleanup. If no pod_lock_manager, runs cleanup without distributed locking. """ + lock_acquired = False try: verbose_proxy_logger.info(f"Cleanup job triggered at {datetime.now()}") if not self._should_delete_spend_logs(): - verbose_proxy_logger.info( + verbose_proxy_logger.error( "Skipping cleanup — invalid or missing retention setting." ) return @@ -155,8 +160,8 @@ class SpendLogCleanup: verbose_proxy_logger.error(f"Error during cleanup: {str(e)}") return # Return after error handling finally: - # Always release the lock if we have a pod lock manager - if self.pod_lock_manager and self.pod_lock_manager.redis_cache: + # Only release the lock if it was actually acquired + if lock_acquired and self.pod_lock_manager and self.pod_lock_manager.redis_cache: await self.pod_lock_manager.release_lock( cronjob_id=SPEND_LOG_CLEANUP_JOB_NAME ) diff --git a/tests/test_litellm/proxy/test_spend_log_cleanup.py b/tests/test_litellm/proxy/test_spend_log_cleanup.py index c1fa3ad0c43..3a01437908d 100644 --- a/tests/test_litellm/proxy/test_spend_log_cleanup.py +++ b/tests/test_litellm/proxy/test_spend_log_cleanup.py @@ -233,6 +233,65 @@ async def test_cleanup_old_spend_logs_no_retention_period(): mock_prisma_client.db.execute_raw.assert_not_called() +@pytest.mark.asyncio +async def test_lock_not_released_when_not_acquired(): + """ + Lock release should be skipped when _should_delete_spend_logs returns False + before the lock is ever acquired. + """ + mock_prisma_client = MagicMock() + mock_prisma_client.db.execute_raw = AsyncMock() + + mock_redis_cache = MagicMock() + mock_pod_lock_manager = MagicMock() + mock_pod_lock_manager.redis_cache = mock_redis_cache + mock_pod_lock_manager.acquire_lock = AsyncMock(return_value=True) + mock_pod_lock_manager.release_lock = AsyncMock() + + # No retention setting → _should_delete_spend_logs() returns False before lock is acquired + cleaner = SpendLogCleanup(general_settings={}) + cleaner.pod_lock_manager = mock_pod_lock_manager + + await cleaner.cleanup_old_spend_logs(mock_prisma_client) + + mock_pod_lock_manager.acquire_lock.assert_not_called() + mock_pod_lock_manager.release_lock.assert_not_called() + + +@pytest.mark.asyncio +async def test_integer_retention_treated_as_days(): + """ + An integer value for maximum_spend_logs_retention_period should be treated + as days (e.g., 3 → '3d' → 259200 seconds). + """ + cleaner = SpendLogCleanup( + general_settings={"maximum_spend_logs_retention_period": 3} + ) + result = cleaner._should_delete_spend_logs() + assert result is True + assert cleaner.retention_seconds == 3 * 86400 # 3 days in seconds + + +def test_string_retention_still_works(): + """ + String values like '3d', '24h', '3600s' should continue to parse correctly. + """ + cases = [ + ("3d", 3 * 86400), + ("24h", 24 * 3600), + ("3600s", 3600), + ("2w", 2 * 604800), + ] + for setting, expected_seconds in cases: + cleaner = SpendLogCleanup( + general_settings={"maximum_spend_logs_retention_period": setting} + ) + assert cleaner._should_delete_spend_logs() is True, f"Failed for {setting}" + assert cleaner.retention_seconds == expected_seconds, ( + f"Expected {expected_seconds} for {setting}, got {cleaner.retention_seconds}" + ) + + def test_cleanup_batch_size_env_var(monkeypatch): """Ensure batch size is configurable via environment variable""" import importlib From a1ba6c9fa643c04492d274b2a664d2de88ce1603 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 3 Mar 2026 10:21:31 -0800 Subject: [PATCH 43/69] Fix log levels: info for unconfigured, warning for misconfigured Suppress noisy error log fired every cron tick when spend log cleanup is simply not configured. _should_delete_spend_logs already logs the specific reason at the right level (info for None, warning for invalid value), so the redundant blanket error log in cleanup_old_spend_logs is removed. Co-Authored-By: Claude Sonnet 4.6 --- litellm/proxy/db/db_transaction_queue/spend_log_cleanup.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/litellm/proxy/db/db_transaction_queue/spend_log_cleanup.py b/litellm/proxy/db/db_transaction_queue/spend_log_cleanup.py index 6db7d6dd43c..a538e411b68 100644 --- a/litellm/proxy/db/db_transaction_queue/spend_log_cleanup.py +++ b/litellm/proxy/db/db_transaction_queue/spend_log_cleanup.py @@ -60,7 +60,7 @@ class SpendLogCleanup: ) return True except ValueError as e: - verbose_proxy_logger.error( + verbose_proxy_logger.warning( f"Invalid maximum_spend_logs_retention_period value: {retention_setting}, error: {str(e)}" ) return False @@ -121,9 +121,6 @@ class SpendLogCleanup: verbose_proxy_logger.info(f"Cleanup job triggered at {datetime.now()}") if not self._should_delete_spend_logs(): - verbose_proxy_logger.error( - "Skipping cleanup — invalid or missing retention setting." - ) return if self.retention_seconds is None: From 43cec8c980abf699b3664a239393f408b9ca2f90 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Tue, 3 Mar 2026 10:46:45 -0800 Subject: [PATCH 44/69] feat(batches): support output_expires_after passthrough --- litellm/batches/main.py | 5 ++ litellm/types/llms/openai.py | 1 + tests/test_litellm/proxy/test_batch_expiry.py | 74 +++++++++++++++++++ 3 files changed, 80 insertions(+) create mode 100644 tests/test_litellm/proxy/test_batch_expiry.py diff --git a/litellm/batches/main.py b/litellm/batches/main.py index 9553d2c5246..e73f73d2f33 100644 --- a/litellm/batches/main.py +++ b/litellm/batches/main.py @@ -112,6 +112,7 @@ async def acreate_batch( metadata: Optional[Dict[str, str]] = None, extra_headers: Optional[Dict[str, str]] = None, extra_body: Optional[Dict[str, str]] = None, + output_expires_after: Optional[Dict[str, Any]] = None, **kwargs, ) -> LiteLLMBatch: """ @@ -133,6 +134,7 @@ async def acreate_batch( metadata, extra_headers, extra_body, + output_expires_after, **kwargs, ) @@ -160,6 +162,7 @@ def create_batch( metadata: Optional[Dict[str, str]] = None, extra_headers: Optional[Dict[str, str]] = None, extra_body: Optional[Dict[str, str]] = None, + output_expires_after: Optional[Dict[str, Any]] = None, **kwargs, ) -> Union[LiteLLMBatch, Coroutine[Any, Any, LiteLLMBatch]]: """ @@ -215,6 +218,8 @@ def create_batch( extra_headers=extra_headers, extra_body=extra_body, ) + if output_expires_after is not None: + _create_batch_request["output_expires_after"] = output_expires_after if model is not None: provider_config = ProviderConfigManager.get_provider_batches_config( model=model, diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index d06d879dad1..c5d610e639b 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -424,6 +424,7 @@ class CreateBatchRequest(TypedDict, total=False): endpoint: Literal["/v1/chat/completions", "/v1/embeddings", "/v1/completions"] input_file_id: str metadata: Optional[Dict[str, str]] + output_expires_after: Optional[FileExpiresAfter] extra_headers: Optional[Dict[str, str]] extra_body: Optional[Dict[str, str]] timeout: Optional[float] diff --git a/tests/test_litellm/proxy/test_batch_expiry.py b/tests/test_litellm/proxy/test_batch_expiry.py new file mode 100644 index 00000000000..25b1631792e --- /dev/null +++ b/tests/test_litellm/proxy/test_batch_expiry.py @@ -0,0 +1,74 @@ +""" +Tests for batch output_expires_after passthrough and team-level expiry enforcement. +""" + +import os +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +sys.path.insert( + 0, os.path.abspath("../../..") +) # Adds the parent directory to the system path + +import litellm +from litellm.types.llms.openai import CreateBatchRequest + + +class TestCreateBatchOutputExpiresAfterPassthrough: + """Verify output_expires_after flows through create_batch to the provider.""" + + def test_output_expires_after_included_in_request(self): + """When output_expires_after is provided, it reaches the openai batches instance.""" + captured = {} + + original_create = None + + def capturing_create(**kwargs): + captured.update(kwargs) + mock_response = MagicMock() + mock_response.id = "batch_123" + return mock_response + + with patch( + "litellm.batches.main.openai_batches_instance" + ) as mock_instance: + mock_instance.create_batch.side_effect = capturing_create + litellm.create_batch( + completion_window="24h", + endpoint="/v1/chat/completions", + input_file_id="file-abc123", + output_expires_after={"anchor": "created_at", "seconds": 86400}, + custom_llm_provider="openai", + ) + + create_batch_data = captured["create_batch_data"] + assert create_batch_data["output_expires_after"] == { + "anchor": "created_at", + "seconds": 86400, + } + + def test_output_expires_after_absent_when_not_provided(self): + """Backward compat: output_expires_after not in request when omitted.""" + captured = {} + + def capturing_create(**kwargs): + captured.update(kwargs) + mock_response = MagicMock() + mock_response.id = "batch_123" + return mock_response + + with patch( + "litellm.batches.main.openai_batches_instance" + ) as mock_instance: + mock_instance.create_batch.side_effect = capturing_create + litellm.create_batch( + completion_window="24h", + endpoint="/v1/chat/completions", + input_file_id="file-abc123", + custom_llm_provider="openai", + ) + + create_batch_data = captured["create_batch_data"] + assert "output_expires_after" not in create_batch_data From 3d15bcdb115731cd0b4dd93f19be880e2128f1bb Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Tue, 3 Mar 2026 10:58:31 -0800 Subject: [PATCH 45/69] feat(proxy): add team-level batch output expiry enforcement --- litellm/proxy/_types.py | 6 + litellm/proxy/batches_endpoints/endpoints.py | 9 + tests/test_litellm/proxy/test_batch_expiry.py | 188 +++++++++++++----- 3 files changed, 153 insertions(+), 50 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index cbf683d226e..5122c64ea64 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1551,6 +1551,8 @@ class NewTeamRequest(TeamBase): ] = None # allow user to set TPM limit for all team members team_member_key_duration: Optional[str] = None # e.g. "1d", "1w", "1m" allowed_vector_store_indexes: Optional[List[AllowedVectorStoreIndexItem]] = None + enforced_batch_output_expires_after: Optional[dict] = None + enforced_file_expires_after: Optional[dict] = None model_config = ConfigDict(protected_namespaces=()) @@ -1606,6 +1608,8 @@ class UpdateTeamRequest(LiteLLMPydanticObjectBase): model_rpm_limit: Optional[Dict[str, int]] = None model_tpm_limit: Optional[Dict[str, int]] = None allowed_vector_store_indexes: Optional[List[AllowedVectorStoreIndexItem]] = None + enforced_batch_output_expires_after: Optional[dict] = None + enforced_file_expires_after: Optional[dict] = None router_settings: Optional[dict] = None access_group_ids: Optional[List[str]] = None @@ -3783,6 +3787,8 @@ LiteLLM_ManagementEndpoint_MetadataFields = [ "temp_budget_increase", "temp_budget_expiry", "allowed_vector_store_indexes", + "enforced_batch_output_expires_after", + "enforced_file_expires_after", ] LiteLLM_ManagementEndpoint_MetadataFields_Premium = [ diff --git a/litellm/proxy/batches_endpoints/endpoints.py b/litellm/proxy/batches_endpoints/endpoints.py index 1c9ba6cb248..60905243369 100644 --- a/litellm/proxy/batches_endpoints/endpoints.py +++ b/litellm/proxy/batches_endpoints/endpoints.py @@ -118,6 +118,15 @@ async def create_batch( # noqa: PLR0915 or "openai" ) _create_batch_data = LiteLLMBatchCreateRequest(**data) + + # Apply team-level batch output expiry enforcement + team_metadata = user_api_key_dict.team_metadata or {} + enforced_batch_expiry = team_metadata.get( + "enforced_batch_output_expires_after" + ) + if enforced_batch_expiry is not None: + _create_batch_data["output_expires_after"] = enforced_batch_expiry + input_file_id = _create_batch_data.get("input_file_id", None) unified_file_id: Union[str, Literal[False]] = False diff --git a/tests/test_litellm/proxy/test_batch_expiry.py b/tests/test_litellm/proxy/test_batch_expiry.py index 25b1631792e..1f54f190c63 100644 --- a/tests/test_litellm/proxy/test_batch_expiry.py +++ b/tests/test_litellm/proxy/test_batch_expiry.py @@ -4,7 +4,7 @@ Tests for batch output_expires_after passthrough and team-level expiry enforceme import os import sys -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock, patch import pytest @@ -13,62 +13,150 @@ sys.path.insert( ) # Adds the parent directory to the system path import litellm -from litellm.types.llms.openai import CreateBatchRequest +from litellm.caching.caching import DualCache +from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.proxy_server import app +from litellm.proxy.utils import ProxyLogging +from litellm.router import Router +from litellm.types.utils import LiteLLMBatch + +from fastapi.testclient import TestClient + +client = TestClient(app) + +TEAM_EXPIRY = {"anchor": "created_at", "seconds": 3600} +CALLER_EXPIRY = {"anchor": "created_at", "seconds": 86400} -class TestCreateBatchOutputExpiresAfterPassthrough: - """Verify output_expires_after flows through create_batch to the provider.""" +@pytest.fixture +def llm_router() -> Router: + return Router( + model_list=[ + { + "model_name": "gpt-3.5-turbo", + "litellm_params": { + "model": "openai/gpt-3.5-turbo", + "api_key": "test-key", + }, + "model_info": {"id": "gpt-3.5-turbo-id"}, + }, + ] + ) - def test_output_expires_after_included_in_request(self): - """When output_expires_after is provided, it reaches the openai batches instance.""" - captured = {} - original_create = None +def _setup_proxy(monkeypatch, llm_router: Router): + proxy_logging_obj = ProxyLogging( + user_api_key_cache=DualCache(default_in_memory_ttl=1) + ) + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", llm_router) + monkeypatch.setattr( + "litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging_obj + ) - def capturing_create(**kwargs): - captured.update(kwargs) - mock_response = MagicMock() - mock_response.id = "batch_123" - return mock_response - with patch( - "litellm.batches.main.openai_batches_instance" - ) as mock_instance: - mock_instance.create_batch.side_effect = capturing_create - litellm.create_batch( - completion_window="24h", - endpoint="/v1/chat/completions", - input_file_id="file-abc123", - output_expires_after={"anchor": "created_at", "seconds": 86400}, - custom_llm_provider="openai", +def _make_batch_response() -> LiteLLMBatch: + return LiteLLMBatch( + id="batch_abc123", + completion_window="24h", + created_at=1234567890, + endpoint="/v1/chat/completions", + input_file_id="file-abc123", + object="batch", + status="validating", + ) + + +def test_output_expires_after_passthrough(): + """output_expires_after flows through create_batch to the provider.""" + captured = {} + + def capturing_create(**kwargs): + captured.update(kwargs) + mock_response = MagicMock() + mock_response.id = "batch_123" + return mock_response + + with patch("litellm.batches.main.openai_batches_instance") as mock_instance: + mock_instance.create_batch.side_effect = capturing_create + litellm.create_batch( + completion_window="24h", + endpoint="/v1/chat/completions", + input_file_id="file-abc123", + output_expires_after=CALLER_EXPIRY, + custom_llm_provider="openai", + ) + + assert captured["create_batch_data"]["output_expires_after"] == CALLER_EXPIRY + + +class TestBatchEndpointTeamOverride: + """Verify team-level enforced_batch_output_expires_after in the proxy endpoint.""" + + def _post_batch( + self, + monkeypatch, + llm_router: Router, + team_metadata: dict, + request_body: dict, + ) -> dict: + """POST /v1/batches with given team_metadata and body, return captured kwargs.""" + _setup_proxy(monkeypatch, llm_router) + + user_key = UserAPIKeyAuth( + api_key="test-key", + team_metadata=team_metadata, + ) + app.dependency_overrides[user_api_key_auth] = lambda: user_key + + captured_kwargs = {} + + async def mock_acreate_batch(**kwargs): + captured_kwargs.update(kwargs) + return _make_batch_response() + + monkeypatch.setattr(litellm, "acreate_batch", mock_acreate_batch) + + try: + response = client.post( + "/v1/batches", + json=request_body, + headers={"Authorization": "Bearer test-key"}, ) + assert response.status_code == 200 + finally: + app.dependency_overrides.clear() - create_batch_data = captured["create_batch_data"] - assert create_batch_data["output_expires_after"] == { - "anchor": "created_at", - "seconds": 86400, - } + return captured_kwargs - def test_output_expires_after_absent_when_not_provided(self): - """Backward compat: output_expires_after not in request when omitted.""" - captured = {} + def test_team_override_overrides_caller(self, monkeypatch, llm_router): + """Team enforcement wins over caller-provided value.""" + kwargs = self._post_batch( + monkeypatch, + llm_router, + team_metadata={ + "enforced_batch_output_expires_after": TEAM_EXPIRY, + }, + request_body={ + "input_file_id": "file-abc123", + "endpoint": "/v1/chat/completions", + "completion_window": "24h", + "output_expires_after": CALLER_EXPIRY, + }, + ) + assert kwargs["output_expires_after"] == TEAM_EXPIRY - def capturing_create(**kwargs): - captured.update(kwargs) - mock_response = MagicMock() - mock_response.id = "batch_123" - return mock_response - - with patch( - "litellm.batches.main.openai_batches_instance" - ) as mock_instance: - mock_instance.create_batch.side_effect = capturing_create - litellm.create_batch( - completion_window="24h", - endpoint="/v1/chat/completions", - input_file_id="file-abc123", - custom_llm_provider="openai", - ) - - create_batch_data = captured["create_batch_data"] - assert "output_expires_after" not in create_batch_data + def test_no_team_setting_preserves_caller(self, monkeypatch, llm_router): + """No team setting = caller value passes through.""" + kwargs = self._post_batch( + monkeypatch, + llm_router, + team_metadata={}, + request_body={ + "input_file_id": "file-abc123", + "endpoint": "/v1/chat/completions", + "completion_window": "24h", + "output_expires_after": CALLER_EXPIRY, + }, + ) + assert kwargs["output_expires_after"] == CALLER_EXPIRY From 08613b24cbbf0315249a305b743e43cc5e73411f Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Tue, 3 Mar 2026 11:03:14 -0800 Subject: [PATCH 46/69] feat(proxy): add team-level file expiry enforcement --- .../openai_files_endpoints/files_endpoints.py | 11 +- .../test_files_endpoint.py | 135 ++++++++++++++++++ 2 files changed, 145 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/openai_files_endpoints/files_endpoints.py b/litellm/proxy/openai_files_endpoints/files_endpoints.py index ec6e9733344..a641fb1c69f 100644 --- a/litellm/proxy/openai_files_endpoints/files_endpoints.py +++ b/litellm/proxy/openai_files_endpoints/files_endpoints.py @@ -454,8 +454,17 @@ async def create_file( # noqa: PLR0915 model=router_model, llm_router=llm_router ) + # Apply team-level file expiry enforcement + team_metadata = user_api_key_dict.team_metadata or {} + enforced_file_expiry = team_metadata.get("enforced_file_expires_after") + if enforced_file_expiry is not None: + expires_after = FileExpiresAfter( + anchor=enforced_file_expiry["anchor"], + seconds=enforced_file_expiry["seconds"], + ) + _create_file_request = CreateFileRequest( - file=file_data, + file=file_data, purpose=cast(CREATE_FILE_REQUESTS_PURPOSE, purpose), expires_after=expires_after, **data diff --git a/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py b/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py index fb063ef8ee7..0239c39e67f 100644 --- a/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py +++ b/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py @@ -1168,3 +1168,138 @@ def test_create_file_with_deep_nested_litellm_metadata( assert captured_litellm_metadata["config"]["database"]["port"] == "5432" assert "cache" in captured_litellm_metadata["config"] assert captured_litellm_metadata["config"]["cache"]["enabled"] == "true" + + +# --------------------------------------------------------------------------- +# Team-level enforced_file_expires_after tests +# --------------------------------------------------------------------------- + + +def _make_capturing_managed_files(): + """Create a DummyManagedFiles that captures the expires_after from the request.""" + from litellm.llms.base_llm.files.transformation import BaseFileEndpoints + + captured = {} + + class CapturingManagedFiles(BaseFileEndpoints): + async def acreate_file( + self, + llm_router, + create_file_request, + target_model_names_list, + litellm_parent_otel_span, + user_api_key_dict, + ): + if isinstance(create_file_request, dict): + captured["expires_after"] = create_file_request.get("expires_after") + else: + captured["expires_after"] = getattr( + create_file_request, "expires_after", None + ) + return OpenAIFileObject( + id="file-abc123", + object="file", + bytes=100, + created_at=1234567890, + filename="mydata.jsonl", + purpose="batch", + status="uploaded", + ) + + async def afile_retrieve(self, file_id, litellm_parent_otel_span, llm_router): + raise NotImplementedError + + async def afile_list(self, purpose, litellm_parent_otel_span): + raise NotImplementedError + + async def afile_delete( + self, file_id, litellm_parent_otel_span, llm_router, **data + ): + raise NotImplementedError + + async def afile_content( + self, file_id, litellm_parent_otel_span, llm_router, **data + ): + raise NotImplementedError + + return CapturingManagedFiles(), captured + + +def _post_file_with_team_metadata( + monkeypatch, + llm_router: Router, + team_metadata: dict, + form_data: dict, +): + """POST /v1/files with given team_metadata, return captured expires_after.""" + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + + proxy_logging_obj = ProxyLogging( + user_api_key_cache=DualCache(default_in_memory_ttl=1) + ) + dummy, captured = _make_capturing_managed_files() + proxy_logging_obj.proxy_hook_mapping["managed_files"] = dummy + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", llm_router) + monkeypatch.setattr( + "litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging_obj + ) + + user_key = UserAPIKeyAuth(api_key="test-key", team_metadata=team_metadata) + app.dependency_overrides[user_api_key_auth] = lambda: user_key + + test_file = ("mydata.jsonl", b'{"prompt": "Hello"}', "application/json") + try: + response = client.post( + "/v1/files", + files={"file": test_file}, + data=form_data, + headers={"Authorization": "Bearer test-key"}, + ) + assert response.status_code == 200 + finally: + app.dependency_overrides.clear() + + return captured["expires_after"] + + +def test_file_team_override_overrides_caller( + mocker: MockerFixture, monkeypatch, llm_router: Router +): + """Team enforced_file_expires_after wins over caller-provided value.""" + expires_after = _post_file_with_team_metadata( + monkeypatch, + llm_router, + team_metadata={ + "enforced_file_expires_after": { + "anchor": "created_at", + "seconds": 3600, + } + }, + form_data={ + "purpose": "batch", + "target_model_names": "gpt-3.5-turbo", + "expires_after[anchor]": "created_at", + "expires_after[seconds]": "86400", + }, + ) + assert expires_after["anchor"] == "created_at" + assert expires_after["seconds"] == 3600 + + +def test_file_no_team_setting_preserves_caller( + mocker: MockerFixture, monkeypatch, llm_router: Router +): + """No team setting = caller-provided expires_after passes through.""" + expires_after = _post_file_with_team_metadata( + monkeypatch, + llm_router, + team_metadata={}, + form_data={ + "purpose": "batch", + "target_model_names": "gpt-3.5-turbo", + "expires_after[anchor]": "created_at", + "expires_after[seconds]": "86400", + }, + ) + assert expires_after["anchor"] == "created_at" + assert expires_after["seconds"] == 86400 From d6614191096d2a9e06e7d9a36281ce71ad4170ab Mon Sep 17 00:00:00 2001 From: Harshit28j Date: Wed, 4 Mar 2026 01:50:28 +0530 Subject: [PATCH 47/69] fix: support list of modes in Mode.default for tag-based guardrails --- .../integrations/custom_guardrail.py | 37 ++++- litellm/integrations/custom_guardrail.py | 4 +- .../integrations/test_custom_guardrail.py | 130 +++++++++++++++++- 3 files changed, 158 insertions(+), 13 deletions(-) diff --git a/enterprise/litellm_enterprise/integrations/custom_guardrail.py b/enterprise/litellm_enterprise/integrations/custom_guardrail.py index b165d788f35..8ed3bfcac4c 100644 --- a/enterprise/litellm_enterprise/integrations/custom_guardrail.py +++ b/enterprise/litellm_enterprise/integrations/custom_guardrail.py @@ -10,10 +10,15 @@ class EnterpriseCustomGuardrailHelper: event_hook: Optional[ Union[GuardrailEventHooks, List[GuardrailEventHooks], Mode] ], + event_type: Optional[GuardrailEventHooks] = None, ) -> Optional[bool]: """ - Assumes check for event match is done in `should_run_guardrail` - Returns True if the guardrail should be run by tag + Returns True if the guardrail should be run for this request and event_type. + + Logic: + - If a request tag matches a Mode tag key, only run if event_type matches + the tag's value (the mode for that tag). + - If no request tag matches, fall back to default mode(s). """ from litellm.litellm_core_utils.litellm_logging import ( StandardLoggingPayloadSetup, @@ -36,11 +41,29 @@ class EnterpriseCustomGuardrailHelper: proxy_server_request=proxy_server_request, ) - if request_tags and any(tag in event_hook.tags for tag in request_tags): - return True - elif event_hook.default and any( - tag in event_hook.default for tag in request_tags - ): + # Check if any request tag matches a Mode tag key + matched_mode = None + if request_tags: + for tag in request_tags: + if tag in event_hook.tags: + matched_mode = event_hook.tags[tag] + break + + if matched_mode is not None: + # Tag matched: only run if event_type matches the tag's mode value + if event_type is not None: + return event_type.value == matched_mode return True + # No tag matched: fall back to default mode(s) + if event_hook.default is not None: + if event_type is not None: + default_list = ( + event_hook.default + if isinstance(event_hook.default, list) + else [event_hook.default] + ) + return event_type.value in default_list + return False + return False diff --git a/litellm/integrations/custom_guardrail.py b/litellm/integrations/custom_guardrail.py index 3fd179bb411..269797b9873 100644 --- a/litellm/integrations/custom_guardrail.py +++ b/litellm/integrations/custom_guardrail.py @@ -420,7 +420,7 @@ class CustomGuardrail(CustomLogger): "Setting tag-based guardrails is only available in litellm-enterprise. You must be a premium user to use this feature." ) result = EnterpriseCustomGuardrailHelper._should_run_if_mode_by_tag( - data, self.event_hook + data, self.event_hook, event_type ) if result is not None: return result @@ -447,7 +447,7 @@ class CustomGuardrail(CustomLogger): "Setting tag-based guardrails is only available in litellm-enterprise. You must be a premium user to use this feature." ) result = EnterpriseCustomGuardrailHelper._should_run_if_mode_by_tag( - data, self.event_hook + data, self.event_hook, event_type ) if result is not None: return result diff --git a/tests/enterprise/litellm_enterprise/integrations/test_custom_guardrail.py b/tests/enterprise/litellm_enterprise/integrations/test_custom_guardrail.py index 6feaca6f0b7..f4e06f9f317 100644 --- a/tests/enterprise/litellm_enterprise/integrations/test_custom_guardrail.py +++ b/tests/enterprise/litellm_enterprise/integrations/test_custom_guardrail.py @@ -1,9 +1,5 @@ -import datetime -import json import os import sys -import unittest -from unittest.mock import ANY, MagicMock, patch sys.path.insert( 0, os.path.abspath("../..") @@ -12,6 +8,132 @@ from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.types.guardrails import GuardrailEventHooks, Mode +def test_custom_guardrail_with_mode_default_list(monkeypatch): + """Test Mode with default as a list of modes (e.g. default: ["pre_call", "post_call"])""" + monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", True) + cg = CustomGuardrail( + guardrail_name="test_guardrail", + supported_event_hooks=[ + GuardrailEventHooks.pre_call, + GuardrailEventHooks.post_call, + GuardrailEventHooks.logging_only, + ], + event_hook=Mode( + tags={"test_tag": "logging_only"}, + default=["pre_call", "post_call"], + ), + default_on=True, + ) + + # No tag match → default fires for pre_call + assert ( + cg.should_run_guardrail( + data={"messages": [{"role": "user", "content": "test"}]}, + event_type=GuardrailEventHooks.pre_call, + ) + is True + ) + + # No tag match → default fires for post_call + assert ( + cg.should_run_guardrail( + data={"messages": [{"role": "user", "content": "test"}]}, + event_type=GuardrailEventHooks.post_call, + ) + is True + ) + + # No tag match → logging_only NOT in default list, should not fire + assert ( + cg.should_run_guardrail( + data={"messages": [{"role": "user", "content": "test"}]}, + event_type=GuardrailEventHooks.logging_only, + ) + is False + ) + + # Tag matches → only logging_only should fire + assert ( + cg.should_run_guardrail( + data={ + "messages": [{"role": "user", "content": "test"}], + "litellm_metadata": {"tags": ["test_tag"]}, + }, + event_type=GuardrailEventHooks.logging_only, + ) + is True + ) + + # Tag matches → pre_call should NOT fire (tag says logging_only) + assert ( + cg.should_run_guardrail( + data={ + "messages": [{"role": "user", "content": "test"}], + "litellm_metadata": {"tags": ["test_tag"]}, + }, + event_type=GuardrailEventHooks.pre_call, + ) + is False + ) + + # Tag matches → post_call should NOT fire (tag says logging_only) + assert ( + cg.should_run_guardrail( + data={ + "messages": [{"role": "user", "content": "test"}], + "litellm_metadata": {"tags": ["test_tag"]}, + }, + event_type=GuardrailEventHooks.post_call, + ) + is False + ) + + +def test_custom_guardrail_with_mode_no_default(monkeypatch): + """Test Mode with no default — guardrail only fires when tag matches""" + monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", True) + cg = CustomGuardrail( + guardrail_name="test_guardrail", + supported_event_hooks=[ + GuardrailEventHooks.pre_call, + GuardrailEventHooks.logging_only, + ], + event_hook=Mode( + tags={"test_tag": "logging_only"}, + ), + default_on=True, + ) + + # No tag, no default → nothing fires + assert ( + cg.should_run_guardrail( + data={"messages": [{"role": "user", "content": "test"}]}, + event_type=GuardrailEventHooks.pre_call, + ) + is False + ) + + assert ( + cg.should_run_guardrail( + data={"messages": [{"role": "user", "content": "test"}]}, + event_type=GuardrailEventHooks.logging_only, + ) + is False + ) + + # Tag matches → only logging_only fires + assert ( + cg.should_run_guardrail( + data={ + "messages": [{"role": "user", "content": "test"}], + "litellm_metadata": {"tags": ["test_tag"]}, + }, + event_type=GuardrailEventHooks.logging_only, + ) + is True + ) + + def test_custom_guardrail_with_mode(monkeypatch): monkeypatch.setattr( "litellm.proxy.proxy_server.premium_user", True From 4edb1e00c60bf620ec6a179e29224298b000e081 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 3 Mar 2026 12:36:26 -0800 Subject: [PATCH 48/69] [Test] UI - Add unit tests for project hooks Co-Authored-By: Claude Sonnet 4.6 --- .../hooks/projects/useCreateProject.test.ts | 111 ++++++++++++++ .../hooks/projects/useDeleteProject.test.ts | 88 +++++++++++ .../hooks/projects/useProjectDetails.test.ts | 144 ++++++++++++++++++ .../hooks/projects/useProjects.test.ts | 124 +++++++++++++++ .../hooks/projects/useUpdateProject.test.ts | 116 ++++++++++++++ 5 files changed, 583 insertions(+) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useCreateProject.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useDeleteProject.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjectDetails.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjects.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useUpdateProject.test.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useCreateProject.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useCreateProject.test.ts new file mode 100644 index 00000000000..64d950d59ee --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useCreateProject.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import React, { ReactNode } from "react"; +import { useCreateProject, ProjectCreateParams } from "./useCreateProject"; +import { projectKeys, ProjectResponse } from "./useProjects"; + +vi.mock("@/components/networking", () => ({ + getProxyBaseUrl: vi.fn(() => ""), + getGlobalLitellmHeaderName: vi.fn(() => "Authorization"), + deriveErrorMessage: vi.fn((data: any) => data?.error || "Error"), + handleError: vi.fn(), +})); + +const mockUseAuthorized = vi.fn(); +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => mockUseAuthorized(), +})); + +const mockProject: ProjectResponse = { + project_id: "proj-1", + project_alias: "Test Project", + description: "A test project", + team_id: "team-1", + budget_id: null, + metadata: null, + models: ["gpt-4"], + spend: 25.0, + model_spend: null, + model_rpm_limit: null, + model_tpm_limit: null, + blocked: false, + object_permission_id: null, + created_at: "2024-01-01T00:00:00Z", + created_by: "user-1", + updated_at: "2024-01-02T00:00:00Z", + updated_by: "user-1", + litellm_budget_table: null, +}; + +function makeWrapper(queryClient: QueryClient) { + return ({ children }: { children: ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); +} + +describe("useCreateProject", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + vi.clearAllMocks(); + global.fetch = vi.fn(); + mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" }); + }); + + it("should render", () => { + const { result } = renderHook(() => useCreateProject(), { + wrapper: makeWrapper(queryClient), + }); + expect(result.current.mutate).toBeDefined(); + }); + + it("should POST to /project/new and return the created project", async () => { + (global.fetch as any).mockResolvedValue({ ok: true, json: async () => mockProject }); + const { result } = renderHook(() => useCreateProject(), { + wrapper: makeWrapper(queryClient), + }); + const params: ProjectCreateParams = { team_id: "team-1", project_alias: "New Project" }; + const data = await result.current.mutateAsync(params); + expect(data).toEqual(mockProject); + const [url, init] = (global.fetch as any).mock.calls[0]; + expect(url).toContain("/project/new"); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body)).toMatchObject(params); + }); + + it("should invalidate project queries on success", async () => { + (global.fetch as any).mockResolvedValue({ ok: true, json: async () => mockProject }); + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); + const { result } = renderHook(() => useCreateProject(), { + wrapper: makeWrapper(queryClient), + }); + await result.current.mutateAsync({ team_id: "team-1" }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: projectKeys.all }); + }); + + it("should set isError when the request fails", async () => { + (global.fetch as any).mockResolvedValue({ + ok: false, + json: async () => ({ error: "Server error" }), + }); + const { result } = renderHook(() => useCreateProject(), { + wrapper: makeWrapper(queryClient), + }); + result.current.mutateAsync({ team_id: "team-1" }).catch(() => {}); + await waitFor(() => expect(result.current.isError).toBe(true)); + }); + + it("should throw when accessToken is missing", async () => { + mockUseAuthorized.mockReturnValue({ accessToken: null, userRole: "Admin" }); + const { result } = renderHook(() => useCreateProject(), { + wrapper: makeWrapper(queryClient), + }); + await expect(result.current.mutateAsync({ team_id: "team-1" })).rejects.toThrow( + "Access token is required" + ); + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useDeleteProject.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useDeleteProject.test.ts new file mode 100644 index 00000000000..85a9f3e0b10 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useDeleteProject.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import React, { ReactNode } from "react"; +import { useDeleteProject } from "./useDeleteProject"; +import { projectKeys } from "./useProjects"; + +vi.mock("@/components/networking", () => ({ + getProxyBaseUrl: vi.fn(() => ""), + getGlobalLitellmHeaderName: vi.fn(() => "Authorization"), + deriveErrorMessage: vi.fn((data: any) => data?.error || "Error"), + handleError: vi.fn(), +})); + +const mockUseAuthorized = vi.fn(); +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => mockUseAuthorized(), +})); + +function makeWrapper(queryClient: QueryClient) { + return ({ children }: { children: ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); +} + +describe("useDeleteProject", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + vi.clearAllMocks(); + global.fetch = vi.fn(); + mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" }); + }); + + it("should render", () => { + const { result } = renderHook(() => useDeleteProject(), { + wrapper: makeWrapper(queryClient), + }); + expect(result.current.mutate).toBeDefined(); + }); + + it("should send DELETE to /project/delete with the given project IDs", async () => { + (global.fetch as any).mockResolvedValue({ ok: true, json: async () => ({}) }); + const { result } = renderHook(() => useDeleteProject(), { + wrapper: makeWrapper(queryClient), + }); + await result.current.mutateAsync(["proj-1", "proj-2"]); + const [url, init] = (global.fetch as any).mock.calls[0]; + expect(url).toContain("/project/delete"); + expect(init.method).toBe("DELETE"); + expect(JSON.parse(init.body)).toEqual({ project_ids: ["proj-1", "proj-2"] }); + }); + + it("should invalidate project queries on success", async () => { + (global.fetch as any).mockResolvedValue({ ok: true, json: async () => ({}) }); + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); + const { result } = renderHook(() => useDeleteProject(), { + wrapper: makeWrapper(queryClient), + }); + await result.current.mutateAsync(["proj-1"]); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: projectKeys.all }); + }); + + it("should set isError when the request fails", async () => { + (global.fetch as any).mockResolvedValue({ + ok: false, + json: async () => ({ error: "Not found" }), + }); + const { result } = renderHook(() => useDeleteProject(), { + wrapper: makeWrapper(queryClient), + }); + result.current.mutateAsync(["proj-1"]).catch(() => {}); + await waitFor(() => expect(result.current.isError).toBe(true)); + }); + + it("should throw when accessToken is missing", async () => { + mockUseAuthorized.mockReturnValue({ accessToken: null, userRole: "Admin" }); + const { result } = renderHook(() => useDeleteProject(), { + wrapper: makeWrapper(queryClient), + }); + await expect(result.current.mutateAsync(["proj-1"])).rejects.toThrow( + "Access token is required" + ); + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjectDetails.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjectDetails.test.ts new file mode 100644 index 00000000000..426abfe9bb6 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjectDetails.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import React, { ReactNode } from "react"; +import { useProjectDetails } from "./useProjectDetails"; +import { projectKeys, ProjectResponse } from "./useProjects"; + +vi.mock("@/components/networking", () => ({ + getProxyBaseUrl: vi.fn(() => ""), + getGlobalLitellmHeaderName: vi.fn(() => "Authorization"), + deriveErrorMessage: vi.fn((data: any) => data?.error || "Error"), + handleError: vi.fn(), +})); + +const mockUseAuthorized = vi.fn(); +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => mockUseAuthorized(), +})); + +const mockProject: ProjectResponse = { + project_id: "proj-1", + project_alias: "Test Project", + description: "A test project", + team_id: "team-1", + budget_id: null, + metadata: null, + models: ["gpt-4"], + spend: 25.0, + model_spend: null, + model_rpm_limit: null, + model_tpm_limit: null, + blocked: false, + object_permission_id: null, + created_at: "2024-01-01T00:00:00Z", + created_by: "user-1", + updated_at: "2024-01-02T00:00:00Z", + updated_by: "user-1", + litellm_budget_table: null, +}; + +const mockProjects: ProjectResponse[] = [ + mockProject, + { ...mockProject, project_id: "proj-2", project_alias: "Test Project 2" }, +]; + +function makeWrapper(queryClient: QueryClient) { + return ({ children }: { children: ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); +} + +describe("useProjectDetails", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + vi.clearAllMocks(); + global.fetch = vi.fn(); + mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" }); + }); + + it("should render", () => { + (global.fetch as any).mockResolvedValue({ ok: true, json: async () => mockProject }); + const { result } = renderHook(() => useProjectDetails("proj-1"), { + wrapper: makeWrapper(queryClient), + }); + expect(result.current).toBeDefined(); + }); + + it("should return project details when the request succeeds", async () => { + (global.fetch as any).mockResolvedValue({ ok: true, json: async () => mockProject }); + const { result } = renderHook(() => useProjectDetails("proj-1"), { + wrapper: makeWrapper(queryClient), + }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(mockProject); + }); + + it("should call /project/info with the projectId encoded as a query param", async () => { + (global.fetch as any).mockResolvedValue({ ok: true, json: async () => mockProject }); + renderHook(() => useProjectDetails("proj-1"), { wrapper: makeWrapper(queryClient) }); + await waitFor(() => expect(global.fetch).toHaveBeenCalled()); + const [url] = (global.fetch as any).mock.calls[0]; + expect(url).toContain("/project/info"); + expect(url).toContain("project_id=proj-1"); + }); + + it("should not fetch when projectId is missing", () => { + const { result } = renderHook(() => useProjectDetails(undefined), { + wrapper: makeWrapper(queryClient), + }); + expect(result.current.isFetched).toBe(false); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("should not fetch when accessToken is missing", () => { + mockUseAuthorized.mockReturnValue({ accessToken: null, userRole: "Admin" }); + const { result } = renderHook(() => useProjectDetails("proj-1"), { + wrapper: makeWrapper(queryClient), + }); + expect(result.current.isFetched).toBe(false); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("should not fetch when userRole is not an admin role", () => { + mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Internal User" }); + const { result } = renderHook(() => useProjectDetails("proj-1"), { + wrapper: makeWrapper(queryClient), + }); + expect(result.current.isFetched).toBe(false); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("should seed initialData from the projects list cache", async () => { + (global.fetch as any).mockResolvedValue({ ok: true, json: async () => mockProject }); + queryClient.setQueryData(projectKeys.list({}), mockProjects); + const { result } = renderHook(() => useProjectDetails("proj-1"), { + wrapper: makeWrapper(queryClient), + }); + expect(result.current.data).toEqual(mockProject); + expect(result.current.isLoading).toBe(false); + await waitFor(() => expect(result.current.isFetching).toBe(false)); + }); + + it("should return undefined initialData when projectId is not in the cache", () => { + queryClient.setQueryData(projectKeys.list({}), mockProjects); + const { result } = renderHook(() => useProjectDetails("non-existent"), { + wrapper: makeWrapper(queryClient), + }); + expect(result.current.data).toBeUndefined(); + }); + + it("should set isError when the request fails", async () => { + (global.fetch as any).mockResolvedValue({ + ok: false, + json: async () => ({ error: "Not found" }), + }); + const { result } = renderHook(() => useProjectDetails("proj-1"), { + wrapper: makeWrapper(queryClient), + }); + await waitFor(() => expect(result.current.isError).toBe(true)); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjects.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjects.test.ts new file mode 100644 index 00000000000..13b9107bdc1 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjects.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import React, { ReactNode } from "react"; +import { useProjects, ProjectResponse } from "./useProjects"; + +vi.mock("@/components/networking", () => ({ + getProxyBaseUrl: vi.fn(() => ""), + getGlobalLitellmHeaderName: vi.fn(() => "Authorization"), + deriveErrorMessage: vi.fn((data: any) => data?.error || "Error"), + handleError: vi.fn(), +})); + +const mockUseAuthorized = vi.fn(); +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => mockUseAuthorized(), +})); + +const mockProjects: ProjectResponse[] = [ + { + project_id: "proj-1", + project_alias: "Test Project", + description: "A test project", + team_id: "team-1", + budget_id: null, + metadata: null, + models: ["gpt-4"], + spend: 25.0, + model_spend: null, + model_rpm_limit: null, + model_tpm_limit: null, + blocked: false, + object_permission_id: null, + created_at: "2024-01-01T00:00:00Z", + created_by: "user-1", + updated_at: "2024-01-02T00:00:00Z", + updated_by: "user-1", + litellm_budget_table: null, + }, + { + project_id: "proj-2", + project_alias: "Test Project 2", + description: null, + team_id: "team-1", + budget_id: null, + metadata: null, + models: [], + spend: 0, + model_spend: null, + model_rpm_limit: null, + model_tpm_limit: null, + blocked: false, + object_permission_id: null, + created_at: "2024-01-03T00:00:00Z", + created_by: "user-1", + updated_at: "2024-01-03T00:00:00Z", + updated_by: "user-1", + litellm_budget_table: null, + }, +]; + +function makeWrapper(queryClient: QueryClient) { + return ({ children }: { children: ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); +} + +describe("useProjects", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + vi.clearAllMocks(); + global.fetch = vi.fn(); + mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" }); + }); + + it("should render", () => { + (global.fetch as any).mockResolvedValue({ ok: true, json: async () => mockProjects }); + const { result } = renderHook(() => useProjects(), { wrapper: makeWrapper(queryClient) }); + expect(result.current).toBeDefined(); + }); + + it("should return projects when the request succeeds", async () => { + (global.fetch as any).mockResolvedValue({ ok: true, json: async () => mockProjects }); + const { result } = renderHook(() => useProjects(), { wrapper: makeWrapper(queryClient) }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(mockProjects); + }); + + it("should call GET /project/list with the auth header", async () => { + (global.fetch as any).mockResolvedValue({ ok: true, json: async () => mockProjects }); + renderHook(() => useProjects(), { wrapper: makeWrapper(queryClient) }); + await waitFor(() => expect(global.fetch).toHaveBeenCalled()); + const [url, init] = (global.fetch as any).mock.calls[0]; + expect(url).toContain("/project/list"); + expect(init.headers["Authorization"]).toBe("Bearer test-token"); + }); + + it("should set isError when the request fails", async () => { + (global.fetch as any).mockResolvedValue({ + ok: false, + json: async () => ({ error: "Not authorized" }), + }); + const { result } = renderHook(() => useProjects(), { wrapper: makeWrapper(queryClient) }); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.data).toBeUndefined(); + }); + + it("should not fetch when accessToken is missing", () => { + mockUseAuthorized.mockReturnValue({ accessToken: null, userRole: "Admin" }); + const { result } = renderHook(() => useProjects(), { wrapper: makeWrapper(queryClient) }); + expect(result.current.isFetched).toBe(false); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("should not fetch when userRole is not an admin role", () => { + mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Internal User" }); + const { result } = renderHook(() => useProjects(), { wrapper: makeWrapper(queryClient) }); + expect(result.current.isFetched).toBe(false); + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useUpdateProject.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useUpdateProject.test.ts new file mode 100644 index 00000000000..31d1a5fb352 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useUpdateProject.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import React, { ReactNode } from "react"; +import { useUpdateProject } from "./useUpdateProject"; +import { projectKeys, ProjectResponse } from "./useProjects"; + +vi.mock("@/components/networking", () => ({ + getProxyBaseUrl: vi.fn(() => ""), + getGlobalLitellmHeaderName: vi.fn(() => "Authorization"), + deriveErrorMessage: vi.fn((data: any) => data?.error || "Error"), + handleError: vi.fn(), +})); + +const mockUseAuthorized = vi.fn(); +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => mockUseAuthorized(), +})); + +const mockProject: ProjectResponse = { + project_id: "proj-1", + project_alias: "Test Project", + description: "A test project", + team_id: "team-1", + budget_id: null, + metadata: null, + models: ["gpt-4"], + spend: 25.0, + model_spend: null, + model_rpm_limit: null, + model_tpm_limit: null, + blocked: false, + object_permission_id: null, + created_at: "2024-01-01T00:00:00Z", + created_by: "user-1", + updated_at: "2024-01-02T00:00:00Z", + updated_by: "user-1", + litellm_budget_table: null, +}; + +function makeWrapper(queryClient: QueryClient) { + return ({ children }: { children: ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); +} + +describe("useUpdateProject", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + vi.clearAllMocks(); + global.fetch = vi.fn(); + mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" }); + }); + + it("should render", () => { + const { result } = renderHook(() => useUpdateProject(), { + wrapper: makeWrapper(queryClient), + }); + expect(result.current.mutate).toBeDefined(); + }); + + it("should POST to /project/update and return the updated project", async () => { + const updated = { ...mockProject, project_alias: "Updated Name" }; + (global.fetch as any).mockResolvedValue({ ok: true, json: async () => updated }); + const { result } = renderHook(() => useUpdateProject(), { + wrapper: makeWrapper(queryClient), + }); + const data = await result.current.mutateAsync({ + projectId: "proj-1", + params: { project_alias: "Updated Name" }, + }); + expect(data).toEqual(updated); + const [url, init] = (global.fetch as any).mock.calls[0]; + expect(url).toContain("/project/update"); + expect(JSON.parse(init.body)).toMatchObject({ + project_id: "proj-1", + project_alias: "Updated Name", + }); + }); + + it("should invalidate project queries on success", async () => { + (global.fetch as any).mockResolvedValue({ ok: true, json: async () => mockProject }); + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); + const { result } = renderHook(() => useUpdateProject(), { + wrapper: makeWrapper(queryClient), + }); + await result.current.mutateAsync({ projectId: "proj-1", params: {} }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: projectKeys.all }); + }); + + it("should set isError when the request fails", async () => { + (global.fetch as any).mockResolvedValue({ + ok: false, + json: async () => ({ error: "Server error" }), + }); + const { result } = renderHook(() => useUpdateProject(), { + wrapper: makeWrapper(queryClient), + }); + result.current.mutateAsync({ projectId: "proj-1", params: {} }).catch(() => {}); + await waitFor(() => expect(result.current.isError).toBe(true)); + }); + + it("should throw when accessToken is missing", async () => { + mockUseAuthorized.mockReturnValue({ accessToken: null, userRole: "Admin" }); + const { result } = renderHook(() => useUpdateProject(), { + wrapper: makeWrapper(queryClient), + }); + await expect( + result.current.mutateAsync({ projectId: "proj-1", params: {} }) + ).rejects.toThrow("Access token is required"); + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); From 279b4f16cb3c5d783294928f1018bf05320dfb50 Mon Sep 17 00:00:00 2001 From: Julio Quinteros Pro Date: Tue, 3 Mar 2026 17:57:32 -0300 Subject: [PATCH 49/69] Fix mypy override errors in count_tokens signatures Replace **kwargs with explicit tools and system parameters to match the BaseTokenCounter.count_tokens abstract method signature. Co-Authored-By: Claude Opus 4.6 --- litellm/llms/gemini/common_utils.py | 3 ++- litellm/llms/vertex_ai/common_utils.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/litellm/llms/gemini/common_utils.py b/litellm/llms/gemini/common_utils.py index f99548c2c45..17b9c78123f 100644 --- a/litellm/llms/gemini/common_utils.py +++ b/litellm/llms/gemini/common_utils.py @@ -166,7 +166,8 @@ class GoogleAIStudioTokenCounter(BaseTokenCounter): contents: Optional[List[Dict[str, Any]]], deployment: Optional[Dict[str, Any]] = None, request_model: str = "", - **kwargs, + tools: Optional[List[Dict[str, Any]]] = None, + system: Optional[Any] = None, ) -> Optional[TokenCountResponse]: import copy diff --git a/litellm/llms/vertex_ai/common_utils.py b/litellm/llms/vertex_ai/common_utils.py index 791878c9700..3c5cbb65437 100644 --- a/litellm/llms/vertex_ai/common_utils.py +++ b/litellm/llms/vertex_ai/common_utils.py @@ -1030,7 +1030,8 @@ class VertexAITokenCounter(BaseTokenCounter): contents: Optional[List[Dict[str, Any]]], deployment: Optional[Dict[str, Any]] = None, request_model: str = "", - **kwargs, + tools: Optional[List[Dict[str, Any]]] = None, + system: Optional[Any] = None, ) -> Optional[TokenCountResponse]: import copy From 6b4bc99202c1fad64920c260f437ca52116966a5 Mon Sep 17 00:00:00 2001 From: Julio Quinteros Pro Date: Tue, 3 Mar 2026 18:12:49 -0300 Subject: [PATCH 50/69] Fix Anthropic streaming sync __next__ and Azure GPT-5.1 logprobs Two independent fixes for pre-existing test failures on main: 1. Anthropic streaming: The sync __next__ method used a simple holding_chunk pattern that lost chunks when multiple events needed to be returned. Refactored to use the same chunk_queue approach as the async __anext__ method. Also fixed tests that used ModelResponse (which defaults finish_reason to 'stop') instead of ModelResponseStream. 2. Azure GPT-5.1 logprobs: The base OpenAI class includes logprobs for gpt-5.1+ models, but Azure hasn't verified support for gpt-5.1. Added explicit removal of logprobs/top_logprobs for gpt-5.1 (non-5.2) models in the Azure config. Co-Authored-By: Claude Opus 4.6 --- .../adapters/streaming_iterator.py | 135 ++++++++++-------- .../llms/azure/chat/gpt_5_transformation.py | 8 +- .../test_content_after_stop_reason.py | 14 +- .../messages/test_parallel_tool_calls.py | 26 ++-- .../messages/test_sse_wrapper.py | 17 +-- 5 files changed, 105 insertions(+), 95 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py b/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py index de634ff9ecf..cdf8ac5ca82 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py @@ -80,38 +80,40 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): from .transformation import LiteLLMAnthropicMessagesAdapter try: + # Always return queued chunks first + if self.chunk_queue: + return self.chunk_queue.popleft() + + # Queue initial chunks if not sent yet if self.sent_first_chunk is False: self.sent_first_chunk = True - return { - "type": "message_start", - "message": { - "id": "msg_{}".format(uuid.uuid4()), - "type": "message", - "role": "assistant", - "content": [], - "model": self.model, - "stop_reason": None, - "stop_sequence": None, - "usage": self._create_initial_usage_delta(), - }, - } + self.chunk_queue.append( + { + "type": "message_start", + "message": { + "id": "msg_{}".format(uuid.uuid4()), + "type": "message", + "role": "assistant", + "content": [], + "model": self.model, + "stop_reason": None, + "stop_sequence": None, + "usage": self._create_initial_usage_delta(), + }, + } + ) + return self.chunk_queue.popleft() + if self.sent_content_block_start is False: self.sent_content_block_start = True - return { - "type": "content_block_start", - "index": self.current_content_block_index, - "content_block": {"type": "text", "text": ""}, - } - - # Handle pending new content block start - if self.pending_new_content_block: - self.pending_new_content_block = False - self.sent_content_block_finish = False # Reset for new block - return { - "type": "content_block_start", - "index": self.current_content_block_index, - "content_block": self.current_content_block_start, - } + self.chunk_queue.append( + { + "type": "content_block_start", + "index": self.current_content_block_index, + "content_block": {"type": "text", "text": ""}, + } + ) + return self.chunk_queue.popleft() for chunk in self.completion_stream: if chunk == "None" or chunk is None: @@ -126,45 +128,65 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): current_content_block_index=self.current_content_block_index, ) - # Check if we need to start a new content block - # This is where you'd add your logic to detect when a new content block should start - # For example, if the chunk indicates a tool call or different content type - if should_start_new_block and not self.sent_content_block_finish: - # End current content block and prepare for new one - self.holding_chunk = processed_chunk - self.sent_content_block_finish = True - self.pending_new_content_block = True - return { - "type": "content_block_stop", - "index": max(self.current_content_block_index - 1, 0), - } + # Queue the sequence: content_block_stop -> content_block_start + # The trigger chunk itself is not emitted as a delta since the + # content_block_start already carries the relevant information. + self.chunk_queue.append( + { + "type": "content_block_stop", + "index": max(self.current_content_block_index - 1, 0), + } + ) + self.chunk_queue.append( + { + "type": "content_block_start", + "index": self.current_content_block_index, + "content_block": self.current_content_block_start, + } + ) + self.sent_content_block_finish = False + return self.chunk_queue.popleft() if ( processed_chunk["type"] == "message_delta" and self.sent_content_block_finish is False ): - self.holding_chunk = processed_chunk + # Queue both the content_block_stop and the message_delta + self.chunk_queue.append( + { + "type": "content_block_stop", + "index": self.current_content_block_index, + } + ) self.sent_content_block_finish = True - return { - "type": "content_block_stop", - "index": self.current_content_block_index, - } + self.chunk_queue.append(processed_chunk) + return self.chunk_queue.popleft() elif self.holding_chunk is not None: - return_chunk = self.holding_chunk - self.holding_chunk = processed_chunk - return return_chunk + self.chunk_queue.append(self.holding_chunk) + self.chunk_queue.append(processed_chunk) + self.holding_chunk = None + return self.chunk_queue.popleft() else: - return processed_chunk + self.chunk_queue.append(processed_chunk) + return self.chunk_queue.popleft() + + # Handle any remaining held chunks after stream ends if self.holding_chunk is not None: - return_chunk = self.holding_chunk + self.chunk_queue.append(self.holding_chunk) self.holding_chunk = None - return return_chunk - if self.sent_last_message is False: + + if not self.sent_last_message: self.sent_last_message = True - return {"type": "message_stop"} + self.chunk_queue.append({"type": "message_stop"}) + + if self.chunk_queue: + return self.chunk_queue.popleft() + raise StopIteration except StopIteration: + if self.chunk_queue: + return self.chunk_queue.popleft() if self.sent_last_message is False: self.sent_last_message = True return {"type": "message_stop"} @@ -265,7 +287,9 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): if not self.queued_usage_chunk: if should_start_new_block and not self.sent_content_block_finish: - # Queue the sequence: content_block_stop -> content_block_start -> current_chunk + # Queue the sequence: content_block_stop -> content_block_start + # The trigger chunk itself is not emitted as a delta since the + # content_block_start already carries the relevant information. # 1. Stop current content block self.chunk_queue.append( @@ -284,9 +308,6 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): } ) - # 3. Queue the current chunk (don't lose it!) - self.chunk_queue.append(processed_chunk) - # Reset state for new block self.sent_content_block_finish = False diff --git a/litellm/llms/azure/chat/gpt_5_transformation.py b/litellm/llms/azure/chat/gpt_5_transformation.py index eeb55911ecf..2a2955fca37 100644 --- a/litellm/llms/azure/chat/gpt_5_transformation.py +++ b/litellm/llms/azure/chat/gpt_5_transformation.py @@ -43,8 +43,12 @@ class AzureOpenAIGPT5Config(AzureOpenAIConfig, OpenAIGPT5Config): if "tool_choice" not in params: params.append("tool_choice") - # Only gpt-5.2 has been verified to support logprobs on Azure - if self.is_model_gpt_5_2_model(model): + # Only gpt-5.2 has been verified to support logprobs on Azure. + # The base OpenAI class includes logprobs for gpt-5.1+, but Azure + # hasn't verified support for gpt-5.1, so remove them unless gpt-5.2. + if self.is_model_gpt_5_1_model(model) and not self.is_model_gpt_5_2_model(model): + params = [p for p in params if p not in ["logprobs", "top_logprobs"]] + elif self.is_model_gpt_5_2_model(model): azure_supported_params = ["logprobs", "top_logprobs"] params.extend(azure_supported_params) diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_content_after_stop_reason.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_content_after_stop_reason.py index 4a170d666f5..eadc0da2f1f 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_content_after_stop_reason.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_content_after_stop_reason.py @@ -23,7 +23,7 @@ sys.path.insert(0, os.path.abspath("../../../../..")) from litellm.llms.anthropic.experimental_pass_through.adapters.streaming_iterator import ( AnthropicStreamWrapper, ) -from litellm.types.utils import Delta, ModelResponse, StreamingChoices, Usage +from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices, Usage class MockCompletionStreamWithContentAfterStopReason: @@ -32,16 +32,14 @@ class MockCompletionStreamWithContentAfterStopReason: def __init__(self): self.responses = [ # Initial text content - ModelResponse( - stream=True, + ModelResponseStream( choices=[ StreamingChoices( delta=Delta(content="Hello"), index=0, finish_reason=None ) ], ), - ModelResponse( - stream=True, + ModelResponseStream( choices=[ StreamingChoices( delta=Delta(content=" world"), index=0, finish_reason=None @@ -49,8 +47,7 @@ class MockCompletionStreamWithContentAfterStopReason: ], ), # Message delta with stop_reason AND usage (this is how it actually comes from the API) - ModelResponse( - stream=True, + ModelResponseStream( choices=[ StreamingChoices( delta=Delta(content=""), index=0, finish_reason="stop" @@ -60,8 +57,7 @@ class MockCompletionStreamWithContentAfterStopReason: ), # Additional content after the stop_reason - this simulates the scenario # where there might be additional content blocks after the main response - ModelResponse( - stream=True, + ModelResponseStream( choices=[ StreamingChoices( delta=Delta(content=" Additional content"), diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_parallel_tool_calls.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_parallel_tool_calls.py index 9d4e58f3c88..1d25d719384 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_parallel_tool_calls.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_parallel_tool_calls.py @@ -10,7 +10,7 @@ from litellm.llms.anthropic.experimental_pass_through.adapters.streaming_iterato ) from litellm.types.utils import ( Delta, - ModelResponse, + ModelResponseStream, StreamingChoices, Usage, ChatCompletionDeltaToolCall, @@ -19,7 +19,7 @@ from litellm.types.utils import ( class MockCompletionStream: - def __init__(self, responses: List[ModelResponse]): + def __init__(self, responses: List[ModelResponseStream]): self.responses = responses self.index = 0 @@ -44,9 +44,8 @@ class MockCompletionStream: return response -def construct_text_chunk(text: str) -> ModelResponse: - return ModelResponse( - stream=True, +def construct_text_chunk(text: str) -> ModelResponseStream: + return ModelResponseStream( choices=[ StreamingChoices( delta=Delta(content=text), @@ -59,11 +58,10 @@ def construct_text_chunk(text: str) -> ModelResponse: def construct_split_tool_call( id: str, function_name: str, function_arg_parts: List[str] -) -> List[ModelResponse]: +) -> List[ModelResponseStream]: return [ # https://platform.openai.com/docs/guides/function-calling#streaming - ModelResponse( - stream=True, + ModelResponseStream( choices=[ StreamingChoices( delta=Delta( @@ -82,8 +80,7 @@ def construct_split_tool_call( ], ), *[ - ModelResponse( - stream=True, + ModelResponseStream( choices=[ StreamingChoices( delta=Delta( @@ -109,8 +106,7 @@ def construct_split_tool_call( def test_anthropic_stream_wrapper_single_tool_call(): responses = [ *construct_split_tool_call("tooluse_foo", "get_weather", ['{"city":', '"NY"}']), - ModelResponse( - stream=True, + ModelResponseStream( choices=[ StreamingChoices( delta=Delta(content="", stop_reason="tool_calls"), @@ -172,8 +168,7 @@ def test_anthropic_stream_wrapper_back_to_back_tool_calls(): responses = [ *construct_split_tool_call("tooluse_foo", "get_weather", ['{"city":', '"NY"}']), *construct_split_tool_call("tooluse_bar", "get_weather", ['{"city":', '"SF"}']), - ModelResponse( - stream=True, + ModelResponseStream( choices=[ StreamingChoices( delta=Delta(content="", stop_reason="tool_calls"), @@ -244,8 +239,7 @@ def test_anthropic_stream_wrapper_interleaved_tool_calls_and_text(): "tooluse_bar", "get_weather", ['{"city":', '"CHI"}'] ), construct_text_chunk("The weather is not so nice today."), - ModelResponse( - stream=True, + ModelResponseStream( choices=[ StreamingChoices( delta=Delta(content="", stop_reason="tool_calls"), diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_sse_wrapper.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_sse_wrapper.py index dfcb9b3eb74..63fed907c3c 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_sse_wrapper.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_sse_wrapper.py @@ -9,31 +9,28 @@ sys.path.insert(0, os.path.abspath("../../../../..")) from litellm.llms.anthropic.experimental_pass_through.adapters.streaming_iterator import ( AnthropicStreamWrapper, ) -from litellm.types.utils import Delta, ModelResponse, StreamingChoices +from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices # Create a simple test class MockCompletionStream: def __init__(self): self.responses = [ - ModelResponse( - stream=True, + ModelResponseStream( choices=[ StreamingChoices( delta=Delta(content="Hello"), index=0, finish_reason=None ) ], ), - ModelResponse( - stream=True, + ModelResponseStream( choices=[ StreamingChoices( delta=Delta(content=" World"), index=0, finish_reason=None ) ], ), - ModelResponse( - stream=True, + ModelResponseStream( choices=[ StreamingChoices( delta=Delta(content=""), index=0, finish_reason="stop" @@ -109,16 +106,14 @@ async def test_async_anthropic_sse_wrapper(): class AsyncMockCompletionStream: def __init__(self): self.responses = [ - ModelResponse( - stream=True, + ModelResponseStream( choices=[ StreamingChoices( delta=Delta(content="Hello"), index=0, finish_reason=None ) ], ), - ModelResponse( - stream=True, + ModelResponseStream( choices=[ StreamingChoices( delta=Delta(content=" World"), index=0, finish_reason=None From ab718444c57db2bc88f078d82a8821ca7f9ba7d7 Mon Sep 17 00:00:00 2001 From: Julio Quinteros Pro Date: Tue, 3 Mar 2026 18:28:34 -0300 Subject: [PATCH 51/69] Remove dead pending_new_content_block attribute Cleanup per review: this class attribute is no longer used after the __next__ refactor to queue-based approach. Co-Authored-By: Claude Opus 4.6 --- .../experimental_pass_through/adapters/streaming_iterator.py | 1 - 1 file changed, 1 deletion(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py b/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py index cdf8ac5ca82..7f17526e75c 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py @@ -41,7 +41,6 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): type="text", text="", ) - pending_new_content_block: bool = False chunk_queue: deque = deque() # Queue for buffering multiple chunks def __init__( From d6ad312a4c2a2d14625210a9bd6daceb14940f97 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Tue, 3 Mar 2026 13:37:11 -0800 Subject: [PATCH 52/69] fix(proxy): validate enforced_file_expires_after keys before access Add key validation for enforced_file_expires_after to return a clear 400 error instead of an unhandled KeyError 500. --- .../proxy/openai_files_endpoints/files_endpoints.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/litellm/proxy/openai_files_endpoints/files_endpoints.py b/litellm/proxy/openai_files_endpoints/files_endpoints.py index a641fb1c69f..386ab2bf044 100644 --- a/litellm/proxy/openai_files_endpoints/files_endpoints.py +++ b/litellm/proxy/openai_files_endpoints/files_endpoints.py @@ -458,11 +458,22 @@ async def create_file( # noqa: PLR0915 team_metadata = user_api_key_dict.team_metadata or {} enforced_file_expiry = team_metadata.get("enforced_file_expires_after") if enforced_file_expiry is not None: + if "anchor" not in enforced_file_expiry or "seconds" not in enforced_file_expiry: + raise HTTPException( + status_code=400, + detail={ + "error": "enforced_file_expires_after must contain 'anchor' and 'seconds' keys", + }, + ) expires_after = FileExpiresAfter( anchor=enforced_file_expiry["anchor"], seconds=enforced_file_expiry["seconds"], ) + verbose_proxy_logger.info( + "create_file expires_after: %s", expires_after + ) + _create_file_request = CreateFileRequest( file=file_data, purpose=cast(CREATE_FILE_REQUESTS_PURPOSE, purpose), From 903ade4a1b3d10c83c2b7b91ffdc22322739e55e Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Tue, 3 Mar 2026 13:51:41 -0800 Subject: [PATCH 53/69] fix(proxy): add anchor validation for file expiry, key validation for batch expiry Validate anchor is "created_at" in enforced_file_expires_after (matching user-provided path). Add key existence validation to batch endpoint for enforced_batch_output_expires_after. --- litellm/proxy/batches_endpoints/endpoints.py | 7 +++++++ litellm/proxy/openai_files_endpoints/files_endpoints.py | 9 ++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/batches_endpoints/endpoints.py b/litellm/proxy/batches_endpoints/endpoints.py index 60905243369..850134b649c 100644 --- a/litellm/proxy/batches_endpoints/endpoints.py +++ b/litellm/proxy/batches_endpoints/endpoints.py @@ -125,6 +125,13 @@ async def create_batch( # noqa: PLR0915 "enforced_batch_output_expires_after" ) if enforced_batch_expiry is not None: + if "anchor" not in enforced_batch_expiry or "seconds" not in enforced_batch_expiry: + raise HTTPException( + status_code=400, + detail={ + "error": "enforced_batch_output_expires_after must contain 'anchor' and 'seconds' keys", + }, + ) _create_batch_data["output_expires_after"] = enforced_batch_expiry input_file_id = _create_batch_data.get("input_file_id", None) diff --git a/litellm/proxy/openai_files_endpoints/files_endpoints.py b/litellm/proxy/openai_files_endpoints/files_endpoints.py index 386ab2bf044..82cae8c64ea 100644 --- a/litellm/proxy/openai_files_endpoints/files_endpoints.py +++ b/litellm/proxy/openai_files_endpoints/files_endpoints.py @@ -465,8 +465,15 @@ async def create_file( # noqa: PLR0915 "error": "enforced_file_expires_after must contain 'anchor' and 'seconds' keys", }, ) + if enforced_file_expiry["anchor"] != "created_at": + raise HTTPException( + status_code=400, + detail={ + "error": f"enforced_file_expires_after anchor must be 'created_at', got '{enforced_file_expiry['anchor']}'", + }, + ) expires_after = FileExpiresAfter( - anchor=enforced_file_expiry["anchor"], + anchor="created_at", seconds=enforced_file_expiry["seconds"], ) From 657a60ea5b55fa3497e4222b12ec782fc54f2df8 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 3 Mar 2026 14:24:55 -0800 Subject: [PATCH 54/69] fix(audit): AND semantics for combined JSON filters; remove unused allTeams prop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix object_team_id + object_key_hash combining incorrectly as OR — each filter now adds an AND clause wrapping an internal OR over before_value and updated_values, so both conditions must be satisfied simultaneously - Rename helper to _build_json_field_or_condition to reflect its purpose - Remove allTeams from AuditLogsProps and its call site in index.tsx Co-Authored-By: Claude Sonnet 4.6 --- .../proxy/audit_logging_endpoints.py | 42 ++++++++++--------- .../src/components/view_logs/audit_logs.tsx | 2 - .../src/components/view_logs/index.tsx | 1 - 3 files changed, 22 insertions(+), 23 deletions(-) diff --git a/enterprise/litellm_enterprise/proxy/audit_logging_endpoints.py b/enterprise/litellm_enterprise/proxy/audit_logging_endpoints.py index 5ab3669b50c..18ac29b9781 100644 --- a/enterprise/litellm_enterprise/proxy/audit_logging_endpoints.py +++ b/enterprise/litellm_enterprise/proxy/audit_logging_endpoints.py @@ -22,19 +22,25 @@ from litellm.proxy.auth.user_api_key_auth import user_api_key_auth router = APIRouter() -def _build_json_field_conditions( - field: str, json_key: str, value: str -) -> List[Dict[str, Any]]: +def _build_json_field_or_condition(json_key: str, value: str) -> Dict[str, Any]: """ - Build OR conditions to match a value inside a JSON column at the given key. + Build an OR condition that matches a value inside a JSON column at the + given key, checking both before_value and updated_values. - Uses Prisma's JSON path filtering (PostgreSQL only). Returns a list of - two conditions — one for `before_value` and one for `updated_values` — to - be merged into the caller's top-level OR list. + Uses Prisma's JSON path filtering (PostgreSQL only). + + Example result (team_id="t1"): + {"OR": [ + {"before_value": {"path": ["team_id"], "string_contains": "t1"}}, + {"updated_values": {"path": ["team_id"], "string_contains": "t1"}}, + ]} """ - return [ - {field: {"path": [json_key], "string_contains": value}}, - ] + return { + "OR": [ + {"before_value": {"path": [json_key], "string_contains": value}}, + {"updated_values": {"path": [json_key], "string_contains": value}}, + ] + } @router.get( @@ -115,19 +121,15 @@ async def get_audit_logs( date_filter["lte"] = end_date where_conditions["updated_at"] = date_filter - # JSON field filters (PostgreSQL only) — search inside before_value and - # updated_values for a matching key/value pair. + # JSON field filters (PostgreSQL only) — each filter is AND'd with the + # others, but checks both before_value and updated_values internally (OR). if object_team_id: - where_conditions["OR"] = [ - *_build_json_field_conditions("before_value", "team_id", object_team_id), - *_build_json_field_conditions("updated_values", "team_id", object_team_id), + where_conditions["AND"] = where_conditions.get("AND", []) + [ + _build_json_field_or_condition("team_id", object_team_id) ] if object_key_hash: - existing_or: List[Dict[str, Any]] = where_conditions.get("OR", []) - where_conditions["OR"] = [ - *existing_or, - *_build_json_field_conditions("before_value", "token", object_key_hash), - *_build_json_field_conditions("updated_values", "token", object_key_hash), + where_conditions["AND"] = where_conditions.get("AND", []) + [ + _build_json_field_or_condition("token", object_key_hash) ] # Build sort conditions diff --git a/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx b/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx index d3372eca6ff..693318acca8 100644 --- a/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx @@ -7,7 +7,6 @@ import moment from "moment"; import { uiAuditLogsCall } from "../networking"; import { AuditLogEntry } from "./columns"; import { AuditLogDrawer } from "./AuditLogDrawer/AuditLogDrawer"; -import { Team } from "../key_team_helpers/key_list"; const { Search } = Input; @@ -18,7 +17,6 @@ interface AuditLogsProps { userID: string | null; isActive: boolean; premiumUser: boolean; - allTeams: Team[]; } const asset_logos_folder = "../ui/assets/"; diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx index 22cc06a73e9..64dc53c0e77 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx @@ -714,7 +714,6 @@ export default function SpendLogsTable({ accessToken={accessToken} isActive={activeTab === "audit logs"} premiumUser={premiumUser} - allTeams={allTeams} /> From 35d2bc382f0185850fbe6c9e505a69d940fb1619 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Tue, 3 Mar 2026 14:28:25 -0800 Subject: [PATCH 55/69] fix(batches): suppress PLR0915 lint for create_batch dispatch function --- litellm/batches/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/batches/main.py b/litellm/batches/main.py index e73f73d2f33..e69c5a5c377 100644 --- a/litellm/batches/main.py +++ b/litellm/batches/main.py @@ -154,7 +154,7 @@ async def acreate_batch( @client -def create_batch( +def create_batch( # noqa: PLR0915 completion_window: Literal["24h"], endpoint: Literal["/v1/chat/completions", "/v1/embeddings", "/v1/completions"], input_file_id: str, From c8e6428eb77196030d65f68de9e8a09aab6ce72c Mon Sep 17 00:00:00 2001 From: ryan-crabbe <128659760+ryan-crabbe@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:35:58 -0800 Subject: [PATCH 56/69] Update litellm/proxy/openai_files_endpoints/files_endpoints.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- litellm/proxy/openai_files_endpoints/files_endpoints.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/proxy/openai_files_endpoints/files_endpoints.py b/litellm/proxy/openai_files_endpoints/files_endpoints.py index 82cae8c64ea..44bd9b09d8e 100644 --- a/litellm/proxy/openai_files_endpoints/files_endpoints.py +++ b/litellm/proxy/openai_files_endpoints/files_endpoints.py @@ -477,7 +477,7 @@ async def create_file( # noqa: PLR0915 seconds=enforced_file_expiry["seconds"], ) - verbose_proxy_logger.info( + verbose_proxy_logger.debug( "create_file expires_after: %s", expires_after ) From 5da7fa9ac18a0b82a1a294389d6cf146fa443dd9 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 3 Mar 2026 14:39:39 -0800 Subject: [PATCH 57/69] [Feature] UI - Virtual Keys: Add manual spend reset to unblock keys Adds a "Reset Spend" button to the key detail view so proxy admins and team admins can immediately reset a key's spend to $0, unblocking keys that have hit their budget limit without waiting for the next scheduled budget reset. Co-Authored-By: Claude Sonnet 4.6 --- .../hooks/keys/useResetKeySpend.ts | 66 ++++++++ .../components/templates/KeyInfoHeader.tsx | 8 + .../templates/key_info_view.test.tsx | 141 ++++++++++++++++++ .../components/templates/key_info_view.tsx | 50 ++++++- 4 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useResetKeySpend.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useResetKeySpend.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useResetKeySpend.ts new file mode 100644 index 00000000000..a845fc5881a --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useResetKeySpend.ts @@ -0,0 +1,66 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + getProxyBaseUrl, + getGlobalLitellmHeaderName, + deriveErrorMessage, + handleError, +} from "@/components/networking"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { keyKeys } from "./useKeys"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export interface ResetKeySpendResponse { + key_hash: string; + spend: number; + previous_spend: number; + max_budget: number | null; + budget_reset_at: string | null; +} + +// ── Fetch function ──────────────────────────────────────────────────────────── + +export const resetKeySpend = async ( + accessToken: string, + keyToken: string, +): Promise => { + const baseUrl = getProxyBaseUrl(); + const url = `${baseUrl ? `${baseUrl}/key/${keyToken}/reset_spend` : `/key/${keyToken}/reset_spend`}`; + + const response = await fetch(url, { + method: "POST", + headers: { + [getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ reset_to: 0 }), + }); + + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + + return response.json(); +}; + +// ── Hook ────────────────────────────────────────────────────────────────────── + +export const useResetKeySpend = () => { + const { accessToken } = useAuthorized(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (keyToken) => { + if (!accessToken) { + throw new Error("Access token is required"); + } + return resetKeySpend(accessToken, keyToken); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: keyKeys.all }); + }, + }); +}; diff --git a/ui/litellm-dashboard/src/components/templates/KeyInfoHeader.tsx b/ui/litellm-dashboard/src/components/templates/KeyInfoHeader.tsx index 1befd657843..df3678afab6 100644 --- a/ui/litellm-dashboard/src/components/templates/KeyInfoHeader.tsx +++ b/ui/litellm-dashboard/src/components/templates/KeyInfoHeader.tsx @@ -11,6 +11,7 @@ import { ClockCircleOutlined, ThunderboltOutlined, SafetyCertificateOutlined, + DollarOutlined, } from "@ant-design/icons"; import LabeledField from "../common_components/LabeledField"; @@ -33,6 +34,7 @@ interface KeyInfoHeaderProps { onCreateNew?: () => void; onRegenerate?: () => void; onDelete?: () => void; + onResetSpend?: () => void; canModifyKey?: boolean; backButtonText?: string; regenerateDisabled?: boolean; @@ -45,6 +47,7 @@ export function KeyInfoHeader({ onCreateNew, onRegenerate, onDelete, + onResetSpend, canModifyKey = true, backButtonText = "Back to Keys", regenerateDisabled = false, @@ -77,6 +80,11 @@ export function KeyInfoHeader({
{canModifyKey && ( + {onResetSpend && ( + + )} )} diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx index 35a75b2f2fe..b77b13b1591 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -450,8 +450,8 @@ export default function KeyInfoView({ $0?

- Current spend: ${formatNumberWithCommas(currentKeyData.spend, 4)}. The key will be - immediately unblocked and able to make requests again. + Current spend: ${formatNumberWithCommas(currentKeyData.spend, 4)}. Spend history is + preserved in logs. This resets the current period spend counter, the same as an automatic budget reset.

From 5b2110ddb56492c6b8876c1e5cf3608d754d3820 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 3 Mar 2026 15:16:47 -0800 Subject: [PATCH 65/69] Polish Reset Spend button and modal - Move Reset Spend button after Regenerate Key in header - Make modal OK button danger style with text "Reset" Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/templates/KeyInfoHeader.tsx | 10 +++++----- .../src/components/templates/key_info_view.test.tsx | 4 ++-- .../src/components/templates/key_info_view.tsx | 3 ++- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/ui/litellm-dashboard/src/components/templates/KeyInfoHeader.tsx b/ui/litellm-dashboard/src/components/templates/KeyInfoHeader.tsx index c750b1b4ac7..93ebae9c4be 100644 --- a/ui/litellm-dashboard/src/components/templates/KeyInfoHeader.tsx +++ b/ui/litellm-dashboard/src/components/templates/KeyInfoHeader.tsx @@ -80,11 +80,6 @@ export function KeyInfoHeader({ {canModifyKey && ( - {onResetSpend && ( - - )} + {onResetSpend && ( + + )} diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx index c19866e5d84..f269ad96a27 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx @@ -642,7 +642,7 @@ describe("KeyInfoView", () => { await waitFor(() => { expect(screen.getByText("Reset Key Spend")).toBeInTheDocument(); - expect(screen.getByText(/reset to \$0/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^reset$/i })).toBeInTheDocument(); }); }); @@ -670,7 +670,7 @@ describe("KeyInfoView", () => { }); // Click the confirm button in the modal - await userEvent.click(screen.getByRole("button", { name: /reset to \$0/i })); + await userEvent.click(screen.getByRole("button", { name: /^reset$/i })); await waitFor(() => { expect(mockResetKeySpendMutate).toHaveBeenCalledWith( diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx index b77b13b1591..6733cd6a595 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -442,7 +442,8 @@ export default function KeyInfoView({ open={isResetSpendModalOpen} onOk={handleResetSpend} onCancel={() => setIsResetSpendModalOpen(false)} - okText="Reset to $0" + okText="Reset" + okButtonProps={{ danger: true }} confirmLoading={resetSpendLoading} >

From 98b9bc8b722b3f8d590d4cbe678280f8fa95cca7 Mon Sep 17 00:00:00 2001 From: milan-berri Date: Wed, 4 Mar 2026 01:43:02 +0200 Subject: [PATCH 66/69] fix: resolve base_model in /cost/estimate for Azure custom deployments (#22724) The _resolve_model_for_cost_lookup function was only checking litellm_params.model when resolving model names from the router. For Azure custom deployment names (e.g. azure/openai/gpt-5.3-codex), this deployment name doesn't exist in the model cost map, so cost returned /bin/zsh. Now checks model_info.base_model and litellm_params.base_model first, falling back to litellm_params.model only if no base_model is set. This matches how the router resolves base_model everywhere else. --- .../cost_tracking_settings.py | 15 ++- .../test_cost_tracking_settings.py | 120 ++++++++++++++++++ 2 files changed, 133 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/management_endpoints/cost_tracking_settings.py b/litellm/proxy/management_endpoints/cost_tracking_settings.py index 6cdadfe216a..38dd4578c05 100644 --- a/litellm/proxy/management_endpoints/cost_tracking_settings.py +++ b/litellm/proxy/management_endpoints/cost_tracking_settings.py @@ -54,16 +54,27 @@ def _resolve_model_for_cost_lookup(model: str) -> Tuple[str, Optional[str]]: deployments = llm_router.get_model_list(model_name=model) if deployments and len(deployments) > 0: - # Get the first deployment's litellm model first_deployment = deployments[0] litellm_params = first_deployment.get("litellm_params", {}) + model_info = first_deployment.get("model_info", {}) + + # Check base_model first (needed for Azure custom deployment names) + base_model = model_info.get("base_model") or litellm_params.get( + "base_model" + ) + if base_model: + verbose_proxy_logger.debug( + f"Resolved model '{model}' to base_model '{base_model}' from router" + ) + custom_llm_provider = litellm_params.get("custom_llm_provider") + return base_model, custom_llm_provider + resolved_model = litellm_params.get("model") if resolved_model: verbose_proxy_logger.debug( f"Resolved model '{model}' to '{resolved_model}' from router" ) - # Extract custom_llm_provider if present custom_llm_provider = litellm_params.get("custom_llm_provider") return resolved_model, custom_llm_provider except Exception as e: diff --git a/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py b/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py index 275240dcc9e..1284cceba26 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py +++ b/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py @@ -270,3 +270,123 @@ class TestCostTrackingSettings: assert "error" in response_data["detail"] assert "STORE_MODEL_IN_DB" in response_data["detail"]["error"] + + +class TestResolveModelForCostLookup: + """Tests for _resolve_model_for_cost_lookup base_model resolution.""" + + def test_resolves_base_model_for_azure_deployment(self): + """ + When a model group has base_model set in model_info, + _resolve_model_for_cost_lookup should return the base_model + instead of the raw litellm_params.model (Azure deployment name). + """ + from litellm.proxy.management_endpoints.cost_tracking_settings import ( + _resolve_model_for_cost_lookup, + ) + + mock_router = MagicMock() + mock_router.get_model_list.return_value = [ + { + "model_name": "gpt-5.3-codex", + "litellm_params": { + "model": "azure/openai/gpt-5.3-codex", + "api_base": "https://fake.openai.azure.com/", + "api_key": "fake-key", + }, + "model_info": { + "id": "test-id", + "base_model": "azure/gpt-4o", + }, + } + ] + + with patch( + "litellm.proxy.proxy_server.llm_router", + mock_router, + ): + resolved_model, provider = _resolve_model_for_cost_lookup("gpt-5.3-codex") + + assert resolved_model == "azure/gpt-4o" + mock_router.get_model_list.assert_called_once_with(model_name="gpt-5.3-codex") + + def test_falls_back_to_litellm_params_model_when_no_base_model(self): + """ + When no base_model is set, should fall back to litellm_params.model. + """ + from litellm.proxy.management_endpoints.cost_tracking_settings import ( + _resolve_model_for_cost_lookup, + ) + + mock_router = MagicMock() + mock_router.get_model_list.return_value = [ + { + "model_name": "gpt-4", + "litellm_params": { + "model": "openai/gpt-4", + }, + "model_info": { + "id": "test-id", + }, + } + ] + + with patch( + "litellm.proxy.proxy_server.llm_router", + mock_router, + ): + resolved_model, provider = _resolve_model_for_cost_lookup("gpt-4") + + assert resolved_model == "openai/gpt-4" + + def test_resolves_base_model_from_litellm_params(self): + """ + When base_model is in litellm_params (not model_info), + it should still be resolved. + """ + from litellm.proxy.management_endpoints.cost_tracking_settings import ( + _resolve_model_for_cost_lookup, + ) + + mock_router = MagicMock() + mock_router.get_model_list.return_value = [ + { + "model_name": "my-azure-model", + "litellm_params": { + "model": "azure/my-custom-deployment", + "base_model": "azure/gpt-4o-mini", + }, + "model_info": { + "id": "test-id", + }, + } + ] + + with patch( + "litellm.proxy.proxy_server.llm_router", + mock_router, + ): + resolved_model, provider = _resolve_model_for_cost_lookup( + "my-azure-model" + ) + + assert resolved_model == "azure/gpt-4o-mini" + + def test_returns_original_model_when_no_router(self): + """ + When no router is available, should return the original model name. + """ + from litellm.proxy.management_endpoints.cost_tracking_settings import ( + _resolve_model_for_cost_lookup, + ) + + with patch( + "litellm.proxy.proxy_server.llm_router", + None, + ): + resolved_model, provider = _resolve_model_for_cost_lookup( + "azure/openai/gpt-5.3-codex" + ) + + assert resolved_model == "azure/openai/gpt-5.3-codex" + assert provider is None From 0a1b2635d7f8c2e17f9c3c5732bb9b8f3dc23c08 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 3 Mar 2026 15:54:34 -0800 Subject: [PATCH 67/69] fix: allow team admins to access /key/{key}/reset_spend route The route-level auth check was blocking internal_user role (team admins) from reaching /key/{key}/reset_spend because KEY_RESET_SPEND was missing from key_management_routes. Added it so team admins pass the route check and the endpoint's existing _check_proxy_or_team_admin_for_key enforces actual authorization. Co-Authored-By: Claude Sonnet 4.6 --- litellm/proxy/_types.py | 1 + .../proxy/auth/test_route_checks.py | 74 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 5122c64ea64..8d49020461d 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -512,6 +512,7 @@ class LiteLLMRoutes(enum.Enum): KeyManagementRoutes.KEY_UNBLOCK.value, KeyManagementRoutes.KEY_BULK_UPDATE.value, KeyManagementRoutes.TEAM_DAILY_ACTIVITY.value, + KeyManagementRoutes.KEY_RESET_SPEND.value, ] management_routes = [ diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index ec1a13b8abc..f1e96f3e660 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -1116,3 +1116,77 @@ def test_route_in_additional_public_routes_exact_match(): assert route_in_additonal_public_routes("/status") is True # Non-matching routes should fail assert route_in_additonal_public_routes("/other") is False + + +def test_internal_user_can_access_key_reset_spend_route(): + """ + Regression test: team admins (role=internal_user) should pass the route-level + check for /key/{hash}/reset_spend. The endpoint itself enforces team admin status. + """ + user_obj = LiteLLM_UserTable( + user_id="team-admin-user", + user_email="teamadmin@example.com", + user_role=LitellmUserRoles.INTERNAL_USER.value, + ) + valid_token = UserAPIKeyAuth( + user_id="team-admin-user", + user_role=LitellmUserRoles.INTERNAL_USER.value, + ) + request = MagicMock(spec=Request) + request.query_params = {} + + key_hash = "baec26d2901589fe9fec76610e6e2be4895cdd8e19b3ada9a4fa2eb85e1901ae" + route = f"/key/{key_hash}/reset_spend" + + # Should not raise — the route-level check must pass for team admins + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=user_obj, + _user_role=LitellmUserRoles.INTERNAL_USER.value, + route=route, + request=request, + valid_token=valid_token, + request_data={}, + ) + + +def test_non_admin_non_team_admin_cannot_access_config_update_but_can_attempt_reset_spend(): + """ + An internal_user passes the route check for /key/{hash}/reset_spend + (authorization is deferred to the endpoint), but is still blocked from + admin-only routes like /config/update. + """ + user_obj = LiteLLM_UserTable( + user_id="regular-user", + user_email="user@example.com", + user_role=LitellmUserRoles.INTERNAL_USER.value, + ) + valid_token = UserAPIKeyAuth( + user_id="regular-user", + user_role=LitellmUserRoles.INTERNAL_USER.value, + ) + request = MagicMock(spec=Request) + request.query_params = {} + + key_hash = "baec26d2901589fe9fec76610e6e2be4895cdd8e19b3ada9a4fa2eb85e1901ae" + + # /key/{hash}/reset_spend passes the route check for internal_user + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=user_obj, + _user_role=LitellmUserRoles.INTERNAL_USER.value, + route=f"/key/{key_hash}/reset_spend", + request=request, + valid_token=valid_token, + request_data={}, + ) + + # /config/update is still blocked + with pytest.raises(Exception) as exc_info: + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=user_obj, + _user_role=LitellmUserRoles.INTERNAL_USER.value, + route="/config/update", + request=request, + valid_token=valid_token, + request_data={}, + ) + assert "Only proxy admin can be used to generate" in str(exc_info.value) From 326ff422f1faad0a7654289d97824270be6c5686 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 3 Mar 2026 15:54:46 -0800 Subject: [PATCH 68/69] fix(ui): Audit logs table and drawer polish Table: - Use DefaultProxyAdminTag for changed_by column - Remove tooltips on Object ID and API Key columns - Rename API Key column to API Key (Hash) - Move pagination controls to upper-right of filter bar; add icon-only refresh button Drawer: - Object ID is now copyable - API Key (Hash) is copyable and no longer truncated - Changed By uses DefaultProxyAdminTag - Expand JSON view boxes from max-h-72 to max-h-96 - Remove unnecessary vertical scrollbar (drop overflow-auto h-full from body div; use flex column layout so header and content flow naturally) Co-Authored-By: Claude Sonnet 4.6 --- .../AuditLogDrawer/AuditLogDrawer.tsx | 40 +++--- .../src/components/view_logs/audit_logs.tsx | 125 ++++++------------ 2 files changed, 62 insertions(+), 103 deletions(-) diff --git a/ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.tsx b/ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.tsx index 1e8dca34f22..de76709f5b2 100644 --- a/ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.tsx @@ -1,7 +1,10 @@ -import { Drawer, Tag, Tooltip } from "antd"; +import { Drawer, Tag, Typography } from "antd"; import { CloseOutlined } from "@ant-design/icons"; import moment from "moment"; import { AuditLogEntry } from "../columns"; +import DefaultProxyAdminTag from "../../common_components/DefaultProxyAdminTag"; + +const { Text } = Typography; interface AuditLogDrawerProps { open: boolean; @@ -26,7 +29,7 @@ const ACTION_COLOR: Record = { function JsonBlock({ value }: { value: Record }) { return ( -

+    
       {JSON.stringify(value, null, 2)}
     
); @@ -92,7 +95,7 @@ function DiffSection({ log }: { log: AuditLogEntry }) { : { note: "No differing fields detected" }; } - const renderValue = (value: Record | null | undefined, label: string) => { + const renderValue = (value: Record | null | undefined) => { if (!value || Object.keys(value).length === 0) { return

N/A

; } @@ -125,11 +128,11 @@ function DiffSection({ log }: { log: AuditLogEntry }) {

Before

- {renderValue(displayBefore, "before")} + {renderValue(displayBefore)}

After

- {renderValue(displayAfter, "after")} + {renderValue(displayAfter)}
); @@ -150,10 +153,10 @@ export function AuditLogDrawer({ open, onClose, log }: AuditLogDrawerProps) { closable={false} mask={true} maskClosable={true} - styles={{ body: { padding: 0 }, header: { display: "none" } }} + styles={{ body: { padding: 0, display: "flex", flexDirection: "column" }, header: { display: "none" } }} > {/* Header */} -
+
{log.action} @@ -172,7 +175,7 @@ export function AuditLogDrawer({ open, onClose, log }: AuditLogDrawerProps) {
{/* Body */} -
+
{/* Metadata */}

@@ -182,21 +185,22 @@ export function AuditLogDrawer({ open, onClose, log }: AuditLogDrawerProps) { - {log.object_id} - + + {log.object_id} + } /> - } + /> + - - {log.changed_by_api_key.slice(0, 12)}… - - + + {log.changed_by_api_key} + ) : ( "—" ) diff --git a/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx b/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx index 693318acca8..71300fe0427 100644 --- a/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx @@ -1,12 +1,13 @@ import { useState } from "react"; import { useQuery, keepPreviousData } from "@tanstack/react-query"; -import { Table, Tag, Input, Select, Button, Tooltip } from "antd"; +import { Table, Tag, Input, Select, Button, Pagination } from "antd"; import { ReloadOutlined } from "@ant-design/icons"; -import type { ColumnsType, TablePaginationConfig } from "antd/es/table"; +import type { ColumnsType } from "antd/es/table"; import moment from "moment"; import { uiAuditLogsCall } from "../networking"; import { AuditLogEntry } from "./columns"; import { AuditLogDrawer } from "./AuditLogDrawer/AuditLogDrawer"; +import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag"; const { Search } = Input; @@ -97,14 +98,7 @@ export default function AuditLogs({ placeholderData: keepPreviousData, }); - const handleFilterChange = () => { - // Reset to page 1 whenever a filter changes - setPage(1); - }; - - const handleTableChange = (pagination: TablePaginationConfig) => { - setPage(pagination.current ?? 1); - }; + const resetPage = () => setPage(1); const handleRowClick = (log: AuditLogEntry) => { setSelectedLog(log); @@ -146,9 +140,7 @@ export default function AuditLogs({ dataIndex: "object_id", key: "object_id", render: (val: string) => ( - - {val} - + {val} ), }, { @@ -156,18 +148,16 @@ export default function AuditLogs({ dataIndex: "changed_by", key: "changed_by", width: 200, - render: (val: string) => val || "—", + render: (val: string) => , }, { - title: "API Key", + title: "API Key (Hash)", dataIndex: "changed_by_api_key", key: "changed_by_api_key", width: 140, render: (val: string) => val ? ( - - {val.slice(0, 12)}… - + {val.slice(0, 12)}… ) : ( "—" ), @@ -212,76 +202,37 @@ export default function AuditLogs({

Audit Logs

-
- {/* Filters */} -
+ {/* Filters + pagination on same row */} +
{ - setObjectId(val); - handleFilterChange(); - }} - onChange={(e) => { - if (!e.target.value) { - setObjectId(""); - handleFilterChange(); - } - }} + onSearch={(val) => { setObjectId(val); resetPage(); }} + onChange={(e) => { if (!e.target.value) { setObjectId(""); resetPage(); } }} /> { - setChangedBy(val); - handleFilterChange(); - }} - onChange={(e) => { - if (!e.target.value) { - setChangedBy(""); - handleFilterChange(); - } - }} + onSearch={(val) => { setChangedBy(val); resetPage(); }} + onChange={(e) => { if (!e.target.value) { setChangedBy(""); resetPage(); } }} /> { - setTeamId(val); - handleFilterChange(); - }} - onChange={(e) => { - if (!e.target.value) { - setTeamId(""); - handleFilterChange(); - } - }} + onSearch={(val) => { setTeamId(val); resetPage(); }} + onChange={(e) => { if (!e.target.value) { setTeamId(""); resetPage(); } }} /> { - setKeyHash(val); - handleFilterChange(); - }} - onChange={(e) => { - if (!e.target.value) { - setKeyHash(""); - handleFilterChange(); - } - }} + onSearch={(val) => { setKeyHash(val); resetPage(); }} + onChange={(e) => { if (!e.target.value) { setKeyHash(""); resetPage(); } }} /> { - setTableName(val); - handleFilterChange(); - }} + onChange={(val) => { setTableName(val); resetPage(); }} /> + + {/* Pagination + refresh pushed to the right */} +
+
- {/* Table */} + {/* Table — pagination handled in header */} columns={columns} dataSource={auditLogs} rowKey="id" loading={query.isLoading} size="small" + pagination={false} onRow={(record) => ({ onClick: () => handleRowClick(record), style: { cursor: "pointer" }, })} - pagination={{ - current: page, - pageSize: PAGE_SIZE, - total, - showTotal: (t) => `${t} total`, - showSizeChanger: false, - onChange: (p) => setPage(p), - }} - onChange={handleTableChange} />
From 5fa0c6e994de825502c3d5b1f8278a5ef03a4850 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 3 Mar 2026 16:00:46 -0800 Subject: [PATCH 69/69] fix(ui): Copyable JSON blocks in audit drawer; custom table spinner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace plain JsonBlock with CopyableJsonBlock: header row with label and copy icon (CheckOutlined on success), pre block below — matches the spend logs RequestResponsePanel pattern - Key-table rows use the same card chrome for visual consistency - Remove separate "Changes" section label (now redundant with block headers) - Table loading spinner replaced with custom Spin + LoadingOutlined Co-Authored-By: Claude Sonnet 4.6 --- .../AuditLogDrawer/AuditLogDrawer.tsx | 105 ++++++++++++------ .../src/components/view_logs/audit_logs.tsx | 9 +- 2 files changed, 78 insertions(+), 36 deletions(-) diff --git a/ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.tsx b/ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.tsx index de76709f5b2..19989ef4882 100644 --- a/ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/AuditLogDrawer/AuditLogDrawer.tsx @@ -1,5 +1,6 @@ import { Drawer, Tag, Typography } from "antd"; -import { CloseOutlined } from "@ant-design/icons"; +import { CloseOutlined, CopyOutlined, CheckOutlined } from "@ant-design/icons"; +import { useState, useCallback } from "react"; import moment from "moment"; import { AuditLogEntry } from "../columns"; import DefaultProxyAdminTag from "../../common_components/DefaultProxyAdminTag"; @@ -27,11 +28,48 @@ const ACTION_COLOR: Record = { rotated: "orange", }; -function JsonBlock({ value }: { value: Record }) { +function CopyableJsonBlock({ label, value }: { label: string; value: Record }) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(async () => { + try { + const text = JSON.stringify(value, null, 2); + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + } else { + const el = document.createElement("textarea"); + el.value = text; + el.style.position = "fixed"; + el.style.opacity = "0"; + document.body.appendChild(el); + el.focus(); + el.select(); + document.execCommand("copy"); + document.body.removeChild(el); + } + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (e) { + console.error("Copy failed:", e); + } + }, [value]); + return ( -
-      {JSON.stringify(value, null, 2)}
-    
+
+
+ {label} + +
+
+        {JSON.stringify(value, null, 2)}
+      
+
); } @@ -95,45 +133,51 @@ function DiffSection({ log }: { log: AuditLogEntry }) { : { note: "No differing fields detected" }; } - const renderValue = (value: Record | null | undefined) => { + const renderValue = (label: string, value: Record | null | undefined) => { if (!value || Object.keys(value).length === 0) { - return

N/A

; + return ( +
+
+ {label} +
+

N/A

+
+ ); } - // For key table updates, filter to only show meaningful fields + // For key table updates, show only meaningful fields as plain text if (isKeyTable && isUpdateAction) { const knownKeyFields = ["token", "spend", "max_budget"]; const hasOnlyKnown = Object.keys(value).every((k) => knownKeyFields.includes(k)); if (hasOnlyKnown && !("note" in value)) { return ( -
- {value.token !== undefined && ( -

Token: {value.token ?? "N/A"}

- )} - {value.spend !== undefined && ( -

Spend: ${Number(value.spend).toFixed(6)}

- )} - {value.max_budget !== undefined && ( -

Max Budget: ${Number(value.max_budget).toFixed(6)}

- )} +
+
+ {label} +
+
+ {value.token !== undefined && ( +

Token: {value.token ?? "N/A"}

+ )} + {value.spend !== undefined && ( +

Spend: ${Number(value.spend).toFixed(6)}

+ )} + {value.max_budget !== undefined && ( +

Max Budget: ${Number(value.max_budget).toFixed(6)}

+ )} +
); } } - return ; + return ; }; return (
-
-

Before

- {renderValue(displayBefore)} -
-
-

After

- {renderValue(displayAfter)} -
+ {renderValue("Before", displayBefore)} + {renderValue("After", displayAfter)}
); } @@ -209,12 +253,7 @@ export function AuditLogDrawer({ open, onClose, log }: AuditLogDrawerProps) {
{/* Diff */} -
-

- Changes -

- -
+
); diff --git a/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx b/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx index 71300fe0427..b16ba30049d 100644 --- a/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useQuery, keepPreviousData } from "@tanstack/react-query"; -import { Table, Tag, Input, Select, Button, Pagination } from "antd"; -import { ReloadOutlined } from "@ant-design/icons"; +import { Table, Tag, Input, Select, Button, Pagination, Spin } from "antd"; +import { ReloadOutlined, LoadingOutlined } from "@ant-design/icons"; import type { ColumnsType } from "antd/es/table"; import moment from "moment"; import { uiAuditLogsCall } from "../networking"; @@ -285,7 +285,10 @@ export default function AuditLogs({ columns={columns} dataSource={auditLogs} rowKey="id" - loading={query.isLoading} + loading={{ + spinning: query.isLoading, + indicator: } size="small" />, + }} size="small" pagination={false} onRow={(record) => ({